diff --git a/css/error.css b/css/error.css new file mode 100644 index 0000000000..a303b260e5 --- /dev/null +++ b/css/error.css @@ -0,0 +1,64 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +.tables-error-wrapper { + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} + +.body-public-container { + --color-text-maxcontrast: var( + --color-text-maxcontrast-background-blur, + var(--color-main-text) + ); + color: var(--color-main-text); + background-color: var(--color-main-background-blur); + padding: calc(3 * var(--default-grid-baseline)); + box-shadow: 0 0 10px var(--color-box-shadow); + -webkit-backdrop-filter: var(--filter-background-blur); + backdrop-filter: var(--filter-background-blur); + display: flex; + flex-direction: column; + text-align: start; + word-wrap: break-word; + border-radius: 10px; + cursor: default; + -moz-user-select: text; + -webkit-user-select: text; + -ms-user-select: text; + user-select: text; + height: fit-content; + width: 100%; + max-width: 700px; + margin-block: 10vh auto; +} + +.body-public-container .icon-big { + background-size: 70px; + height: 70px; +} + +.body-public-container form { + width: initial; +} + +.body-public-container p:not(:last-child) { + margin-bottom: 12px; +} + +.infogroup { + margin: 8px 0; +} + +.infogroup:last-child { + margin-bottom: 0; +} + +.update { + width: calc(100% - 32px); + text-align: center; +} diff --git a/cypress/e2e/tables-sharing-link.cy.js b/cypress/e2e/tables-sharing-link.cy.js new file mode 100644 index 0000000000..b2041cc4fb --- /dev/null +++ b/cypress/e2e/tables-sharing-link.cy.js @@ -0,0 +1,121 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +describe('Public link sharing', () => { + let localUser + const tableTitle = 'Public Share Test Table' + + before(() => { + cy.createRandomUser().then(user => { + localUser = user + cy.login(localUser) + }) + }) + + beforeEach(() => { + cy.login(localUser) + cy.visit('apps/tables') + cy.get('[data-cy="navigationCreateTableIcon"]').click({ force: true }) + cy.get('.modal__content input[type="text"]').clear().type(tableTitle) + cy.get('.tile').contains('ToDo').click({ force: true }) + cy.get('[data-cy="createTableSubmitBtn"]').scrollIntoView().click() + cy.loadTable(tableTitle) + }) + + it('Create, access and delete a public link share', () => { + + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableShareBtn"]').click() + cy.contains('Public links').should('be.visible') + cy.intercept('POST', '**/apps/tables/api/2/tables/*/share').as('createShare') + cy.get('[data-cy="sharingEntryLinkCreateButton"]').click() + cy.get('[data-cy="sharingEntryLinkCreateFormCreateButton"]').click() + + cy.wait('@createShare').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + const shareToken = interception.response.body.ocs.data.shareToken + expect(shareToken).to.be.a('string') + + cy.clearCookies() + cy.visit(`apps/tables/s/${shareToken}`) + cy.get('[data-cy="publicTableElement"]').should('be.visible') + cy.clickOnTableThreeDotMenu('Export as CSV') + + // Verify we cannot edit (simple check: no edit controls or just read-only mode) + // We can check that the "Add row" button is missing. + cy.get('[data-cy="addRowBtn"]').should('not.exist') + + // Login again to delete share + cy.login(localUser) + cy.visit('apps/tables') + cy.loadTable(tableTitle) + + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableShareBtn"]').click() + + cy.get('[data-cy="sharingEntryLinkTitle"]').should('be.visible') + cy.get('[data-cy="sharingEntryLinkDeleteButton"]').click() + + cy.get('[data-cy="sharingEntryLinkTitle"]').should('not.exist') + + // Verify share is gone + cy.clearCookies() + cy.visit(`apps/tables/s/${shareToken}`, { failOnStatusCode: false }) + cy.get('h2').contains('Share not found').should('be.visible') + }) + }) + + it('Create, access and delete a password protected public link share', () => { + const password = 'extremelySafePassword123' + + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableShareBtn"]').click() + cy.contains('Public links').should('be.visible') + cy.intercept('POST', '**/apps/tables/api/2/tables/*/share').as('createShare') + + // Open create form + cy.get('[data-cy="sharingEntryLinkCreateButton"]').click() + + // Set password + cy.get('[data-cy="sharingEntryLinkPasswordCheck"]').click() + cy.get('[data-cy="sharingEntryLinkPasswordInput"] input').type(password) + + // Create + cy.get('[data-cy="sharingEntryLinkCreateFormCreateButton"]').click() + + cy.wait('@createShare').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + const shareToken = interception.response.body.ocs.data.shareToken + expect(shareToken).to.be.a('string') + + cy.clearCookies() + cy.visit(`apps/tables/s/${shareToken}`) + + // Password Gate + cy.get('input[type="password"]#password').should('be.visible').type(password) + cy.get('input#password-submit').click() + cy.get('[data-cy="publicTableElement"]').should('be.visible') + + // Login again to delete share + cy.login(localUser) + cy.visit('apps/tables') + cy.loadTable(tableTitle) + + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableShareBtn"]').click() + + cy.get('[data-cy="sharingEntryLinkTitle"]').should('be.visible') + cy.get('[data-cy="sharingEntryLinkDeleteButton"]').click() + + cy.get('[data-cy="sharingEntryLinkTitle"]').should('not.exist') + + // Verify share is gone + cy.clearCookies() + cy.visit(`apps/tables/s/${shareToken}`, { failOnStatusCode: false }) + cy.get('h2').contains('Share not found').should('be.visible') + }) + }) +}) + diff --git a/lib/Controller/ApiColumnsController.php b/lib/Controller/ApiColumnsController.php index 9a8de80e9b..7b4f9eea92 100644 --- a/lib/Controller/ApiColumnsController.php +++ b/lib/Controller/ApiColumnsController.php @@ -4,9 +4,11 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\Tables\Controller; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Db\Column; use OCA\Tables\Dto\Column as ColumnDto; use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; @@ -15,6 +17,7 @@ use OCA\Tables\Middleware\Attribute\RequirePermission; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnService; +use OCA\Tables\Service\ShareService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; @@ -27,12 +30,14 @@ */ class ApiColumnsController extends ACommonColumnsController { + public function __construct( IRequest $request, LoggerInterface $logger, ColumnService $service, IL10N $n, string $userId, + protected ShareService $shareService, ) { parent::__construct($request, $logger, $n, $userId); $this->service = $service; @@ -49,8 +54,10 @@ public function __construct( * array{}>|DataResponse * + * * 200: View deleted * 400: Invalid input arguments + * * 403: No permissions * 404: Not found */ @@ -59,6 +66,7 @@ public function __construct( public function index(int $nodeId, string $nodeType): DataResponse { try { $columns = $this->getColumnsFromTableOrView($nodeType, $nodeId); + return new DataResponse($this->service->formatColumns($columns)); } catch (PermissionError $e) { return $this->handlePermissionError($e); @@ -79,6 +87,7 @@ public function index(int $nodeId, string $nodeType): DataResponse { * array{}>|DataResponse * + * * 200: Column returned * 403: No permissions * 404: Not found @@ -118,10 +127,12 @@ public function show(int $id): DataResponse { * @param array $customSettings Custom settings for the * column * + * * @return DataResponse|DataResponse * + * * 200: Column created * 403: No permission * 404: Not found @@ -169,10 +180,12 @@ public function createNumberColumn(int $baseNodeId, string $title, ?float $numbe * @param int|null $textMaxLength Max raw text length * @param bool|null $textUnique Whether the text value must be unique, if * column is a text + * * @param 'progress'|'stars'|null $subtype Subtype for the new column * @param string|null $description Description * @param list|null $selectedViewIds View IDs where this columns * should be added + * * @param boolean $mandatory Is mandatory * @param 'table'|'view' $baseNodeType Context type of the column creation * @param array $customSettings Custom settings for the @@ -181,6 +194,7 @@ public function createNumberColumn(int $baseNodeId, string $title, ?float $numbe * array{}>|DataResponse * + * * 200: Column created * 403: No permission * 404: Not found @@ -226,19 +240,23 @@ public function createTextColumn(int $baseNodeId, string $title, ?string $textDe * 2, "label": "second"}] * @param string|null $selectionDefault Json int|list for default * selected option(s), eg 5 or ["1", "8"] + * * @param 'progress'|'stars'|null $subtype Subtype for the new column * @param string|null $description Description * @param list|null $selectedViewIds View IDs where this columns * should be added + * * @param boolean $mandatory Is mandatory * @param 'table'|'view' $baseNodeType Context type of the column creation * @param array $customSettings Custom settings for the * column * + * * @return DataResponse|DataResponse * + * * 200: Column created * 403: No permission * 404: Not found @@ -279,19 +297,23 @@ public function createSelectionColumn(int $baseNodeId, string $title, string $se * @param string $title Title * @param 'today'|'now'|null $datetimeDefault For a subtype 'date' you can * set 'today'. For a main type or subtype 'time' you can set to 'now'. + * * @param 'progress'|'stars'|null $subtype Subtype for the new column * @param string|null $description Description * @param list|null $selectedViewIds View IDs where this columns * should be added + * * @param boolean $mandatory Is mandatory * @param 'table'|'view' $baseNodeType Context type of the column creation * @param array $customSettings Custom settings for the * column * + * * @return DataResponse|DataResponse * + * * 200: Column created * 403: No permission * 404: Not found @@ -331,6 +353,7 @@ public function createDatetimeColumn(int $baseNodeId, string $title, ?string $da * eg [{"id": "admin", "type": 0}, {"id": "user1", "type": 0}] * @param boolean $usergroupMultipleItems Whether you can select multiple * users or/and groups + * * @param boolean $usergroupSelectUsers Whether you can select users * @param boolean $usergroupSelectGroups Whether you can select groups * @param boolean $usergroupSelectTeams Whether you can select teams @@ -338,15 +361,18 @@ public function createDatetimeColumn(int $baseNodeId, string $title, ?string $da * @param string|null $description Description * @param list|null $selectedViewIds View IDs where this columns * should be added + * * @param boolean $mandatory Is mandatory * @param 'table'|'view' $baseNodeType Context type of the column creation * @param array $customSettings Custom settings for the * column * + * * @return DataResponse|DataResponse * + * * 200: Column created * 403: No permission * 404: Not found diff --git a/lib/Controller/ApiPublicColumnsController.php b/lib/Controller/ApiPublicColumnsController.php index 25239d40aa..44858e5c71 100644 --- a/lib/Controller/ApiPublicColumnsController.php +++ b/lib/Controller/ApiPublicColumnsController.php @@ -67,7 +67,9 @@ public function indexByPublicLink(string $token): DataResponse { $shareToken = new ShareToken($token); } catch (InvalidArgumentException $e) { return $this->handleBadRequestError(new BadRequestError( - 'Invalid share token', $e->getCode(), $e + 'Invalid share token', + $e->getCode(), + $e )); } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 5455a1945a..c4c6cb41e9 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -78,6 +78,6 @@ protected function loadStyles(): void { Util::addStyle(Application::APP_ID, 'grid'); Util::addStyle(Application::APP_ID, 'modal'); Util::addStyle(Application::APP_ID, 'tiptap'); - Util::addStyle(Application::APP_ID, 'tables-style'); + Util::addStyle(Application::APP_ID, 'error'); } } diff --git a/lib/Controller/PublicSharePageController.php b/lib/Controller/PublicSharePageController.php index adb2cabaf6..b988a1175e 100644 --- a/lib/Controller/PublicSharePageController.php +++ b/lib/Controller/PublicSharePageController.php @@ -31,8 +31,8 @@ #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class PublicSharePageController extends AuthPublicShareController { - private readonly Share $share; - private readonly ShareToken $shareToken; + private ?Share $share = null; + private ?ShareToken $shareToken = null; public function __construct( string $appName, @@ -45,8 +45,11 @@ public function __construct( private IEventDispatcher $eventDispatcher, ) { parent::__construct($appName, $request, $session, $urlGenerator); - $this->shareToken = new ShareToken($this->getToken()); - $this->share = $this->shareService->findByToken($this->shareToken); + $token = $request->getParam('token'); + if (is_string($token) && $token !== '') { + $this->shareToken = new ShareToken($token); + $this->share = $this->shareService->findByToken($this->shareToken); + } } #[PublicPage] @@ -54,6 +57,7 @@ public function __construct( #[FrontpageRoute(verb: 'GET', url: '/s/{token}')] #[AnonRateLimit(limit: 10, period: 10)] public function showShare(): TemplateResponse { + $this->loadStyles(); Util::addScript(Application::APP_ID, 'tables-main'); @@ -91,11 +95,13 @@ protected function getPasswordHash(): ?string { } public function isValidToken(): bool { - $this->shareToken; - return true; + return $this->share !== null; } protected function isPasswordProtected(): bool { + if ($this->share === null) { + return false; + } $password = $this->share->getPassword(); return $password !== null && $password !== ''; } diff --git a/src/App.vue b/src/App.vue index 28da579124..09465b68a0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,19 +4,21 @@ --> diff --git a/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue b/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue index 7fbc886e0e..73ee0ba7bd 100644 --- a/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue +++ b/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue @@ -285,7 +285,7 @@ export default { } .row-elements.actions { - margin-left: auto; + margin-inline-start: auto; } .column-entry:hover .row-elements.move, .column-entry:focus-within .row-elements.move, .column-entry:hover .row-elements.actions, .column-entry:focus-within .row-elements.actions { @@ -298,7 +298,7 @@ export default { .mandatory-indicator { color: var(--color-error); - margin-left: 4px; + margin-inline-start: 4px; font-size: 16px; line-height: 1; } diff --git a/src/modules/main/sections/Dashboard.vue b/src/modules/main/sections/Dashboard.vue index a063ec2a6c..0fa9e3d936 100644 --- a/src/modules/main/sections/Dashboard.vue +++ b/src/modules/main/sections/Dashboard.vue @@ -129,6 +129,7 @@ import { NcActionButton, NcActions, NcAvatar, NcButton, NcLoadingIcon } from '@n import PlaylistEditIcon from 'vue-material-design-icons/PlaylistEdit.vue' import { emit } from '@nextcloud/event-bus' import { showError, showSuccess } from '@nextcloud/dialogs' +import { isPublicLinkShare } from '../../../shared/utils/shareUtils.js' export default { components: { @@ -263,7 +264,8 @@ export default { async loadShares() { // load shares for table this.loadingTableShares = true - this.tableShares = await this.getSharesForTableFromBE(this.table.id) + const allTableShares = await this.getSharesForTableFromBE(this.table.id) + this.tableShares = allTableShares.filter(s => !isPublicLinkShare(s)) this.loadingTableShares = false // load shares for all views @@ -271,10 +273,10 @@ export default { for (const index in this.table.views) { const view = this.table.views[index] if (view.hasShares) { - this.viewShares[view.id] = await this.getSharesForViewFromBE(view.id) + const allViewShares = await this.getSharesForViewFromBE(view.id) + this.viewShares[view.id] = allViewShares.filter(s => !isPublicLinkShare(s)) } } - this.tableShares = await this.getSharesForTableFromBE(this.table.id) this.loadingViewShares = false }, }, diff --git a/src/modules/main/sections/PublicElement.vue b/src/modules/main/sections/PublicElement.vue new file mode 100644 index 0000000000..9b797ea891 --- /dev/null +++ b/src/modules/main/sections/PublicElement.vue @@ -0,0 +1,60 @@ + + + + diff --git a/src/modules/main/sections/PublicMainWrapper.vue b/src/modules/main/sections/PublicMainWrapper.vue new file mode 100644 index 0000000000..cbc5bbd2a1 --- /dev/null +++ b/src/modules/main/sections/PublicMainWrapper.vue @@ -0,0 +1,114 @@ + + + + + + diff --git a/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index ade3c3909e..9d01cf4f93 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -3,15 +3,9 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->