Skip to content

Commit

Permalink
transfer ownership of a form
Browse files Browse the repository at this point in the history
Signed-off-by: hamza221 <[email protected]>
  • Loading branch information
hamza221 authored and susnux committed Dec 9, 2023
1 parent 8ec7f06 commit 25b4c0c
Show file tree
Hide file tree
Showing 9 changed files with 517 additions and 198 deletions.
8 changes: 8 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand Down
14 changes: 14 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
45 changes: 45 additions & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
]);

Check warning on line 370 in lib/Controller/ApiController.php

View check run for this annotation

Codecov / codecov/patch

lib/Controller/ApiController.php#L366-L370

Added lines #L366 - L370 were not covered by tests

try {
$form = $this->formMapper->findById($formId);
} catch (IMapperException $e) {
$this->logger->debug('Could not find form');
throw new NotFoundException('Could not find form');

Check warning on line 376 in lib/Controller/ApiController.php

View check run for this annotation

Codecov / codecov/patch

lib/Controller/ApiController.php#L373-L376

Added lines #L373 - L376 were not covered by tests
}

$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');

Check warning on line 382 in lib/Controller/ApiController.php

View check run for this annotation

Codecov / codecov/patch

lib/Controller/ApiController.php#L379-L382

Added lines #L379 - L382 were not covered by tests
}

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');

Check warning on line 387 in lib/Controller/ApiController.php

View check run for this annotation

Codecov / codecov/patch

lib/Controller/ApiController.php#L385-L387

Added lines #L385 - L387 were not covered by tests
}

// update form owner
$form->setOwnerId($uid);

Check warning on line 391 in lib/Controller/ApiController.php

View check run for this annotation

Codecov / codecov/patch

lib/Controller/ApiController.php#L391

Added line #L391 was not covered by tests

// Update changed Columns in Db.
$this->formMapper->update($form);

Check warning on line 394 in lib/Controller/ApiController.php

View check run for this annotation

Codecov / codecov/patch

lib/Controller/ApiController.php#L394

Added line #L394 was not covered by tests

return new DataResponse($form->getOwnerId());

Check warning on line 396 in lib/Controller/ApiController.php

View check run for this annotation

Codecov / codecov/patch

lib/Controller/ApiController.php#L396

Added line #L396 was not covered by tests
}

/**
* @CORS
* @NoAdminRequired
Expand Down
21 changes: 21 additions & 0 deletions lib/Db/ShareMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Check warning on line 115 in lib/Db/ShareMapper.php

View check run for this annotation

Codecov / codecov/patch

lib/Db/ShareMapper.php#L114-L115

Added lines #L114 - L115 were not covered by tests

$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))
);

Check warning on line 124 in lib/Db/ShareMapper.php

View check run for this annotation

Codecov / codecov/patch

lib/Db/ShareMapper.php#L117-L124

Added lines #L117 - L124 were not covered by tests

return $this->findEntity($qb);

Check warning on line 126 in lib/Db/ShareMapper.php

View check run for this annotation

Codecov / codecov/patch

lib/Db/ShareMapper.php#L126

Added line #L126 was not covered by tests
}
/**
* Delete a share
* @param int $id of the share.
Expand Down
2 changes: 2 additions & 0 deletions src/Forms.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions src/components/SidebarTabs/SettingsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
{{ t('forms', 'Message to show after a user submitted the form. Please note that the message will not be translated!') }}
</div>
</div>
<TransferOwnership :form="form" />
</div>
</template>

Expand All @@ -96,6 +97,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'
Expand All @@ -104,6 +106,7 @@ export default {
components: {
NcCheckboxRadioSwitch,
NcDateTimePicker,
TransferOwnership,
},
directives: {
Expand Down
200 changes: 2 additions & 198 deletions src/components/SidebarTabs/SharingSearchDiv.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,16 @@
</template>

<script>
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import debounce from 'debounce'
import OcsResponse2Data from '../../utils/OcsResponse2Data.js'
import ShareTypes from '../../mixins/ShareTypes.js'
import logger from '../../utils/Logger.js'
import UserSearchMixin from '../../mixins/UserSearchMixin.js'
export default {
components: {
NcSelect,
},
mixins: [ShareTypes],
mixins: [UserSearchMixin],
props: {
currentShares: {
Expand All @@ -69,21 +63,6 @@ export default {
},
},
data() {
return {
loading: false,
query: '',
// TODO: have a global mixin for this, shared with server?
minSearchStringLength: parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0,
maxAutocompleteResults: parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 200,
// Search Results
recommendations: [],
suggestions: [],
}
},
computed: {
/**
* Multiselect options. Recommendations by default, direct search when search query is valid.
Expand All @@ -100,27 +79,6 @@ export default {
return this.recommendations.filter(item => !this.currentShares.find(share => share.shareWith === item.shareWith && share.shareType === item.shareType))
},
/**
* 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.')
},
/**
* Show Loading if loading is either set by parent or by this module (search)
*/
Expand Down Expand Up @@ -148,160 +106,6 @@ export default {
}
this.$emit('add-share', newShare)
},
/**
* 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<string, Sharee>} 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,
}
},
},
}
</script>
Expand Down
Loading

0 comments on commit 25b4c0c

Please sign in to comment.