diff --git a/appinfo/routes.php b/appinfo/routes.php index 3620e691e..2e884d4db 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -25,15 +25,11 @@ return [ 'routes' => [ - ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], - - // Before /{hash} to avoid conflict - ['name' => 'page#index', 'url' => '/new', 'verb' => 'GET', 'postfix' => 'create'], - ['name' => 'page#index', 'url' => '/{hash}/edit', 'verb' => 'GET', 'postfix' => 'edit'], - ['name' => 'page#index', 'url' => '/{hash}/clone', 'verb' => 'GET', 'postfix' => 'clone'], - ['name' => 'page#index', 'url' => '/{hash}/results', 'verb' => 'GET', 'postfix' => 'results'], - + // Before /{hash}/{action} to avoid conflict ['name' => 'page#goto_form', 'url' => '/{hash}', 'verb' => 'GET'], + + // As parameters have defaults, this catches all routes from '/' to '/hash/edit' + ['name' => 'page#index', 'url' => '/{hash}/{action}', 'verb' => 'GET', 'defaults' => ['hash' => '', 'action' => '']], ], 'ocs' => [ // Forms @@ -43,6 +39,7 @@ ['name' => 'api#cloneForm', 'url' => '/api/v1/form/clone/{id}', 'verb' => 'POST'], ['name' => 'api#updateForm', 'url' => '/api/v1/form/update', 'verb' => 'POST'], ['name' => 'api#deleteForm', 'url' => '/api/v1/form/{id}', 'verb' => 'DELETE'], + ['name' => 'api#getSharedForms', 'url' => '/api/v1/shared_forms', 'verb' => 'GET'], // Questions ['name' => 'api#newQuestion', 'url' => '/api/v1/question', 'verb' => 'POST'], diff --git a/docs/API.md b/docs/API.md index 2f53614b9..eff1a1241 100644 --- a/docs/API.md +++ b/docs/API.md @@ -46,6 +46,16 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. ] ``` +### List shared Forms +Returns condensed objects of all Forms, that are shared to the authenticated user via instance ([access-type](DataStructure.md#share-types) `registered` or `selected`) and have not expired yet. +- Endpoint: `/api/v1/shared_forms` +- Method: `GET` +- Parameters: None +- Response: Array of condensed Form Objects, sorted as newest first, similar to [List owned Forms](#list-owned-forms). +``` +See above, 'List owned forms' +``` + ### Create a new Form - Endpoint: `/api/v1/form` - Method: `POST` @@ -65,7 +75,8 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "expires": null, "isAnonymous": null, "submitOnce": true, - "questions": [] + "questions": [], + "canSubmit": true } ``` diff --git a/docs/DataStructure.md b/docs/DataStructure.md index e12150098..3a015b152 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -19,6 +19,7 @@ This document describes the Obect-Structure, that is used within the Forms App a | submitOnce | Boolean | | If users are only allowed to submit once to the form | | questions | Array of [Questions](#question) | | Array of questions belonging to the form | | submissions | Array of [Submissions](#submissions) | | Array of submissions belonging to the form | +| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitOnce` and existing submissions. | ``` { @@ -33,7 +34,8 @@ This document describes the Obect-Structure, that is used within the Forms App a "isAnonymous": false, "submitOnce": false, "questions": [], - "submissions": [] + "submissions": [], + "canSubmit": true } ``` diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 30c081a29..6786cf47f 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -149,7 +149,8 @@ public function __construct(string $appName, /** * @NoAdminRequired * - * Read Form-List only with necessary information for Listing. + * Read Form-List of owned forms + * Return only with necessary information for Listing. * @return DataResponse */ public function getForms(): DataResponse { @@ -169,6 +170,38 @@ public function getForms(): DataResponse { return new DataResponse($result); } + /** + * @NoAdminRequired + * + * Read List of forms shared with current user + * Return only with necessary information for Listing. + * @return DataResponse + */ + public function getSharedForms(): DataResponse { + $forms = $this->formMapper->findAll(); + + $result = []; + foreach ($forms as $form) { + // Don't add if user is owner, user has no access, form has expired, form is link-shared + if ($form->getOwnerId() === $this->currentUser->getUID() + || !$this->formsService->hasUserAccess($form->getId()) + || $this->formsService->hasFormExpired($form->getId()) + || $form->getAccess()['type'] === 'public') { + continue; + } + + $result[] = [ + 'id' => $form->getId(), + 'hash' => $form->getHash(), + 'title' => $form->getTitle(), + 'expires' => $form->getExpires(), + 'partial' => true + ]; + } + + return new DataResponse($result); + } + /** * @NoAdminRequired * @@ -914,8 +947,8 @@ public function insertSubmission(int $formId, array $answers): DataResponse { throw new OCSForbiddenException('Not allowed to access this form'); } - // Not allowed if form expired. Expires is '0' if the form does not expire. - if ($form->getExpires() && $form->getExpires() < time()) { + // Not allowed if form has expired. + if ($this->formsService->hasFormExpired($form->getId())) { throw new OCSForbiddenException('This form is no longer taking answers'); } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index c6da21222..dc7d811b9 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -34,12 +34,15 @@ use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http\Template\PublicTemplateResponse; +use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Http\RedirectResponse; use OCP\IGroupManager; use OCP\IInitialStateService; use OCP\IL10N; use OCP\ILogger; use OCP\IRequest; +use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; @@ -74,6 +77,9 @@ class PageController extends Controller { /** @var ILogger */ private $logger; + /** @var IUrlGenerator */ + private $urlGenerator; + /** @var IUserManager */ private $userManager; @@ -101,6 +107,7 @@ public function __construct(string $appName, IInitialStateService $initialStateService, IL10N $l10n, ILogger $logger, + IUrlGenerator $urlGenerator, IUserManager $userManager, IUserSession $userSession) { parent::__construct($appName, $request); @@ -115,6 +122,7 @@ public function __construct(string $appName, $this->initialStateService = $initialStateService; $this->l10n = $l10n; $this->logger = $logger; + $this->urlGenerator = $urlGenerator; $this->userManager = $userManager; $this->userSession = $userSession; } @@ -137,9 +145,9 @@ public function index(): TemplateResponse { * @NoCSRFRequired * @PublicPage * @param string $hash - * @return TemplateResponse + * @return RedirectResponse|TemplateResponse Redirect for logged-in users, public template otherwise. */ - public function gotoForm($hash): ?TemplateResponse { + public function gotoForm(string $hash): Response { // Inject style on all templates Util::addStyle($this->appName, 'forms'); @@ -149,18 +157,21 @@ public function gotoForm($hash): ?TemplateResponse { return $this->provideTemplate(self::TEMPLATE_NOTFOUND); } - // Does the user have access to form - if (!$this->formsService->hasUserAccess($form->getId())) { - return $this->provideTemplate(self::TEMPLATE_NOTFOUND); - } + // If not link-shared, redirect to internal route + if ($form->getAccess()['type'] !== 'public') { + $internalLink = $this->urlGenerator->linkToRoute('forms.page.index', ['hash' => $hash, 'action' => 'submit']); - // Does the user have permissions to submit (resp. submitOnce) - if (!$this->formsService->canSubmit($form->getId())) { - return $this->provideTemplate(self::TEMPLATE_NOSUBMIT, $form); + if ($this->userSession->isLoggedIn()) { + // Directly internal view + return new RedirectResponse($internalLink); + } else { + // Internal through login + return new RedirectResponse($this->urlGenerator->linkToRoute('core.login.showLoginForm', ['redirect_url' => $internalLink])); + } } // Has form expired - if ($form->getExpires() !== 0 && time() > $form->getExpires()) { + if ($this->formsService->hasFormExpired($form->getId())) { return $this->provideTemplate(self::TEMPLATE_EXPIRED, $form); } diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index afef7974e..23311fd94 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -148,14 +148,15 @@ public function getForm(int $id): array { $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']); + // Append canSubmit, to be able to show proper EmptyContent on internal view. + $result['canSubmit'] = $this->canSubmit($form->getId()); + return $result; } @@ -188,6 +189,11 @@ public function canSubmit($formId) { return true; } + // Owner is always allowed to submit + if ($this->currentUser->getUID() === $form->getOwnerId()) { + return true; + } + // Refuse access, if SubmitOnce is set and user already has taken part. if ($form->getSubmitOnce()) { $participants = $this->submissionMapper->findParticipantsByForm($form->getId()); @@ -248,6 +254,17 @@ public function hasUserAccess(int $formId): bool { return false; } + /* + * Has the form expired? + * + * @param int $formId The id of the form to check. + * @return boolean + */ + public function hasFormExpired(int $formId): bool { + $form = $this->formMapper->findById($formId); + return ($form->getExpires() !== 0 && $form->getExpires() < time()); + } + /** * Format users access * diff --git a/src/Forms.vue b/src/Forms.vue index 2f1ad59fd..e52a355f0 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -26,17 +26,28 @@ - + {{ t('forms', 'Loading forms …') }} @@ -80,6 +91,7 @@ import axios from '@nextcloud/axios' import AppContent from '@nextcloud/vue/dist/Components/AppContent' import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation' +import AppNavigationCaption from '@nextcloud/vue/dist/Components/AppNavigationCaption' import AppNavigationNew from '@nextcloud/vue/dist/Components/AppNavigationNew' import Content from '@nextcloud/vue/dist/Components/Content' import isMobile from '@nextcloud/vue/src/mixins/isMobile' @@ -95,6 +107,7 @@ export default { AppNavigationForm, AppContent, AppNavigation, + AppNavigationCaption, AppNavigationNew, Content, EmptyContent, @@ -107,26 +120,61 @@ export default { loading: true, sidebarOpened: false, forms: [], + sharedForms: [], } }, computed: { noForms() { - return this.forms && this.forms.length === 0 + return this.noOwnedForms && this.noSharedForms + }, + noOwnedForms() { + return this.forms?.length === 0 + }, + noSharedForms() { + return this.sharedForms?.length === 0 }, routeHash() { return this.$route.params.hash }, + // If the user is allowed to access this route + routeAllowed() { + // If the form is not within the owned or shared list, the user has no access on internal view. + if (this.routeHash && this.forms.concat(this.sharedForms).findIndex(form => form.hash === this.routeHash) < 0) { + console.error('Form not found for hash: ', this.routeHash) + return false + } + + // For Routes edit or results, the form must be in owned forms. + if (this.$route.name === 'edit' || this.$route.name === 'results') { + if (this.forms.findIndex(form => form.hash === this.routeHash) < 0) { + return false + } + } + + return true + }, + selectedForm: { get() { - return this.forms.find(form => form.hash === this.routeHash) + if (this.routeAllowed) { + return this.forms.concat(this.sharedForms).find(form => form.hash === this.routeHash) + } + return {} }, set(form) { - const index = this.forms.findIndex(search => search.hash === this.routeHash) + // If a owned form + let index = this.forms.findIndex(search => search.hash === this.routeHash) if (index > -1) { this.$set(this.forms, index, form) + return + } + // Otherwise a shared form + index = this.sharedForms.findIndex(search => search.hash === this.routeHash) + if (index > -1) { + this.$set(this.sharedForms, index, form) } }, }, @@ -151,15 +199,26 @@ export default { */ async loadForms() { this.loading = true + + // Load Owned forms try { const response = await axios.get(generateOcsUrl('apps/forms/api/v1', 2) + 'forms') this.forms = OcsResponse2Data(response) } catch (error) { showError(t('forms', 'An error occurred while loading the forms list')) console.error(error) - } finally { - this.loading = false } + + // Load shared forms + try { + const response = await axios.get(generateOcsUrl('apps/forms/api/v1', 2) + 'shared_forms') + this.sharedForms = OcsResponse2Data(response) + } catch (error) { + showError(t('forms', 'An error occurred while loading the forms list')) + console.error(error) + } + + this.loading = false }, /** diff --git a/src/FormsSubmit.vue b/src/FormsSubmit.vue new file mode 100644 index 000000000..0f8fad424 --- /dev/null +++ b/src/FormsSubmit.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/src/components/AppNavigationForm.vue b/src/components/AppNavigationForm.vue index 4a03787cd..43627ffae 100644 --- a/src/components/AppNavigationForm.vue +++ b/src/components/AppNavigationForm.vue @@ -25,9 +25,12 @@ ref="navigationItem" :icon="icon" :title="formTitle" - :to="{ name: 'formRoot', params: { hash: form.hash } }" + :to="{ + name: routerTarget, + params: { hash: form.hash } + }" @click="mobileCloseNavigation"> -