diff --git a/appinfo/routes.php b/appinfo/routes.php index 269682834..61d5b44d1 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -127,6 +127,14 @@ 'apiVersion' => 'v2.2' ] ], + [ + 'name' => 'api#transferOwner', + 'url' => '/api/{apiVersion}/form/transfer', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v2.2' + ] + ], [ 'name' => 'api#deleteForm', 'url' => '/api/{apiVersion}/form/{id}', diff --git a/docs/API.md b/docs/API.md index 5871dd534..4de86d4de 100644 --- a/docs/API.md +++ b/docs/API.md @@ -221,6 +221,20 @@ Update a single or multiple properties of a form-object. Concerns **only** the F ``` "data": 3 ``` +### Transfer form ownership +Transfer the ownership of a form to another user +- Endpoint: `/api/v2.2/form/transfer` +- Method: `POST` +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form to tranfer | + | _uid_ | Integer | ID of the new form owner | +- Restrictions: The initiator must be the current form owner. +- Response: **Status-Code OK**, as well as the id of the new owner. +``` +"data": "user1" +``` ### Delete a form - Endpoint: `/api/v2.2/form/{id}` diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index d5abc9ed6..a777751e4 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -51,6 +51,7 @@ use OCP\AppFramework\OCS\OCSException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCSController; +use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IL10N; use OCP\IRequest; @@ -351,6 +352,50 @@ public function updateForm(int $id, array $keyValuePairs): DataResponse { return new DataResponse($form->getId()); } + /** + * @NoAdminRequired + * + * Transfer ownership of a form to another user + * + * @param int $formId id of the form to update + * @param string $uid id of the new owner + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function transferOwner(int $formId, string $uid): DataResponse { + $this->logger->debug('Updating owner: formId: {formId}, userId: {uid}', [ + 'formId' => $formId, + 'uid' => $uid + ]); + + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new NotFoundException('Could not find form'); + } + + $user = $this->userManager->get($uid); + if($user == null) { + $this->logger->debug('Could not find new form owner'); + throw new OCSBadRequestException('Could not find new form owner'); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException('This form is not owned by the current user'); + } + + // update form owner + $form->setOwnerId($uid); + + // Update changed Columns in Db. + $this->formMapper->update($form); + + return new DataResponse($form->getOwnerId()); + } + /** * @CORS * @NoAdminRequired diff --git a/lib/Db/ShareMapper.php b/lib/Db/ShareMapper.php index e56ae0cfd..a7e1a8de4 100644 --- a/lib/Db/ShareMapper.php +++ b/lib/Db/ShareMapper.php @@ -103,7 +103,28 @@ public function findPublicShareByHash(string $hash): Share { return $this->findEntity($qb); } + /** + * Find Share by formId and user id + * @param int $formId + * @param string $uid + * @return Share + * @throws MultipleObjectsReturnedException if more than one result + * @throws DoesNotExistException if not found + */ + public function findPublicShareByFormIdAndUid(int $formId, string $uid): Share { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('share_with', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR)) + ); + + return $this->findEntity($qb); + } /** * Delete a share * @param int $id of the share. diff --git a/src/Forms.vue b/src/Forms.vue index e3c400f54..d62c43dbe 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -230,10 +230,12 @@ export default { mounted() { subscribe('forms:last-updated:set', (id) => this.onLastUpdatedByEventBus(id)) + subscribe('forms:ownership-transfered', (id) => this.onDeleteForm(id)) }, unmounted() { unsubscribe('forms:last-updated:set', (id) => this.onLastUpdatedByEventBus(id)) + unsubscribe('forms:ownership-transfered', (id) => this.onDeleteForm(id)) }, methods: { diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index a94133189..e6ca684f9 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -88,6 +88,8 @@ {{ t('forms', 'Message to show after a user submitted the form. Please note that the message will not be translated!') }} + + @@ -96,6 +98,7 @@ import moment from '@nextcloud/moment' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePicker.js' import ShareTypes from '../../mixins/ShareTypes.js' +import TransferOwnership from './TransferOwnership.vue' import { directive as ClickOutside } from 'v-click-outside' import { loadState } from '@nextcloud/initial-state' @@ -104,6 +107,7 @@ export default { components: { NcCheckboxRadioSwitch, NcDateTimePicker, + TransferOwnership, }, directives: { @@ -286,6 +290,11 @@ export default { margin-inline-start: 40px; } +.sidebar-tabs__content { + display: flex; + flex-direction: column; + gap: 8px; +} .submission-message { &__description { color: var(--color-text-maxcontrast); diff --git a/src/components/SidebarTabs/SharingSearchDiv.vue b/src/components/SidebarTabs/SharingSearchDiv.vue index f82cc7ee8..e6c0d849d 100644 --- a/src/components/SidebarTabs/SharingSearchDiv.vue +++ b/src/components/SidebarTabs/SharingSearchDiv.vue @@ -41,22 +41,16 @@ diff --git a/src/components/SidebarTabs/TransferOwnership.vue b/src/components/SidebarTabs/TransferOwnership.vue new file mode 100644 index 000000000..7cc2ac229 --- /dev/null +++ b/src/components/SidebarTabs/TransferOwnership.vue @@ -0,0 +1,182 @@ + + + + + + + diff --git a/src/mixins/UserSearchMixin.js b/src/mixins/UserSearchMixin.js new file mode 100644 index 000000000..810edf17c --- /dev/null +++ b/src/mixins/UserSearchMixin.js @@ -0,0 +1,222 @@ +/** + - @copyright Copyright (c) 2021 Jonas Rittershofer + - + - @author John Molakvoæ + - @author Jonas Rittershofer + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import axios from '@nextcloud/axios' +import debounce from 'debounce' + +import OcsResponse2Data from '../utils/OcsResponse2Data.js' +import logger from '../utils/Logger.js' +import ShareTypes from './ShareTypes.js' + +export default { + mixins: [ShareTypes], + data() { + return { + loading: false, + + query: '', + // TODO: have a global mixin for this, shared with server? + maxAutocompleteResults: parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 200, + minSearchStringLength: parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0, + // Search Results + recommendations: [], + suggestions: [], + } + }, + computed: { + /** + * Is the search query valid ? + * + * @return {boolean} + */ + isValidQuery() { + return this.query && this.query.trim() !== '' && this.query.length > this.minSearchStringLength + }, + /** + * Text when there is no Results to be shown + * + * @return {string} + */ + noResultText() { + if (!this.query) { + return t('forms', 'No recommendations. Start typing.') + } + return t('forms', 'No elements found.') + }, + }, + methods: { + /** + * Search for suggestions + * + * @param {string} query The search query to search for + */ + async asyncSearch(query) { + // save query to check if valid + this.query = query.trim() + if (this.isValidQuery) { + // already set loading to have proper ux feedback during debounce + this.loading = true + await this.debounceGetSuggestions(query) + } + }, + + /** + * Debounce getSuggestions + * + * @param {...*} args arguments to pass + */ + debounceGetSuggestions: debounce(function(...args) { + this.getSuggestions(...args) + }, 300), + + /** + * Get suggestions + * + * @param {string} query the search query + */ + async getSuggestions(query) { + this.loading = true + + // Search for all used share-types, except public link. + const shareType = this.SHARE_TYPES_USED.filter(type => type !== this.SHARE_TYPES.SHARE_TYPE_LINK) + + try { + const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), { + params: { + format: 'json', + itemType: 'file', + perPage: this.maxAutocompleteResults, + search: query, + shareType, + }, + }) + + const data = OcsResponse2Data(request) + const exact = data.exact + delete data.exact // removing exact from general results + + const exactSuggestions = this.formatSearchResults(exact) + const suggestions = this.formatSearchResults(data) + + this.suggestions = exactSuggestions.concat(suggestions) + } catch (error) { + logger.error('Loading Suggestions failed.', { error }) + } finally { + this.loading = false + } + }, + + /** + * Get the sharing recommendations + */ + async getRecommendations() { + this.loading = true + + try { + const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees_recommended'), { + params: { + format: 'json', + itemType: 'file', + }, + }) + + this.recommendations = this.formatSearchResults(OcsResponse2Data(request).exact) + } catch (error) { + logger.error('Fetching recommendations failed.', { error }) + } finally { + this.loading = false + } + }, + /** + * A OCS Sharee response + * @typedef {{label: string, shareWithDisplayNameUnique: string, value: { shareType: number, shareWith: string }, status?: unknown }} Sharee + */ + + /** + * Format search results + * + * @param {Record} results Results as returned by search + * @return {Sharee[]} results as we use them on storage + */ + formatSearchResults(results) { + // flatten array of arrays + const flatResults = Object.values(results).flat() + + return this.filterUnwantedShares(flatResults) + .map(share => this.formatForMultiselect(share)) + // sort by type so we can get user&groups first... + .sort((a, b) => a.shareType - b.shareType) + }, + + /** + * Remove static unwanted shares from search results + * Existing shares must be done dynamically to account for new shares. + * + * @param {Sharee[]} shares the array of share objects + * @return {Sharee[]} + */ + filterUnwantedShares(shares) { + return shares.filter((share) => { + // only use proper objects + if (typeof share !== 'object') { + return false + } + + try { + // filter out current user + if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER + && share.value.shareWith === getCurrentUser().uid) { + return false + } + + // All good, let's add the suggestion + return true + } catch { + return false + } + }) + }, + + /** + * Format shares for the multiselect options + * + * @param {Sharee} share Share in search formatting + * @return {object} Share in multiselect formatting + */ + formatForMultiselect(share) { + return { + shareWith: share.value.shareWith, + shareType: share.value.shareType, + user: share.value.shareWith, + isNoUser: share.value.shareType !== this.SHARE_TYPES.SHARE_TYPE_USER, + id: share.value.shareWith, + displayName: share.label, + subname: share.shareWithDisplayNameUnique, + iconSvg: this.shareTypeToIcon(share.value.shareType), + // Vue unique binding to render within Multiselect's AvatarSelectOption + key: share.value.shareWith + '-' + share.value.shareType, + } + }, + }, +}