From fa0b80db6502b2924c676eb42c557676511808e8 Mon Sep 17 00:00:00 2001 From: Remko Date: Thu, 3 Sep 2026 10:50:37 +0200 Subject: [PATCH 1/4] fix(dashboard): push the quick-action tiles through the router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "New secret" and "Register application" dashboard tiles landed on the lock screen: linkType 'app' resolves to a full browser navigation, the vault master key is memory-only, so every tile click re-locked the vault. The '#/secrets' hash values were dead weight on top — main.js routes with createWebHistory and the fragment is never read — and the manifest _note still claimed hash routing, which made those links look correct on review. The tiles now use the tile widgets' linkType 'route', which pushes the value through the host vue-router so the SPA and the unlocked vault survive the click. They target the create actions rather than the bare lists: SecretList consumes ?action=create and ApplicationRegisterView consumes ?action=register — each opens its dialog and strips the marker from the URL, so a refresh (which round-trips the query through the lock screen's returnUrl) does not re-open the dialog on every unlock. Watchers rather than mounted() checks, because CnPageRenderer keeps a view mounted when only the query changes. The stale _note is corrected. Two interactions shaped the SecretList side. CnAppRoot closes the active registry modal on every route change, and stripping the query IS a route change — so the marker is stripped first and the dialog opened after, and the spec pins that order. And the library's CnIndexPage also consumes ?action=create, opening its own generic schema-form dialog over the registry one; :showFormDialog="false" is its documented opt-out, and the @add path is unaffected because the view listens to it. Requires @conduction/nextcloud-vue with route-type tile support; until that release ships, the released library renders these tiles with a dead href (no navigation), not a broken page. --- src/manifest.json | 10 +-- src/views/ApplicationRegisterView.vue | 25 ++++++++ src/views/SecretList.vue | 51 +++++++++++++++ tests/views/ApplicationRegisterView.spec.js | 44 +++++++++++++ tests/views/SecretList.quickAction.spec.js | 70 +++++++++++++++++++++ 5 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 tests/views/SecretList.quickAction.spec.js diff --git a/src/manifest.json b/src/manifest.json index 00360143..07032f22 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -327,14 +327,14 @@ { "id": "quick-action-new-secret", "type": "tile", - "_note": "Union of the bump-ncvue branch's real quick-action tiles with development's ADR-049 placeholder text; wired here in place of the quick-actions-text placeholder since these already point at live deep links. Tile fields sit at the def's top level because CnDashboardPage.getTileConfig() reads them from there, not from content. LINK SHAPE: CnTileWidget resolves linkType 'app' as generateUrl('/apps/' + linkValue), so linkValue must NOT start with /apps/ (a full path doubled into /apps//apps/… and 404'd). The SPA router is hash-based (createWebHashHistory on /apps/keepiq), so an in-app page needs the hash in the value: 'keepiq#/secrets' → {webroot}/apps/keepiq#/secrets, correct on index.php-prefixed instances too.", + "_note": "Union of the bump-ncvue branch's real quick-action tiles with development's ADR-049 placeholder text; wired here in place of the quick-actions-text placeholder since these already point at live deep links. Tile fields sit at the def's top level because CnDashboardPage.getTileConfig() reads them from there, not from content. LINK SHAPE: both in-app tiles use linkType 'route', which the tile widgets push through the host vue-router. linkType 'app' resolved to a FULL page load, and the vault master key is memory-only, so every tile click re-locked the vault and landed on /lock — and the old 'keepiq#/secrets' values were dead weight on top, because main.js routes with createWebHistory and the hash fragment is never read (an earlier revision of this note claimed createWebHashHistory; that stale claim made the hash links look correct on review). The values carry ?action=create / ?action=register, which SecretList and ApplicationRegisterView consume (open the create/register dialog) and then strip from the URL. Requires @conduction/nextcloud-vue with route-type tile support (CnTileWidget/CnDashTileWidget router navigation).", "title": "New secret", "icon": "M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z", "iconType": "svg", "backgroundColor": "#21468B", "textColor": "#ffffff", - "linkType": "app", - "linkValue": "keepiq#/secrets" + "linkType": "route", + "linkValue": "/secrets?action=create" }, { "id": "quick-action-register-application", @@ -344,8 +344,8 @@ "iconType": "svg", "backgroundColor": "#3b82f6", "textColor": "#ffffff", - "linkType": "app", - "linkValue": "keepiq#/applications" + "linkType": "route", + "linkValue": "/applications?action=register" }, { "id": "quick-action-documentation", diff --git a/src/views/ApplicationRegisterView.vue b/src/views/ApplicationRegisterView.vue index 9561df04..e1348033 100644 --- a/src/views/ApplicationRegisterView.vue +++ b/src/views/ApplicationRegisterView.vue @@ -123,6 +123,31 @@ export default { }, }, + watch: { + /** + * Dashboard quick-action deep link (`/applications?action=register`): + * open the register dialog and strip the marker from the URL, so a + * refresh (which re-locks the vault and round-trips the query through + * the lock screen's `returnUrl`) does not re-open the dialog. A + * watcher rather than a mounted() check because CnPageRenderer keeps + * the view mounted when only the query changes. + * + * @param {string|undefined} action The `action` query value. + * @spec openspec/specs/application-mgmt/spec.md#requirement-register-application + */ + '$route.query.action': { + immediate: true, + handler(action) { + if (action === 'register') { + this.dialogOpen = true + const query = { ...this.$route.query } + delete query.action + this.$router.replace({ query }) + } + }, + }, + }, + mounted() { this.store.fetchApplications().catch(() => {}) }, diff --git a/src/views/SecretList.vue b/src/views/SecretList.vue index 34a6a402..dd534757 100644 --- a/src/views/SecretList.vue +++ b/src/views/SecretList.vue @@ -67,10 +67,17 @@ @close="closeBulkDialog" @done="onBulkDone" /> + } + * @spec openspec/specs/secrets-write-ui/spec.md#requirement-create-a-secret-from-the-ui + */ + async consumeCreateAction() { + const query = { ...this.$route.query } + delete query.action + await this.$router.replace({ query }) + await this.$nextTick() + this.openCreateSecret() + }, + /** * Open the create-folder dialog and reload the folder tree on success. * diff --git a/tests/views/ApplicationRegisterView.spec.js b/tests/views/ApplicationRegisterView.spec.js index d7198c5d..52432e43 100644 --- a/tests/views/ApplicationRegisterView.spec.js +++ b/tests/views/ApplicationRegisterView.spec.js @@ -91,4 +91,48 @@ describe('ApplicationRegisterView', () => { await flush() expect(dialog.attributes('data-open')).toBe('true') }) + + // The dashboard's "Register application" tile deep-links to + // `/applications?action=register` through the router (a full page load + // would drop the in-memory vault key). The view consumes the marker: + // dialog open, marker stripped so a refresh cannot re-open it. + it('opens the register dialog from the dashboard quick action and strips the marker', async () => { + vi.spyOn(axios, 'get').mockResolvedValue({ data: [] }) + const replace = vi.fn() + const wrapper = mount(ApplicationRegisterView, { + global: { + mocks: { + $route: { query: { action: 'register', view: 'cards' } }, + $router: { replace, push: vi.fn() }, + }, + }, + }) + await flush() + expect( + wrapper + .find('[data-testid="application-register-dialog"]') + .attributes('data-open'), + ).toBe('true') + expect(replace).toHaveBeenCalledWith({ query: { view: 'cards' } }) + }) + + it('ignores an action marker that is not register', async () => { + vi.spyOn(axios, 'get').mockResolvedValue({ data: [] }) + const replace = vi.fn() + const wrapper = mount(ApplicationRegisterView, { + global: { + mocks: { + $route: { query: { action: 'create' } }, + $router: { replace, push: vi.fn() }, + }, + }, + }) + await flush() + expect( + wrapper + .find('[data-testid="application-register-dialog"]') + .attributes('data-open'), + ).toBe('false') + expect(replace).not.toHaveBeenCalled() + }) }) diff --git a/tests/views/SecretList.quickAction.spec.js b/tests/views/SecretList.quickAction.spec.js new file mode 100644 index 00000000..3ff5d83b --- /dev/null +++ b/tests/views/SecretList.quickAction.spec.js @@ -0,0 +1,70 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction / Keepiq Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * The dashboard's "New secret" tile deep-links to `/secrets?action=create`. + * The tile navigates through the router (a full page load would drop the + * in-memory vault key and land on the lock screen), so the list view must + * consume the marker itself: open the create-secret dialog, then strip + * `action` from the URL so a refresh — which re-locks the vault and + * round-trips the query through the lock screen's `returnUrl` — does not + * re-open the dialog on every unlock. + * + * Options-object style (like SecretList.registryDispatch.spec.js): mounting + * the whole list view drags in the folder tree, search and type catalogue, + * none of which this behaviour touches. + * + * @spec openspec/specs/secrets-write-ui/spec.md#requirement-create-a-secret-from-the-ui + */ + +import { describe, expect, it, vi } from 'vitest' +import SecretList from '../../src/views/SecretList.vue' + +const actionWatcher = SecretList.watch['$route.query.action'] + +describe('SecretList — dashboard quick action (?action=create)', () => { + it('watches the action query immediately, so a fresh mount sees the marker', () => { + // CnPageRenderer keeps the list mounted when only the query changes, + // so this must be a watcher; `immediate` covers the fresh-mount case + // (dashboard tile → route change → new page component). + expect(actionWatcher.immediate).toBe(true) + expect(typeof actionWatcher.handler).toBe('function') + }) + + it('fires only on the create marker', () => { + const consumeCreateAction = vi.fn() + + actionWatcher.handler.call({ consumeCreateAction }, 'create') + expect(consumeCreateAction).toHaveBeenCalledTimes(1) + + actionWatcher.handler.call({ consumeCreateAction }, undefined) + actionWatcher.handler.call({ consumeCreateAction }, 'register') + expect(consumeCreateAction).toHaveBeenCalledTimes(1) + }) + + it('strips the marker FIRST, then opens the create dialog', async () => { + // Order is the contract: CnAppRoot closes the active registry modal on + // every route change, and the query replace is one — a dialog opened + // before the replace is closed in the same tick it opened. + const calls = [] + const ctx = { + openCreateSecret: vi.fn(() => calls.push('open')), + $route: { query: { action: 'create', view: 'cards' } }, + $router: { + replace: vi.fn((to) => { + calls.push('replace') + return Promise.resolve(to) + }), + }, + $nextTick: () => Promise.resolve(), + } + + await SecretList.methods.consumeCreateAction.call(ctx) + + expect(ctx.$router.replace).toHaveBeenCalledWith({ + query: { view: 'cards' }, + }) + expect(ctx.openCreateSecret).toHaveBeenCalledTimes(1) + expect(calls).toEqual(['replace', 'open']) + }) +}) From 52e7702ae5f5a9c619cb0f1d24a32425e7e87d35 Mon Sep 17 00:00:00 2001 From: Remko Date: Thu, 3 Sep 2026 10:50:50 +0200 Subject: [PATCH 2/4] fix(secrets): offer only real folders when creating a secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create dialog's folder picker was a local NcSelect that prepended a "Vault root" option — but the root is not a place a secret can live: top-level folders are vaults, and a rootless secret has nowhere to be shown in the vault views. The picker is now the shared DestinationSelect (the move dialogs' picker): every vault and folder, tree-ordered, with the nav rail's own glyphs and colours, and no root option by design. Creating without a folder stays blocked instead of posting a null folderId — the requiredFields spec that used to pin "sends a null folder when created at the vault root" now pins the refusal. --- src/dialogs/SecretCreateDialog.vue | 43 +++++++++---------- ...ecretCreateDialog.additionalFields.spec.js | 8 +++- .../SecretCreateDialog.requiredFields.spec.js | 16 +++++-- 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/src/dialogs/SecretCreateDialog.vue b/src/dialogs/SecretCreateDialog.vue index 6780004b..f99f1325 100644 --- a/src/dialogs/SecretCreateDialog.vue +++ b/src/dialogs/SecretCreateDialog.vue @@ -124,12 +124,14 @@ :disabled="saving" @update:members="additionalFields = $event" /> - + + mode="folders" + :label="t('keepiq', 'Folder')" /> } - * @spec openspec/specs/secrets-write-ui/spec.md#requirement-create-a-secret-from-the-ui - * @spec openspec/specs/secrets/spec.md#requirement-folder-management - */ - folderOptions() { - const roots = [{ value: null, label: t('keepiq', 'Vault root') }] - return roots.concat( - useFolderStore().folders.map((folder) => ({ - value: folder.id, - label: folder.name, - })), - ) - }, - /** * The label for the secret-value field, which reads "Note" for the * `note` system type and "Secret value" otherwise. @@ -340,10 +326,21 @@ export default { return evaluateScore(this.policy, this.selectedTypeName, this.value) }, + /** + * Whether Create may run: not busy, named, a folder chosen (secrets + * cannot live at the vault root), and the type's own required value + * present and policy-compliant. + * + * @return {boolean} + * @spec exclude Form-enablement guard; no domain behaviour. + */ canSubmit() { if (this.saving || this.locked || this.name.trim() === '') { return false } + if (!this.selectedFolderId) { + return false + } if (this.isCard) { return this.card.number !== '' } diff --git a/tests/dialogs/SecretCreateDialog.additionalFields.spec.js b/tests/dialogs/SecretCreateDialog.additionalFields.spec.js index e349a037..912b6c88 100644 --- a/tests/dialogs/SecretCreateDialog.additionalFields.spec.js +++ b/tests/dialogs/SecretCreateDialog.additionalFields.spec.js @@ -54,7 +54,13 @@ const stubs = { } async function mountDialog() { - const wrapper = mount(SecretCreateDialog, { propsData: {}, global: { stubs } }) + const wrapper = mount(SecretCreateDialog, { + // A folder is required since the picker lost its "Vault root" option + // (secrets cannot live at the root). These scenarios are about the + // additional fields, so any folder will do. + propsData: { folderId: 'folder-1' }, + global: { stubs }, + }) await wrapper.vm.$nextTick() return wrapper diff --git a/tests/dialogs/SecretCreateDialog.requiredFields.spec.js b/tests/dialogs/SecretCreateDialog.requiredFields.spec.js index 09abd52c..d644d8f6 100644 --- a/tests/dialogs/SecretCreateDialog.requiredFields.spec.js +++ b/tests/dialogs/SecretCreateDialog.requiredFields.spec.js @@ -105,7 +105,12 @@ describe('SecretCreateDialog — required fields and folder default', () => { await wrapper.vm.submit() expect(create).not.toHaveBeenCalled() + // Name and value alone are no longer enough: the picker lost its + // "Vault root" option, so a folder must be chosen too. wrapper.vm.name = 'Both present' + expect(wrapper.vm.canSubmit).toBe(false) + + wrapper.vm.selectedFolderId = 'folder-1' expect(wrapper.vm.canSubmit).toBe(true) }) @@ -143,7 +148,11 @@ describe('SecretCreateDialog — required fields and folder default', () => { expect(create.mock.calls[0][0].folderId).toBe('folder-42') }) - it('sends a null folder when created at the vault root', async () => { + // This used to assert the opposite — "sends a null folder when created at + // the vault root". The root is not a place a secret can live (top-level + // folders are Vaults; a rootless secret has nowhere to be shown), so the + // picker no longer offers it and a folderless form must stay blocked. + it('refuses to create at the vault root — a folder must be chosen', async () => { const create = vi .spyOn(useSecretStore(), 'createSecret') .mockResolvedValue({ id: 's1' }) @@ -151,8 +160,9 @@ describe('SecretCreateDialog — required fields and folder default', () => { wrapper.vm.name = 'At the root' wrapper.vm.value = STRONG - await wrapper.vm.submit() - expect(create.mock.calls[0][0].folderId ?? null).toBeNull() + expect(wrapper.vm.canSubmit).toBe(false) + await wrapper.vm.submit() + expect(create).not.toHaveBeenCalled() }) }) From 7b779d9f0ced6e0dcc81e4783ddc25d1e6c7cc54 Mon Sep 17 00:00:00 2001 From: Remko Date: Thu, 3 Sep 2026 10:51:23 +0200 Subject: [PATCH 3/4] style(dashboard): give the quick-action tiles icons that say what they do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "New secret" wore a bare plus and "Register application" a person-with- plus — the latter reads as "add user". The tiles now carry mdi key-plus and mdi application-import, both verbatim from vue-material-design-icons (a hand-recomposed key-plus variant with the badge beside the key was tried and dropped: stock MDI beats a custom remix). The Documentation tile was already right and is untouched. --- src/manifest.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index 07032f22..6d40f0c4 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -329,8 +329,9 @@ "type": "tile", "_note": "Union of the bump-ncvue branch's real quick-action tiles with development's ADR-049 placeholder text; wired here in place of the quick-actions-text placeholder since these already point at live deep links. Tile fields sit at the def's top level because CnDashboardPage.getTileConfig() reads them from there, not from content. LINK SHAPE: both in-app tiles use linkType 'route', which the tile widgets push through the host vue-router. linkType 'app' resolved to a FULL page load, and the vault master key is memory-only, so every tile click re-locked the vault and landed on /lock — and the old 'keepiq#/secrets' values were dead weight on top, because main.js routes with createWebHistory and the hash fragment is never read (an earlier revision of this note claimed createWebHashHistory; that stale claim made the hash links look correct on review). The values carry ?action=create / ?action=register, which SecretList and ApplicationRegisterView consume (open the create/register dialog) and then strip from the URL. Requires @conduction/nextcloud-vue with route-type tile support (CnTileWidget/CnDashTileWidget router navigation).", "title": "New secret", - "icon": "M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z", + "icon": "M7.5 3C9.5 3 11.1 4.2 11.7 6H21V9H18V12H15V9H11.7C11.1 10.8 9.4 12 7.5 12C5 12 3 10 3 7.5S5 3 7.5 3M7.5 6C6.7 6 6 6.7 6 7.5S6.7 9 7.5 9 9 8.3 9 7.5 8.3 6 7.5 6M8 17H11V14H13V17H16V19H13V22H11V19H8V17Z", "iconType": "svg", + "_iconNote": "mdi key-plus, verbatim (vue-material-design-icons/KeyPlus.vue) — the plus badge sits UNDER the key; that is the published glyph, kept unmodified on purpose. A hand-recomposed side-badge variant was tried and dropped: stock MDI beats a custom remix.", "backgroundColor": "#21468B", "textColor": "#ffffff", "linkType": "route", @@ -340,7 +341,7 @@ "id": "quick-action-register-application", "type": "tile", "title": "Register application", - "icon": "M15,14C12.33,14 7,15.33 7,18V20H23V18C23,15.33 17.67,14 15,14M15,12A4,4 0 0,0 19,8A4,4 0 0,0 15,4A4,4 0 0,0 11,8A4,4 0 0,0 15,12M5,15V12H8V10H5V7H3V10H0V12H3V15H5Z", + "icon": "M1 12H10.8L8.3 9.5L9.7 8.1L14.6 13L9.7 17.9L8.3 16.5L10.8 14H1V12M21 2H3C1.9 2 1 2.9 1 4V10.1H3V6H21V20H3V16H1V20C1 21.1 1.9 22 3 22H21C22.1 22 23 21.1 23 20V4C23 2.9 22.1 2 21 2", "iconType": "svg", "backgroundColor": "#3b82f6", "textColor": "#ffffff", From 8c9f4354bc12d126486bb9b2a9c9d23f58eb7a0a Mon Sep 17 00:00:00 2001 From: Remko Date: Thu, 3 Sep 2026 10:54:25 +0200 Subject: [PATCH 4/4] fix(quality): anchor the action-query watchers for the spec-coverage gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate-16 anchors on the function it sees: the @spec docblocks sat on the watcher KEYS while the checker attributed the changed lines to the inner handler functions, so both handlers — and openCreateSecret, pulled into the changed set by a reformat — read as unanchored. The docblocks now sit on the handlers themselves. --- src/views/ApplicationRegisterView.vue | 10 ++++++++++ src/views/SecretList.vue | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/src/views/ApplicationRegisterView.vue b/src/views/ApplicationRegisterView.vue index e1348033..e3a0a893 100644 --- a/src/views/ApplicationRegisterView.vue +++ b/src/views/ApplicationRegisterView.vue @@ -137,6 +137,16 @@ export default { */ '$route.query.action': { immediate: true, + /** + * Open the dialog and strip the marker; anything else is ignored. + * The dialog is local view state (not a registry modal), so the + * query replace cannot close it the way it would a cnOpenModal + * dialog. + * + * @param {string|undefined} action The `action` query value. + * @return {void} + * @spec openspec/specs/application-mgmt/spec.md#requirement-register-application + */ handler(action) { if (action === 'register') { this.dialogOpen = true diff --git a/src/views/SecretList.vue b/src/views/SecretList.vue index dd534757..6741067e 100644 --- a/src/views/SecretList.vue +++ b/src/views/SecretList.vue @@ -988,6 +988,13 @@ export default { */ '$route.query.action': { immediate: true, + /** + * Dispatch the marker to the consumer; anything else is ignored. + * + * @param {string|undefined} action The `action` query value. + * @return {void} + * @spec openspec/specs/secrets-write-ui/spec.md#requirement-create-a-secret-from-the-ui + */ handler(action) { if (action === 'create') { this.consumeCreateAction() @@ -1460,6 +1467,7 @@ export default { * view, and reload the list on success. * * @return {void} + * @spec openspec/specs/secrets-write-ui/spec.md#requirement-create-a-secret-from-the-ui */ openCreateSecret() { this.cnOpenModal('secret-create', {