Fix users & groups sharing

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2020-05-05 14:30:02 +02:00
parent c6b515fa3d
commit e060cfdfc7
No known key found for this signature in database
GPG key ID: 60C25B8C072916CF
9 changed files with 433 additions and 232 deletions

View file

@ -60,7 +60,5 @@ return [
['name' => 'api#insertSubmission', 'url' => '/api/v1/submission/insert', 'verb' => 'POST'],
['name' => 'api#deleteSubmission', 'url' => '/api/v1/submission/{id}', 'verb' => 'DELETE'],
['name' => 'system#get_site_users_and_groups', 'url' => '/get/siteusers', 'verb' => 'POST'],
]
];

View file

@ -26,6 +26,7 @@
namespace OCA\Forms\Controller;
use Exception;
use OCA\Forms\Db\Answer;
use OCA\Forms\Db\AnswerMapper;
use OCA\Forms\Db\Form;
@ -127,6 +128,7 @@ class ApiController extends Controller {
'hash' => $form->getHash(),
'title' => $form->getTitle(),
'expires' => $form->getExpires(),
'partial' => true
];
}
@ -210,6 +212,21 @@ class ApiController extends Controller {
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
}
// Make sure we only store id
try {
if ($keyValuePairs['access']) {
$keyValuePairs['access']['users'] = array_map(function (array $user): string {
return $user['id'];
}, $keyValuePairs['access']['users']);
$keyValuePairs['access']['groups'] = array_map(function (array $group): string {
return $group['id'];
}, $keyValuePairs['access']['groups']);
}
} catch (Exception $e) {
$this->logger->debug('Malformed access');
return new Http\JSONResponse(['message' => 'Malformed access'], Http::STATUS_BAD_REQUEST);
}
// Create FormEntity with given Params & Id.
$form = Form::fromParams($keyValuePairs);
$form->setId($id);

View file

@ -1,95 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2017 Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @author affan98 <affan98@gmail.com>
* @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Forms\Controller;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\IGroupManager;
use OCP\IUserManager;
use OCP\IRequest;
class SystemController extends Controller {
public function __construct(
string $appName,
IGroupManager $groupManager,
IUserManager $userManager,
IRequest $request
) {
parent::__construct($appName, $request);
$this->groupManager = $groupManager;
$this->userManager = $userManager;
}
/**
* Get a list of NC users and groups
* @NoAdminRequired
* @return DataResponse
*/
public function getSiteUsersAndGroups($query = '', $getGroups = true, $getUsers = true, $skipGroups = [], $skipUsers = []) {
$list = [];
$data = [];
if ($getGroups) {
$groups = $this->groupManager->search($query);
foreach ($groups as $group) {
if (!in_array($group->getGID(), $skipGroups)) {
$list[] = [
'id' => $group->getGID(),
'user' => $group->getGID(),
'type' => 'group',
'desc' => 'group',
'icon' => 'icon-group',
'displayName' => $group->getGID(),
'avatarURL' => ''
];
}
}
}
if ($getUsers) {
$users = $this->userManager->searchDisplayName($query);
foreach ($users as $user) {
if (!in_array($user->getUID(), $skipUsers)) {
$list[] = [
'id' => $user->getUID(),
'user' => $user->getUID(),
'type' => 'user',
'desc' => 'user',
'icon' => 'icon-user',
'displayName' => $user->getDisplayName(),
'avatarURL' => '',
'lastLogin' => $user->getLastLogin(),
'cloudId' => $user->getCloudId()
];
}
}
}
$data['siteusers'] = $list;
return new DataResponse($data, Http::STATUS_OK);
}
}

View file

@ -29,8 +29,12 @@ use OCA\Forms\Db\QuestionMapper;
use OCA\Forms\Db\SubmissionMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\IMapperException;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Share\IShare;
/**
* Trait for getting forms information in a service
@ -51,6 +55,9 @@ class FormsService {
/** @var IGroupManager */
private $groupManager;
/** @var IUserManager */
private $userManager;
/** @var IUserSession */
private $userSession;
@ -60,12 +67,14 @@ class FormsService {
OptionMapper $optionMapper,
SubmissionMapper $submissionMapper,
IGroupManager $groupManager,
IUserManager $userManager,
IUserSession $userSession) {
$this->formMapper = $formMapper;
$this->questionMapper = $questionMapper;
$this->optionMapper = $optionMapper;
$this->submissionMapper = $submissionMapper;
$this->groupManager = $groupManager;
$this->userManager = $userManager;
$this->userSession = $userSession;
}
@ -112,6 +121,15 @@ class FormsService {
$result = $form->read();
$result['questions'] = $this->getQuestions($id);
// Set proper user/groups properties
// Make sure we have the bare minimum
$result['access'] = array_merge(['users' => [], 'groups' => []], $result['access']);
// Properly format users & groups
$result['access']['users'] = array_map([$this, 'formatUsers'], $result['access']['users']);
$result['access']['groups'] = array_map([$this, 'formatGroups'], $result['access']['groups']);
return $result;
}
@ -205,4 +223,40 @@ class FormsService {
// None of the possible access-options matched.
return false;
}
/**
* Format users access
*
* @param string $userId
* @return array
*/
private function formatUsers(string $userId): array {
$user = $this->userManager->get($userId);
if ($user instanceof IUser) {
return [
'id' => $userId,
'displayName' => $user->getDisplayName(),
'shareType' => IShare::TYPE_USER
];
}
return [];
}
/**
* Format groups access
*
* @param string $groupId
* @return array
*/
private function formatGroups(string $groupId): array {
$group = $this->groupManager->get($groupId);
if ($group instanceof IGroup) {
return [
'id' => $groupId,
'displayName' => $group->getDisplayName(),
'shareType' => IShare::TYPE_GROUP
];
}
return [];
}
}

View file

@ -58,7 +58,9 @@
<!-- No errors show router content -->
<template v-else>
<router-view :form.sync="selectedForm" />
<router-view :form="selectedForm" name="sidebar" />
<router-view v-if="!selectedForm.partial"
:form="selectedForm"
name="sidebar" />
</template>
</Content>
</template>

View file

@ -20,79 +20,47 @@
-
-->
/* global Vue, oc_userconfig */
<template>
<div :class="type" class="user-row">
<div v-if="description" class="description">
{{ description }}
</div>
<Avatar :user="userId" :display-name="computedDisplayName" :is-no-user="isNoUser" />
<div v-if="!hideNames" class="user-name">
{{ computedDisplayName }}
</div>
<div class="user-row">
<Avatar :user="id" :display-name="computedDisplayName" :is-no-user="isNoUser" />
</div>
</template>
<script>
import Avatar from '@nextcloud/vue/dist/Components/Avatar'
import { getCurrentUser } from '@nextcloud/auth'
import ShareTypes from '../mixins/ShareTypes'
export default {
components: {
Avatar,
},
mixins: [ShareTypes],
props: {
hideNames: {
type: Boolean,
default: false,
},
userId: {
id: {
type: String,
default: undefined,
required: true,
},
displayName: {
type: String,
default: '',
required: true,
},
size: {
shareType: {
type: Number,
default: 32,
},
type: {
type: String,
default: 'user',
},
description: {
type: String,
default: '',
required: true,
},
},
data() {
return {
nothidden: false,
}
},
computed: {
isNoUser() {
return this.type !== 'user'
return this.shareType !== this.SHARE_TYPES.SHARE_TYPE_USER
},
computedDisplayName() {
let value = this.displayName
if (this.userId === getCurrentUser().uid) {
value = getCurrentUser().displayName
} else {
if (!this.displayName) {
value = this.userId
}
if (this.shareType === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
return `${this.displayName} (${t('forms', 'Group')})`
}
if (this.type === 'group') {
value = value + ' (' + t('forms', 'Group') + ')'
}
return value
return this.displayName
},
},

View file

@ -23,38 +23,35 @@
<template>
<div class="sharing">
<Multiselect id="ajax"
v-model="shares"
:options="users"
:multiple="true"
:user-select="true"
:tag-width="80"
:clear-on-select="false"
:preserve-search="true"
:options-limit="20"
:loading="isLoading"
:hide-selected="true"
:internal-search="false"
:searchable="true"
:loading="loading"
:multiple="true"
:options="options"
:placeholder="t('forms', 'User or group name …')"
:preselect-first="true"
:placeholder="placeholder"
:preserve-search="true"
:searchable="true"
:user-select="true"
label="displayName"
track-by="user"
@search-change="loadUsersAsync"
@close="updateShares">
<template slot="selection" slot-scope="{ values, search, isOpen }">
<span v-if="values.length &amp;&amp; !isOpen" class="multiselect__single">
{{ values.length }} users selected
</span>
track-by="id"
@search-change="asyncFind"
@select="addShare">
<template #noOptions>
{{ t('forms', 'No recommendations. Start typing.') }}
</template>
<template #noResult>
{{ noResultText }}
</template>
</Multiselect>
<TransitionGroup :css="false" tag="ul" class="shared-list">
<li v-for="(item, index) in sortedShares" :key="item.displayName" :data-index="index">
<UserDiv :user-id="item.user"
:display-name="item.displayName"
:type="item.type"
:hide-names="hideNames" />
<!-- TODO: Iterate two times, will be cleaner, one for users, one for groups -->
<li v-for="(item, index) in sortedShares" :key="item.id + '-' + item.shareType" :data-index="index">
<UserDiv v-bind="item" />
<div class="options">
<a class="icon icon-delete svg delete-form" @click="removeShare(index, item)" />
<a class="icon icon-delete svg delete-form" @click="removeShare(item)" />
</div>
</li>
</TransitionGroup>
@ -62,12 +59,15 @@
</template>
<script>
import { generateUrl } from '@nextcloud/router'
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import debounce from 'debounce'
import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
import ShareTypes from '../mixins/ShareTypes'
// TODO: replace with same design as core sharing
import UserDiv from './_base-UserDiv'
import UserDiv from './UserDiv'
export default {
components: {
@ -75,70 +75,101 @@ export default {
UserDiv,
},
mixins: [ShareTypes],
props: {
placeholder: {
type: String,
default: '',
},
activeShares: {
groupShares: {
type: Array,
default: function() {
return []
},
default: () => ([]),
},
hideNames: {
type: Boolean,
default: false,
userShares: {
type: Array,
default: () => ([]),
},
},
data() {
return {
shares: [],
users: [],
isLoading: false,
siteUsersListOptions: {
getUsers: true,
getGroups: true,
query: '',
},
query: '',
loading: false,
// 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 data
recommendations: [],
suggestions: [],
}
},
computed: {
sortedShares() {
return this.shares.slice(0).sort(this.sortByDisplayname)
return [...this.userShares, ...this.groupShares].slice()
.sort(this.sortByDisplayname)
},
/**
* Is the search valid ?
* @returns {boolean}
*/
isValidQuery() {
return this.query && this.query.trim() !== '' && this.query.length > this.minSearchStringLength
},
/**
* Multiseelct options. Recommendations by default,
* direct search when search query is valid.
* @returns {Array}
*/
options() {
if (this.isValidQuery) {
return this.suggestions
}
return this.recommendations
},
noResultText() {
if (this.loading) {
return t('forms', 'Searching …')
}
return t('forms', 'No elements found.')
},
},
watch: {
activeShares(value) {
this.shares = value.slice(0)
},
mounted() {
this.getRecommendations()
},
methods: {
removeShare(index, item) {
this.$emit('remove-share', item)
removeShare(item) {
// Filter out the removed item
const users = this.userShares.filter(user => !(user.id === item.id && !item.isGroup))
const groups = this.groupShares.filter(group => !(group.id === item.id && item.isGroup))
this.$emit('update:shares', { users, groups })
},
updateShares() {
this.$emit('update-shares', this.shares)
},
/**
* Add a new share and dispatch the change to the parent
* @param {Object} share the new share
*/
addShare(share) {
const users = this.userShares.slice()
const groups = this.groupShares.slice()
const newShare = {
id: share.shareWith,
displayName: share.displayName,
shareType: share.shareType,
}
loadUsersAsync(query) {
this.isLoading = false
this.siteUsersListOptions.query = query
axios.post(generateUrl('apps/forms/get/siteusers'), this.siteUsersListOptions)
.then((response) => {
this.users = response.data.siteusers
this.isLoading = false
}, (error) => {
/* eslint-disable-next-line no-console */
console.log(error.response)
})
if (share.shareType === this.SHARE_TYPES.SHARE_TYPE_USER) {
users.push(newShare)
} else if (share.shareType === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
groups.push(newShare)
}
console.debug('Adding new share', share, users, groups)
this.$emit('update:shares', { users, groups })
},
sortByDisplayname(a, b) {
@ -147,6 +178,196 @@ export default {
return 0
},
async asyncFind(query, id) {
// save current query to check if we display
// recommendations or search results
this.query = query.trim()
if (this.isValidQuery) {
// start loading now to have proper ux feedback
// during the debounce
this.loading = true
await this.debounceGetSuggestions(query)
}
},
/**
* Get suggestions
*
* @param {string} search the search query
*/
async getSuggestions(search) {
this.loading = true
const shareType = [
this.SHARE_TYPES.SHARE_TYPE_USER,
this.SHARE_TYPES.SHARE_TYPE_GROUP,
]
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1') + 'sharees', {
params: {
format: 'json',
itemType: 'file',
search,
perPage: this.maxAutocompleteResults,
shareType,
},
})
if (request.data.ocs.meta.statuscode !== 100) {
console.error('Error fetching suggestions', request)
return
}
const data = request.data.ocs.data
const exact = request.data.ocs.data.exact
data.exact = [] // removing exact from general results
// flatten array of arrays
const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), [])
// remove invalid data and format to user-select layout
const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions)
.map(share => this.formatForMultiselect(share))
// sort by type so we can get user&groups first...
.sort((a, b) => a.shareType - b.shareType)
const suggestions = this.filterOutExistingShares(rawSuggestions)
.map(share => this.formatForMultiselect(share))
// sort by type so we can get user&groups first...
.sort((a, b) => a.shareType - b.shareType)
this.suggestions = exactSuggestions.concat(suggestions)
this.loading = false
console.info('suggestions', this.suggestions)
},
/**
* Debounce getSuggestions
*
* @param {...*} args the arguments
*/
debounceGetSuggestions: debounce(function(...args) {
this.getSuggestions(...args)
}, 300),
/**
* Get the sharing recommendations
*/
async getRecommendations() {
this.loading = true
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1') + 'sharees_recommended', {
params: {
format: 'json',
itemType: 'file',
},
})
if (request.data.ocs.meta.statuscode !== 100) {
console.error('Error fetching recommendations', request)
return
}
const exact = request.data.ocs.data.exact
// flatten array of arrays
const rawRecommendations = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
// remove invalid data and format to user-select layout
this.recommendations = this.filterOutExistingShares(rawRecommendations)
.map(share => this.formatForMultiselect(share))
this.loading = false
console.info('recommendations', this.recommendations)
},
/**
* Filter out existing shares from
* the provided shares search results
*
* @param {Object[]} shares the array of shares object
* @returns {Object[]}
*/
filterOutExistingShares(shares) {
return shares.reduce((arr, share) => {
// only check proper objects
if (typeof share !== 'object') {
return arr
}
try {
// filter out current user
if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER
&& share.value.shareWith === getCurrentUser().uid) {
return arr
}
// Filter out existing shares
if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER) {
if (this.userShares.find(user => user.id === share.value.shareWith)) {
return arr
}
} else if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
if (this.groupShares.find(group => group.id === share.value.shareWith)) {
return arr
}
}
// ALL GOOD
// let's add the suggestion
arr.push(share)
} catch {
return arr
}
return arr
}, [])
},
/**
* Format shares for the multiselect options
* @param {Object} result select entry item
* @returns {Object}
*/
formatForMultiselect(result) {
return {
shareWith: result.value.shareWith,
shareType: result.value.shareType,
user: result.uuid || result.value.shareWith,
isNoUser: result.value.shareType !== this.SHARE_TYPES.SHARE_TYPE_USER,
displayName: result.name || result.label,
icon: this.shareTypeToIcon(result.value.shareType),
}
},
/**
* Get the icon based on the share type
* @param {number} type the share type
* @returns {string} the icon class
*/
shareTypeToIcon(type) {
switch (type) {
case this.SHARE_TYPES.SHARE_TYPE_GUEST:
// default is a user, other icons are here to differenciate
// themselves from it, so let's not display the user icon
// case this.SHARE_TYPES.SHARE_TYPE_REMOTE:
// case this.SHARE_TYPES.SHARE_TYPE_USER:
return 'icon-user'
case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
case this.SHARE_TYPES.SHARE_TYPE_GROUP:
return 'icon-group'
case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
return 'icon-mail'
case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
return 'icon-circle'
case this.SHARE_TYPES.SHARE_TYPE_ROOM:
return 'icon-room'
default:
return ''
}
},
},
}
</script>

39
src/mixins/ShareTypes.js Normal file
View file

@ -0,0 +1,39 @@
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
export default {
data() {
return {
SHARE_TYPES: {
SHARE_TYPE_USER: OC.Share.SHARE_TYPE_USER,
SHARE_TYPE_GROUP: OC.Share.SHARE_TYPE_GROUP,
SHARE_TYPE_LINK: OC.Share.SHARE_TYPE_LINK,
SHARE_TYPE_EMAIL: OC.Share.SHARE_TYPE_EMAIL,
SHARE_TYPE_REMOTE: OC.Share.SHARE_TYPE_REMOTE,
SHARE_TYPE_CIRCLE: OC.Share.SHARE_TYPE_CIRCLE,
SHARE_TYPE_GUEST: OC.Share.SHARE_TYPE_GUEST,
SHARE_TYPE_REMOTE_GROUP: OC.Share.SHARE_TYPE_REMOTE_GROUP,
SHARE_TYPE_ROOM: OC.Share.SHARE_TYPE_ROOM,
},
}
},
}

View file

@ -21,7 +21,7 @@
-->
<template>
<AppSidebar v-if="form"
<AppSidebar
v-show="opened"
:title="form.title"
@close="onClose">
@ -44,7 +44,7 @@
:disabled="isPublic || form.isAnonymous"
type="checkbox"
class="checkbox"
@change="onSubmOnceChange">
@change="onSubmitOnceChange">
<label for="submitOnce">
{{ t('forms', 'Only allow one response per user') }}
</label>
@ -113,11 +113,9 @@
</span>
</label>
<ShareDiv v-show="form.access.type === 'selected'"
:active-shares="form.shares"
:placeholder="t('forms', 'Name of user or group')"
:hide-names="true"
@update-shares="updateShares"
@remove-share="removeShare" />
:user-shares="userShares"
:group-shares="groupShares"
@update:shares="onSharingChange" />
</li>
</ul>
</AppSidebar>
@ -182,10 +180,16 @@ export default {
expirationDate() {
return moment(this.form.expires, 'X').toDate()
},
isExpired() {
return this.form.expires && moment().unix() > this.form.expires
},
userShares() {
return [...this.form?.access?.users || []]
},
groupShares() {
return [...this.form?.access?.groups || []]
},
},
async created() {
@ -224,18 +228,6 @@ export default {
},
methods: {
addShare(item) {
this.form.shares.push(item)
},
updateShares(share) {
this.form.shares = share.slice(0)
},
removeShare(item) {
this.form.shares.splice(this.form.shares.indexOf(item), 1)
},
/**
* Sidebar state methods
*/
@ -252,12 +244,17 @@ export default {
onAnonChange() {
this.saveFormProperty('isAnonymous')
},
onSubmOnceChange() {
onSubmitOnceChange() {
this.saveFormProperty('submitOnce')
},
onAccessChange() {
this.saveFormProperty('access')
},
onSharingChange({ groups, users }) {
this.$set(this.form.access, 'groups', groups)
this.$set(this.form.access, 'users', users)
this.onAccessChange()
},
/**
* On date picker change