From 10d8429d8dc5cfd461fb21b3b40f08c3015e0b7c Mon Sep 17 00:00:00 2001 From: Benjamin Frueh Date: Tue, 14 Apr 2026 14:28:14 +0200 Subject: [PATCH] feat: add permissions for public link shares Signed-off-by: Benjamin Frueh --- appinfo/info.xml | 1 + appinfo/routes.php | 1 + .../helpers/viewFilteringSelectionSetup.js | 2 + lib/Controller/Api1Controller.php | 2 +- lib/Controller/PublicRowOCSController.php | 169 +++++ lib/Controller/PublicSharePageController.php | 6 + lib/Controller/ShareController.php | 20 +- lib/Db/Row2Mapper.php | 4 + lib/Migration/ResetPublicSharePermissions.php | 51 ++ lib/Service/PermissionsService.php | 9 +- lib/Service/RowService.php | 10 +- lib/Service/ShareService.php | 67 +- lib/Service/SuperService.php | 5 + openapi.json | 708 ++++++++++++++++++ package-lock.json | 85 +++ playwright/e2e/tables-sharing-link.spec.ts | 194 +++-- src/modules/main/partials/TableView.vue | 5 + src/modules/main/sections/ElementTitle.vue | 11 +- src/modules/main/sections/PublicElement.vue | 75 +- .../main/sections/PublicMainWrapper.vue | 29 +- src/modules/modals/CreateRow.vue | 34 +- src/modules/modals/DeleteRows.vue | 15 +- src/modules/modals/EditRow.vue | 16 +- src/modules/sidebar/mixins/shareAPI.js | 8 + .../partials/SharePermissionSelect.vue | 219 ++++++ .../sidebar/partials/SharingEntryLink.vue | 12 +- .../sidebar/partials/SharingLinkList.vue | 2 +- .../sidebar/sections/SidebarSharing.vue | 8 +- src/shared/components/ncTable/NcTable.vue | 27 +- .../ncTable/mixins/cellEditMixin.js | 12 +- src/store/data.js | 59 +- src/types/openapi/openapi.ts | 312 +++++++- 32 files changed, 2040 insertions(+), 138 deletions(-) create mode 100644 lib/Migration/ResetPublicSharePermissions.php create mode 100644 src/modules/sidebar/partials/SharePermissionSelect.vue diff --git a/appinfo/info.xml b/appinfo/info.xml index b60dd96c5f..cbe93ead0b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -53,6 +53,7 @@ Have a good time and manage whatever you want. OCA\Tables\Migration\FixContextsDefaults + OCA\Tables\Migration\ResetPublicSharePermissions OCA\Tables\Migration\NewDbStructureRepairStep diff --git a/appinfo/routes.php b/appinfo/routes.php index 050db4d07d..913570e0a4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -97,6 +97,7 @@ ['name' => 'share#show', 'url' => '/share/{id}', 'verb' => 'GET'], ['name' => 'share#create', 'url' => '/share', 'verb' => 'POST'], ['name' => 'share#updatePermission', 'url' => '/share/{id}/permission', 'verb' => 'PUT'], + ['name' => 'share#updatePermissions', 'url' => '/share/{id}/permissions', 'verb' => 'PUT'], ['name' => 'share#updateDisplayMode', 'url' => '/share/{id}/display-mode', 'verb' => 'PUT'], ['name' => 'share#destroy', 'url' => '/share/{id}', 'verb' => 'DELETE'], diff --git a/cypress/e2e/helpers/viewFilteringSelectionSetup.js b/cypress/e2e/helpers/viewFilteringSelectionSetup.js index c53cb5b27c..43d6d1534c 100644 --- a/cypress/e2e/helpers/viewFilteringSelectionSetup.js +++ b/cypress/e2e/helpers/viewFilteringSelectionSetup.js @@ -81,4 +81,6 @@ const addRow = (title, selection, multiSelection, checked) => { cy.get('[data-cy="createRowSaveButton"]').click() cy.get('[data-cy="createRowModal"]').should('not.exist') + cy.get('.toastify.toast-success').should('be.visible') + cy.get('.toastify.toast-success .toast-close').click({ multiple: true }) } diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 0db4b1eb8b..ce15c6fbe3 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -672,7 +672,7 @@ public function deleteShare(int $shareId): DataResponse { #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function updateSharePermissions(int $shareId, string $permissionType, bool $permissionValue): DataResponse { try { - return new DataResponse($this->shareService->updatePermission($shareId, $permissionType, $permissionValue)->jsonSerialize()); + return new DataResponse($this->shareService->updatePermission($shareId, [$permissionType => $permissionValue])->jsonSerialize()); } catch (PermissionError $e) { $this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]); $message = ['message' => $e->getMessage()]; diff --git a/lib/Controller/PublicRowOCSController.php b/lib/Controller/PublicRowOCSController.php index bfb348c5a3..9ac88b6768 100644 --- a/lib/Controller/PublicRowOCSController.php +++ b/lib/Controller/PublicRowOCSController.php @@ -9,12 +9,14 @@ namespace OCA\Tables\Controller; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Db\Row2Mapper; use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Helper\ConversionHelper; use OCA\Tables\Middleware\Attribute\AssertShareAccessIsAccessible; +use OCA\Tables\Model\RowDataInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\RowService; use OCA\Tables\Service\ShareService; @@ -37,11 +39,13 @@ class PublicRowOCSController extends AOCSController { public function __construct( protected ShareService $shareService, protected RowService $rowService, + protected Row2Mapper $row2Mapper, IRequest $request, LoggerInterface $logger, IL10N $l, ) { parent::__construct($request, $logger, $l, ''); + $this->rowService->setPublicContext(); } /** @@ -68,6 +72,10 @@ public function getRows(string $token, ?int $limit, ?int $offset): DataResponse $shareToken = new ShareToken($token); $share = $this->shareService->findByToken($shareToken); + if (!$share->getPermissionRead()) { + return $this->handlePermissionError(new PermissionError('No read permission on this share')); + } + $limit = $limit !== null ? max(0, min(500, $limit)) : null; $offset = $offset !== null ? max(0, $offset) : null; @@ -90,4 +98,165 @@ public function getRows(string $token, ?int $limit, ?int $offset): DataResponse return $this->handleBadRequestError($e); } } + + /** + * [api v2] Create a row in a link share + * + * @param string $token The share token + * @param string|array $data An array containing the column identifiers and their values + * @return DataResponse|DataResponse + * + * 200: Row created + * 400: Invalid request parameters + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[PublicPage] + #[AssertShareAccessIsAccessible] + #[ApiRoute(verb: 'POST', url: '/api/2/public/{token}/rows', requirements: ['token' => '[a-zA-Z0-9]{16}'])] + #[OpenAPI] + #[AnonRateLimit(limit: 20, period: 30)] + public function createRow(string $token, mixed $data): DataResponse { + try { + $shareToken = new ShareToken($token); + $share = $this->shareService->findByToken($shareToken); + $this->row2Mapper->setUserId('public-' . $token); + + if (!$share->getPermissionCreate()) { + return $this->handlePermissionError(new PermissionError('No create permission on this share')); + } + + if (is_string($data)) { + $data = json_decode($data, true); + } + if (!is_array($data)) { + return $this->handleBadRequestError(new BadRequestError('Invalid data input')); + } + + $newRowData = new RowDataInput(); + foreach ($data as $key => $value) { + $newRowData->add((int)$key, $value); + } + + $tableId = $share->getNodeType() === 'table' ? $share->getNodeId() : null; + $viewId = $share->getNodeType() === 'view' ? $share->getNodeId() : null; + + if ($viewId === null && $tableId === null) { + throw new InternalError('Cannot create row without table or view provided'); + } + + $row = $this->rowService->create($tableId, $viewId, $newRowData); + return new DataResponse($this->rowService->formatRowsForPublicShare([$row])[0]); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (InternalError|\Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Update a row in a link share + * + * @param string $token The share token + * @param int $rowId The row identifier + * @param string|array $data An array containing the column identifiers and their values + * @return DataResponse|DataResponse + * + * 200: Row updated + * 400: Invalid request parameters + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[PublicPage] + #[AssertShareAccessIsAccessible] + #[ApiRoute(verb: 'PUT', url: '/api/2/public/{token}/rows/{rowId}', requirements: ['token' => '[a-zA-Z0-9]{16}', 'rowId' => '\d+'])] + #[OpenAPI] + #[AnonRateLimit(limit: 20, period: 30)] + public function updateRow(string $token, int $rowId, mixed $data): DataResponse { + try { + $shareToken = new ShareToken($token); + $share = $this->shareService->findByToken($shareToken); + $this->row2Mapper->setUserId('public-' . $token); + + if (!$share->getPermissionUpdate()) { + return $this->handlePermissionError(new PermissionError('No update permission on this share')); + } + + if (is_string($data)) { + $data = json_decode($data, true); + } + if (!is_array($data)) { + return $this->handleBadRequestError(new BadRequestError('Invalid data input')); + } + + $viewId = $share->getNodeType() === 'view' ? $share->getNodeId() : null; + $tableId = $share->getNodeType() === 'table' ? $share->getNodeId() : null; + + if ($viewId === null && $tableId === null) { + throw new InternalError('Cannot update row without table or view provided'); + } + + $row = $this->rowService->updateSet($rowId, $viewId, $data, '', $tableId); + return new DataResponse($this->rowService->formatRowsForPublicShare([$row])[0]); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (InternalError|\Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Delete a row in a link share + * + * @param string $token The share token + * @param int $rowId The row identifier + * @return DataResponse|DataResponse + * + * 200: Row deleted + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[PublicPage] + #[AssertShareAccessIsAccessible] + #[ApiRoute(verb: 'DELETE', url: '/api/2/public/{token}/rows/{rowId}', requirements: ['token' => '[a-zA-Z0-9]{16}', 'rowId' => '\d+'])] + #[OpenAPI] + #[AnonRateLimit(limit: 20, period: 30)] + public function deleteRow(string $token, int $rowId): DataResponse { + try { + $shareToken = new ShareToken($token); + $share = $this->shareService->findByToken($shareToken); + $this->row2Mapper->setUserId('public-' . $token); + + if (!$share->getPermissionDelete()) { + return $this->handlePermissionError(new PermissionError('No delete permission on this share')); + } + + $viewId = $share->getNodeType() === 'view' ? $share->getNodeId() : null; + $tableId = $share->getNodeType() === 'table' ? $share->getNodeId() : null; + + if ($viewId === null && $tableId === null) { + throw new InternalError('Cannot delete row without table or view provided'); + } + + $row = $this->rowService->delete($rowId, $viewId, '', $tableId); + return new DataResponse($this->rowService->formatRowsForPublicShare([$row])[0]); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (InternalError|\Exception $e) { + return $this->handleError($e); + } + } } diff --git a/lib/Controller/PublicSharePageController.php b/lib/Controller/PublicSharePageController.php index dda37e5af2..fa194fdf31 100644 --- a/lib/Controller/PublicSharePageController.php +++ b/lib/Controller/PublicSharePageController.php @@ -71,6 +71,12 @@ public function showShare(): TemplateResponse { $this->initialState->provideInitialState('shareToken', (string)$this->shareToken); $this->initialState->provideInitialState('nodeType', $this->share->getNodeType()); $this->initialState->provideInitialState('nodeData', $nodeData); + $this->initialState->provideInitialState('sharePermissions', [ + 'read' => $this->share->getPermissionRead(), + 'create' => $this->share->getPermissionCreate(), + 'update' => $this->share->getPermissionUpdate(), + 'delete' => $this->share->getPermissionDelete(), + ]); if (class_exists(LoadEditor::class)) { $this->eventDispatcher->dispatchTyped(new LoadEditor()); diff --git a/lib/Controller/ShareController.php b/lib/Controller/ShareController.php index afbb9ec4d2..f3b3151b03 100644 --- a/lib/Controller/ShareController.php +++ b/lib/Controller/ShareController.php @@ -85,7 +85,25 @@ public function create( #[NoAdminRequired] public function updatePermission(int $id, string $permission, bool $value): DataResponse { return $this->handleError(function () use ($id, $permission, $value) { - return $this->service->updatePermission($id, $permission, $value); + return $this->service->updatePermission($id, [$permission => $value]); + }); + } + + #[NoAdminRequired] + public function updatePermissions( + int $id, + bool $permissionRead = false, + bool $permissionCreate = false, + bool $permissionUpdate = false, + bool $permissionDelete = false, + ): DataResponse { + return $this->handleError(function () use ($id, $permissionRead, $permissionCreate, $permissionUpdate, $permissionDelete) { + return $this->service->updatePermission($id, [ + 'read' => $permissionRead, + 'create' => $permissionCreate, + 'update' => $permissionUpdate && $permissionRead, + 'delete' => $permissionDelete && $permissionRead, + ]); }); } diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 05ec003cd3..95eb4c17c7 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -122,6 +122,10 @@ public function getTableIdForRow(int $rowId): ?int { return $rowSleeve->getTableId(); } + public function setUserId(string $userId): void { + $this->userId = $userId; + } + /** * @return int[] * @throws InternalError diff --git a/lib/Migration/ResetPublicSharePermissions.php b/lib/Migration/ResetPublicSharePermissions.php new file mode 100644 index 0000000000..9cda262da0 --- /dev/null +++ b/lib/Migration/ResetPublicSharePermissions.php @@ -0,0 +1,51 @@ +config->getAppValue(Application::APP_ID, 'installed_version', '0.0'); + if (\version_compare($appVersion, '2.0.2', '>=')) { + $output->info('Not applicable, skipping.'); + return; + } + + $qb = $this->dbc->getQueryBuilder(); + $qb->update('tables_shares') + ->set('permission_read', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)) + ->set('permission_create', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->set('permission_update', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->set('permission_delete', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->set('permission_manage', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('receiver_type', $qb->createNamedParameter(ShareReceiverType::LINK, IQueryBuilder::PARAM_STR))) + ->executeStatement(); + + $output->info('Reset public link share permissions to read-only.'); + } +} diff --git a/lib/Service/PermissionsService.php b/lib/Service/PermissionsService.php index a1d04614ca..4964652111 100644 --- a/lib/Service/PermissionsService.php +++ b/lib/Service/PermissionsService.php @@ -45,6 +45,8 @@ class PermissionsService { protected bool $isCli = false; + private bool $isPublicContext = false; + private ContextMapper $contextMapper; public function __construct( @@ -549,6 +551,11 @@ public function getPermissionArrayForNodeFromContexts(int $nodeId, string $nodeT ); } + public function setPublicContext(): void { + $this->userId = ''; + $this->isPublicContext = true; + } + private function hasPermission(int $existingPermissions, string $permissionName): bool { $constantName = 'PERMISSION_' . strtoupper($permissionName); try { @@ -634,7 +641,7 @@ private function basisCheck(Table|View|Context $element, string $nodeType, ?stri } if ($userId === '') { - return true; + return $this->isCli || $this->isPublicContext; } if ($this->userIsElementOwner($element, $userId, $nodeType)) { diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index fb9ab1873c..f74d819e26 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -178,7 +178,7 @@ public function find(int $rowId): Row2 { * @throws InternalError */ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data): Row2 { - if ($this->userId === null || $this->userId === '') { + if ($this->userId === null) { $e = new \Exception('No user id in context, but needed.'); $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); @@ -685,7 +685,7 @@ public function updateSet( * @throws PermissionError * @noinspection DuplicatedCode */ - public function delete(int $id, ?int $viewId, string $userId): Row2 { + public function delete(int $id, ?int $viewId, string $userId, ?int $tableId = null): Row2 { try { $item = $this->getRowById($id); } catch (InternalError $e) { @@ -720,6 +720,12 @@ public function delete(int $id, ?int $viewId, string $userId): Row2 { throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } } else { + if ($tableId !== null && $tableId !== $item->getTableId()) { + $e = new \Exception('Row does not belong to table with id ' . $tableId); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + // security if (!$this->permissionsService->canReadRowsByElementId($item->getTableId(), 'table', $userId)) { $e = new \Exception('Row not found.'); diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index 1da4ed69e5..7ac0c3c539 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -326,16 +326,47 @@ public function create(int $nodeId, string $nodeType, string $receiver, string $ return $this->addReceiverDisplayName($newShare); } + /** + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + */ + private function applyPermissions(Share $item, array $permissions): Share { + $time = new DateTime(); + if (isset($permissions['read'])) { + $item->setPermissionRead($permissions['read']); + } + if (isset($permissions['create'])) { + $item->setPermissionCreate($permissions['create']); + } + if (isset($permissions['update'])) { + $item->setPermissionUpdate($permissions['update']); + } + if (isset($permissions['delete'])) { + $item->setPermissionDelete($permissions['delete']); + } + if (isset($permissions['manage'])) { + $item->setPermissionManage($permissions['manage']); + } + $item->setLastEditAt($time->format('Y-m-d H:i:s')); + + try { + return $this->mapper->update($item); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + } + /** * @param int $id - * @param string $permission - * @param bool $value + * @param array $permissions * @return Share * @throws InternalError * @throws NotFoundError * @throws PermissionError */ - public function updatePermission(int $id, string $permission, bool $value): Share { + public function updatePermission(int $id, array $permissions): Share { try { $item = $this->mapper->find($id); } catch (DoesNotExistException $e) { @@ -351,36 +382,8 @@ public function updatePermission(int $id, string $permission, bool $value): Shar throw new PermissionError('PermissionError: can not update share with id ' . $id); } - $time = new DateTime(); - - if ($permission === 'read') { - $item->setPermissionRead($value); - } - - if ($permission === 'create') { - $item->setPermissionCreate($value); - } - - if ($permission === 'update') { - $item->setPermissionUpdate($value); - } - - if ($permission === 'delete') { - $item->setPermissionDelete($value); - } + $share = $this->applyPermissions($item, $permissions); - if ($permission === 'manage') { - $item->setPermissionManage($value); - } - - $item->setLastEditAt($time->format('Y-m-d H:i:s')); - - try { - $share = $this->mapper->update($item); - } catch (Exception $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); - } return $this->addReceiverDisplayName($share); } diff --git a/lib/Service/SuperService.php b/lib/Service/SuperService.php index d2e24a21be..9379cdbca6 100644 --- a/lib/Service/SuperService.php +++ b/lib/Service/SuperService.php @@ -21,4 +21,9 @@ public function __construct(LoggerInterface $logger, ?string $userId, Permission $this->logger = $logger; $this->userId = $userId; } + + public function setPublicContext(): void { + $this->userId = ''; + $this->permissionsService->setPublicContext(); + } } diff --git a/openapi.json b/openapi.json index 6ab031fc0d..42b001fcb6 100644 --- a/openapi.json +++ b/openapi.json @@ -13036,6 +13036,714 @@ } } } + }, + "post": { + "operationId": "public_rowocs-create-row", + "summary": "[api v2] Create a row in a link share", + "tags": [ + "public_rowocs" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "description": "An array containing the column identifiers and their values", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + } + } + } + } + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The share token", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Row created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/PublicRow" + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/public/{token}/rows/{rowId}": { + "put": { + "operationId": "public_rowocs-update-row", + "summary": "[api v2] Update a row in a link share", + "tags": [ + "public_rowocs" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "description": "An array containing the column identifiers and their values", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + } + } + } + } + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The share token", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "name": "rowId", + "in": "path", + "description": "The row identifier", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Row updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/PublicRow" + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "public_rowocs-delete-row", + "summary": "[api v2] Delete a row in a link share", + "tags": [ + "public_rowocs" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The share token", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "name": "rowId", + "in": "path", + "description": "The row identifier", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Row deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/PublicRow" + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } } }, "/ocs/v2.php/apps/tables/api/2/{nodeCollection}/{nodeId}/share": { diff --git a/package-lock.json b/package-lock.json index 95a595a6db..5ddc1ce91f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3208,6 +3208,17 @@ "source-map-js": "^1.2.1" } }, + "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-shared": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.6.tgz", @@ -3394,6 +3405,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@nextcloud/dialogs/node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@nextcloud/dialogs/node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -6093,6 +6127,34 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "rfdc": "^1.4.1" + } + }, "node_modules/@vue/eslint-config-typescript": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-13.0.0.tgz", @@ -13853,6 +13915,14 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -15428,6 +15498,21 @@ } } }, + "node_modules/rollup-plugin-license/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/rollup-plugin-node-externals": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/rollup-plugin-node-externals/-/rollup-plugin-node-externals-8.1.1.tgz", diff --git a/playwright/e2e/tables-sharing-link.spec.ts b/playwright/e2e/tables-sharing-link.spec.ts index 2663323f5d..ae831bb38a 100644 --- a/playwright/e2e/tables-sharing-link.spec.ts +++ b/playwright/e2e/tables-sharing-link.spec.ts @@ -23,32 +23,76 @@ async function setupPublicShareTable(page: Page, title: string) { await loadTable(page, title) } +async function createPublicLinkShare(page: Page, options: { password?: string, permissions?: { read?: boolean, create?: boolean, update?: boolean, delete?: boolean } } = {}) { + const menuButton = page.locator('[data-cy="customTableAction"] button').first() + await menuButton.waitFor({ state: 'visible' }) + await menuButton.hover() + await menuButton.click({ force: true }) + await page.locator('[data-cy="dataTableShareBtn"]').click() + await expect(page.getByText('Public links')).toBeVisible() + + const createShareReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/api/2/tables/') && r.url().includes('/share') && r.request().method() === 'POST') + + await page.locator('[data-cy="sharingEntryLinkCreateButton"]').click() + + if (options.password) { + await page.locator('[data-cy="sharingEntryLinkPasswordCheck"]').click() + await page.locator('[data-cy="sharingEntryLinkPasswordInput"] input').fill(options.password) + } + + await page.locator('[data-cy="sharingEntryLinkCreateFormCreateButton"]').click() + + const interception = await createShareReqPromise + expect(interception.status()).toBe(200) + const body = await interception.json() + const shareToken = body.ocs.data.shareToken + expect(typeof shareToken).toBe('string') + + if (options.permissions) { + await setSharePermissions(page, options.permissions) + } + + return shareToken as string +} + +async function setSharePermissions(page: Page, permissions: { read?: boolean, create?: boolean, update?: boolean, delete?: boolean }) { + const isCanEdit = permissions.read && permissions.create && permissions.update && permissions.delete + const isViewOnly = permissions.read && !permissions.create && !permissions.update && !permissions.delete + + await page.locator('.share-permission-select .action-item__menutoggle').click() + + if (isCanEdit) { + await page.locator('button[role="menuitemradio"]').filter({ hasText: 'Can edit' }).click() + } else if (isViewOnly) { + await page.locator('button[role="menuitemradio"]').filter({ hasText: 'View only' }).click() + } else { + await page.locator('button[role="menuitemradio"]').filter({ hasText: 'Custom permissions' }).click() + for (const [key, value] of Object.entries(permissions)) { + if (value === undefined) continue + const checkbox = page.locator(`[data-cy="sharePermission${key.charAt(0).toUpperCase() + key.slice(1)}"] input[type="checkbox"]`) + const isChecked = await checkbox.isChecked() + if (isChecked !== value) { + await checkbox.click({ force: true }) + await page.waitForResponse(r => r.url().includes('/share/') && r.url().includes('/permissions') && r.request().method() === 'PUT') + await page.waitForResponse(r => r.url().includes('/apps/tables/share/') && r.request().method() === 'GET') + } + } + return + } + + await page.waitForResponse(r => r.url().includes('/share/') && r.url().includes('/permissions') && r.request().method() === 'PUT') +} + test.describe('Public link sharing', () => { test('Create, access and delete a public link share', async ({ userPage: { page } }) => { const tableTitle = 'Public Share Test Table 1' await setupPublicShareTable(page, tableTitle) - - const menuButton1 = page.locator('[data-cy="customTableAction"] button').first() - await menuButton1.waitFor({ state: 'visible' }) - await menuButton1.hover() - await menuButton1.click({ force: true }) - await page.locator('[data-cy="dataTableShareBtn"]').click() - await expect(page.getByText('Public links')).toBeVisible() - - const createShareReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/api/2/tables/') && r.url().includes('/share') && r.request().method() === 'POST') - await page.locator('[data-cy="sharingEntryLinkCreateButton"]').click() - await page.locator('[data-cy="sharingEntryLinkCreateFormCreateButton"]').click() - - const interception = await createShareReqPromise - expect(interception.status()).toBe(200) - const body = await interception.json() - const shareToken = body.ocs.data.shareToken - expect(typeof shareToken).toBe('string') + const origin = new URL(page.url()).origin + const shareToken = await createPublicLinkShare(page) const publicContext = await page.context().browser()!.newContext() const publicPage = await publicContext.newPage() - const origin = new URL(page.url()).origin await publicPage.goto(`${origin}/index.php/apps/tables/s/${shareToken}`) await expect(publicPage.locator('[data-cy="publicTableElement"]')).toBeVisible({ timeout: 15000 }) await expect(publicPage.locator('h1').filter({ hasText: tableTitle })).toBeVisible({ timeout: 15000 }) @@ -82,35 +126,11 @@ test.describe('Public link sharing', () => { const tableTitle = 'Public Share Test Table 2' await setupPublicShareTable(page, tableTitle) const password = 'extremelySafePassword123' - - const menuButton3 = page.locator('[data-cy="customTableAction"] button').first() - await menuButton3.waitFor({ state: 'visible' }) - await menuButton3.hover() - await menuButton3.click({ force: true }) - await page.locator('[data-cy="dataTableShareBtn"]').click() - await expect(page.getByText('Public links')).toBeVisible() - - const createShareReqPromise = page.waitForResponse(r => r.url().includes('/apps/tables/api/2/tables/') && r.url().includes('/share') && r.request().method() === 'POST') - - // Open create form - await page.locator('[data-cy="sharingEntryLinkCreateButton"]').click() - - // Set password - await page.locator('[data-cy="sharingEntryLinkPasswordCheck"]').click() - await page.locator('[data-cy="sharingEntryLinkPasswordInput"] input').fill(password) - - // Create - await page.locator('[data-cy="sharingEntryLinkCreateFormCreateButton"]').click() - - const interception = await createShareReqPromise - expect(interception.status()).toBe(200) - const body = await interception.json() - const shareToken = body.ocs.data.shareToken - expect(typeof shareToken).toBe('string') + const origin = new URL(page.url()).origin + const shareToken = await createPublicLinkShare(page, { password }) const publicContext = await page.context().browser()!.newContext() const publicPage = await publicContext.newPage() - const origin = new URL(page.url()).origin await publicPage.goto(`${origin}/index.php/apps/tables/s/${shareToken}`) // Password Gate @@ -141,4 +161,90 @@ test.describe('Public link sharing', () => { await expect(verifyPage.locator('h2').filter({ hasText: 'Share not found' })).toBeVisible() await verifyContext.close() }) + + test('View only public share has no create/edit/delete buttons', async ({ userPage: { page } }) => { + const tableTitle = 'Public Share Permissions - View only' + await setupPublicShareTable(page, tableTitle) + const origin = new URL(page.url()).origin + const shareToken = await createPublicLinkShare(page) + + const publicContext = await page.context().browser()!.newContext() + const publicPage = await publicContext.newPage() + await publicPage.goto(`${origin}/index.php/apps/tables/s/${shareToken}`) + await expect(publicPage.locator('[data-cy="publicTableElement"]')).toBeVisible({ timeout: 15000 }) + await expect(publicPage.getByRole('button', { name: /Create row/i })).toHaveCount(0) + await expect(publicPage.locator('[data-cy="editRowBtn"]')).toHaveCount(0) + await publicContext.close() + }) + + test('Can edit public share allows create, edit and delete rows', async ({ userPage: { page } }) => { + const tableTitle = 'Public Share Permissions Test - Can edit' + await setupPublicShareTable(page, tableTitle) + const origin = new URL(page.url()).origin + const shareToken = await createPublicLinkShare(page, { + permissions: { read: true, create: true, update: true, delete: true }, + }) + + const publicContext = await page.context().browser()!.newContext() + const publicPage = await publicContext.newPage() + await publicPage.goto(`${origin}/index.php/apps/tables/s/${shareToken}`) + await expect(publicPage.locator('[data-cy="publicTableElement"]')).toBeVisible({ timeout: 15000 }) + + // Verify create button exists + await expect(publicPage.getByRole('button', { name: /Create row/i })).toBeVisible() + + // Create a row + await publicPage.getByRole('button', { name: /Create row/i }).click() + await expect(publicPage.locator('[data-cy="createRowModal"]')).toBeVisible() + await publicPage.locator('[data-cy="createRowModal"]').getByRole('textbox').first().fill('Test row for editing') + await publicPage.locator('[data-cy="createRowSaveButton"]').click() + await expect(publicPage.locator('[data-cy="ncTable"]').getByText('Test row for editing')).toBeVisible() + + // Verify edit button exists and edit the row + await expect(publicPage.locator('[data-cy="editRowBtn"]').last()).toBeVisible() + await publicPage.locator('[data-cy="editRowBtn"]').last().click() + await expect(publicPage.locator('[data-cy="editRowModal"]')).toBeVisible() + await publicPage.locator('[data-cy="editRowModal"]').getByRole('textbox').first().fill('Updated row') + await publicPage.locator('[data-cy="editRowSaveButton"]').click() + await expect(publicPage.locator('[data-cy="ncTable"]').getByText('Updated row')).toBeVisible() + await expect(publicPage.locator('[data-cy="ncTable"]').getByText('Test row for editing')).toHaveCount(0) + + // Delete the row + await publicPage.locator('[data-cy="editRowBtn"]').last().click() + await expect(publicPage.locator('[data-cy="editRowModal"]')).toBeVisible() + await publicPage.locator('[data-cy="editRowDeleteButton"]').click() + await publicPage.locator('[data-cy="editRowDeleteConfirmButton"]').click() + await expect(publicPage.locator('[data-cy="editRowModal"]')).toBeHidden() + await expect(publicPage.locator('[data-cy="ncTable"]').getByText('Updated row')).toHaveCount(0) + + await publicContext.close() + }) + + test('Create only public share shows form mode and allows submitting', async ({ userPage: { page } }) => { + const tableTitle = 'Public Share Permissions Test - Create only' + await setupPublicShareTable(page, tableTitle) + const origin = new URL(page.url()).origin + const shareToken = await createPublicLinkShare(page, { + permissions: { read: false, create: true, update: false, delete: false }, + }) + + const publicContext = await page.context().browser()!.newContext() + const publicPage = await publicContext.newPage() + await publicPage.goto(`${origin}/index.php/apps/tables/s/${shareToken}`) + + await expect(publicPage.getByText('This is a public form.')).toBeVisible({ timeout: 15000 }) + await expect(publicPage.getByRole('button', { name: /Fill form/i })).toBeVisible() + await expect(publicPage.locator('[data-cy="ncTable"] table')).toHaveCount(0) + + await publicPage.getByRole('button', { name: /Fill form/i }).click() + await expect(publicPage.locator('[data-cy="createRowModal"]')).toBeVisible() + await publicPage.locator('[data-cy="createRowModal"]').getByRole('textbox').first().fill('Form submission') + await publicPage.locator('[data-cy="createRowSaveButton"]').click() + await expect(publicPage.locator('.toastify.toast-success')).toBeVisible() + await publicContext.close() + + await page.goto('/index.php/apps/tables') + await loadTable(page, tableTitle) + await expect(page.locator('[data-cy="ncTable"]').getByText('Form submission')).toBeVisible() + }) }) diff --git a/src/modules/main/partials/TableView.vue b/src/modules/main/partials/TableView.vue index 741f1cebc7..75fe2d71f8 100644 --- a/src/modules/main/partials/TableView.vue +++ b/src/modules/main/partials/TableView.vue @@ -18,6 +18,7 @@ :can-edit-columns="canEditColumns" :can-delete-columns="canDeleteColumns" :can-delete-table="canDeleteTable" + :is-form-mode="isFormMode" @import="openImportModal" @create-column="createColumn" @edit-column="editColumn" @@ -99,6 +100,10 @@ export default { type: Boolean, default: true, }, + isFormMode: { + type: Boolean, + default: false, + }, }, data() { return { diff --git a/src/modules/main/sections/ElementTitle.vue b/src/modules/main/sections/ElementTitle.vue index 239fd47569..e4cb4f4ad4 100644 --- a/src/modules/main/sections/ElementTitle.vue +++ b/src/modules/main/sections/ElementTitle.vue @@ -3,7 +3,7 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->