Skip to content

fix(secrets): move-dialog reachability, immutable type, and folder-filter refreshes - #600

Open
remko48 wants to merge 9 commits into
developmentfrom
fix/dialog-select-reachability-and-readonly-type
Open

fix(secrets): move-dialog reachability, immutable type, and folder-filter refreshes#600
remko48 wants to merge 9 commits into
developmentfrom
fix/dialog-select-reachability-and-readonly-type

Conversation

@remko48

@remko48 remko48 commented Sep 2, 2026

Copy link
Copy Markdown
Member

Depends on

#599 must be merged first. This branch is cut from
fix/must-list-bug-sweep, so until #599 lands its commit also shows in this
diff; retargeting this PR's base at that branch shows the four commits below
on their own.

fix(secrets): freeze a secret's type after creation

The edit dialog offered a free Type select while the payload shape is
type-dependent — the card and identity composites render conditionally on
isCard / isIdentity and serialize into key differently. Switching a Login
to a Card left the card fields empty and the login fields unreachable, and the
diff sent the new typeId anyway, so a saved secret's declared type no longer
matched its stored payload. Bitwarden and 1Password both freeze type after
creation for the same reason.

Type is read-only text here; the CREATE dialog keeps its select, where there is
no existing payload to invalidate. The typeId branch is removed from the diff
rather than guarded, so a later change that sets typeId behind the template's
back still cannot ship a type switch built for the old shape.

fix(secrets): keep the folder filter when the list refreshes

Moving a secret out of a folder made every other secret in the vault appear in
that folder.

The list passed folderId / typeId / search as call arguments on every
fetch and nothing ever wrote them to filters, so they stayed permanently
null — while the refreshes that run after a mutation call fetchSecrets() with
no arguments. Each therefore re-queried the whole vault and replaced the
folder's contents with it. The sidebar's own comment asserted the opposite
("reuses the store's active filters/sort/page"); this makes that true.

Two things fell out: paging a type-filtered list silently un-filtered it
(goToPage() never passed typeId), and fetchAllSecrets() must NOT inherit
the stored query or an export would shrink to the browsed folder — so it passes
a whole-vault scope explicitly, and the lookups test key presence rather than
nullishness, which also makes an explicit "no filter" work.

refactor(dialogs): one move dialog and one destination picker

Moving a secret and moving a vault's contents were two dialogs for one verb,
drifted apart: one drew each candidate with its own icon, colour and tinted
circle as the nav rail does, the other showed bare path strings — and fixes
reached one and not the other. MoveDialog replaces both; subject switches
the title, the destination label, the candidate set and the vault-only chrome,
and nothing else. The two mechanisms stay separate methods behind a two-line
dispatch, because one atomic PUT and a non-atomic client-driven loop share a
frame and a picker, not a mechanism.

DestinationSelect is the shared control and settles three things:

  • Options are TREE-ordered — a vault, then its own folders indented beneath it.
    The flat store order listed every vault and then every folder, so a folder
    sat pages from the vault it belongs to. A deep tree means a long list; that
    is the honest trade.
  • There is no vault-root destination. A secret always lives in a vault, and a
    rootless secret has nowhere to be displayed.
  • excludeId drops a whole subtree, so a vault is never a destination for its
    own contents.

The list teleports again, which fixes the clipped-to-a-sliver dropdown that
made "move a secret" unusable. It had been forced inline because an older
@nextcloud/vue painted a teleported list behind NcDialog, leaving the control
dead; that evidence predates 9.11.0, which positions the menu with floating-ui
and renders it over the dialog correctly. The history is kept in the component.

The e2e workflow gains a geometric assertion, because the spec already clicked
that option and passed throughout the bug: Playwright scrolls an element into
view before clicking, so the test covering the flow was structurally blind to a
control no human could reach.

The vault-contents spec moves across intact — it pins the server-sourced
subfolder set and the per-item failure accounting — and the secret path gains
the component test it never had, including that it never runs the vault
machinery, which is the risk a merged dialog carries.

refactor(bulk): move the bulk-move target onto the shared picker

BulkMoveDialog held a third copy of the same option builder, without icons and
without the teleport fix. Rewiring it also removed a latent trap: its Move
button keyed off !!targetFolder, which worked only because the old select
bound whole option objects — with a reduced value and a vault-root option, null
would have been indistinguishable from "nothing picked" and bulk-move to the
root impossible.

Note for review

No new l10n strings in this branch: every label reuses an existing catalogue
key, so the parity ratchet is untouched.

The sidebar's ... menu called deleteSecret() straight from the click handler:
the only irreversible action in the app was also the only one with no
confirmation, on the path people actually use, because list-view rows carry no
... menu of their own. The table-view row menu already confirmed.

Adds SecretDeleteConfirmDialog, registered as the `secret-delete` modal and
opened through cnOpenModal like the sidebar's edit, move and share dialogs.
It mirrors FolderDeleteConfirmDialog: it owns the delete call and emits
`deleted`, so a refused delete (403 on a delegated secret, offline write)
keeps the dialog open with the reason inline rather than closing the sidebar
behind it, which reads exactly like a delete that worked.

Reuses the existing "Delete secret", "Cancel" and "Failed to delete secret"
keys; the one new warning string mirrors BulkDeleteDialog's no-trash wording
and ships with its Dutch translation. Note this consumes the last unit of
slack in the l10n parity ratchet: every locale except nl now sits exactly at
its bound, so the next new source string needs translating everywhere.
The edit dialog offered a free Type select, and the payload shape is
type-dependent: the card and identity composites render conditionally on
`isCard` / `isIdentity` and serialize into `key` differently. Switching a Login
to a Card therefore left the card fields empty and the login fields
unreachable, and the diff sent the new `typeId` anyway — a saved secret whose
declared type no longer matched its stored payload. Bitwarden and 1Password
both freeze type after creation for the same reason.

Type now renders as read-only text here; the CREATE dialog keeps its select,
where there is no existing payload to invalidate. The `typeId` branch is
removed from the diff entirely rather than guarded, so even if something later
sets `typeId` behind the template's back, this dialog cannot ship a type switch
built for the old shape. NcSelect is no longer imported at all.

The label reuses the existing `Type` key and the type names already in the
catalogue, so no new source strings.
Moving a secret out of a folder made every other secret in the vault appear in
that folder.

The list passed `folderId` / `typeId` / `search` as call arguments on every
fetch and nothing ever wrote them to `filters`, so they stayed permanently
null — while the refreshes that run after a mutation call `fetchSecrets()` with
NO arguments (the detail sidebar's after edit/move/delete, the vault-contents
move's afterwards). Every one of them therefore re-queried the whole vault and
replaced the folder's contents with it. The sidebar's own comment asserted the
opposite: "fetchSecrets() with no options reuses the store's active
filters/sort/page". This is what makes that true.

`setListQuery()` records what the list is showing and the list declares it in
`reload()` and `goToPage()`. Two things fell out of that:

- Paging a type-filtered list silently un-filtered it: `goToPage()` never
  passed `typeId`.
- `fetchAllSecrets()` must NOT inherit the stored query, or an export would
  shrink to whichever folder was being browsed. It passes WHOLE_VAULT
  explicitly, and the option lookups test key PRESENCE rather than nullishness
  so an explicit null means "no filter" instead of falling back to the stored
  value — which also makes returning to the vault root work.
Moving a secret and moving a vault's contents were two dialogs for one verb,
and they had drifted: the vault one drew each candidate with its own icon,
colour and tinted circle exactly as the nav rail does, the secret one showed a
bare list of path strings. Same choice, different control depending on how you
got there — and fixes reached one and not the other.

MoveDialog replaces both. `subject` switches the title, the destination label,
the picker's candidate set and the vault-only chrome (the emptiness hint, the
list of items that stayed behind); nothing else. The two mechanisms stay
separate methods behind a two-line dispatch, because one atomic PUT and a
non-atomic client-driven loop share a frame and a picker, not a mechanism.
`moveVaultContents()` keeps the server-sourced subfolder set and the per-item
failure accounting verbatim — that behaviour is what makes a non-atomic move
survivable.

DestinationSelect is the shared control, and it settles three things the
callers kept getting differently:

- Options are TREE-ordered: a vault, then its own folders indented beneath it,
  then the next vault. The flat store order listed every vault and then every
  folder, so a folder sat pages away from the vault it belongs to and the list
  read as two unrelated groups. Path labels go with it — the indentation says
  where a row sits, and a repeated path on every row of a deep tree is noise.
  A deep tree means a long list; that is the honest trade.
- There is NO vault-root destination. A secret always lives in a vault, and a
  rootless secret has nowhere to be displayed.
- `excludeId` drops a whole subtree, so a vault is never offered as a
  destination for its own contents.

The option list teleports again, which also fixes the clipped-to-a-sliver
dropdown that made "move a secret" unusable. It had been forced inline because
an older @nextcloud/vue painted a teleported list BEHIND NcDialog, leaving the
control dead — that evidence predates 9.11.0, which positions the menu with
floating-ui and renders it correctly over the dialog. Inline, it was instead
clipped by the dialog's 52px content box, and reserving room for it made every
move dialog 300px tall. The history is preserved in the component so nobody
has to rediscover it.

The e2e workflow gains a geometric assertion, because the spec already clicked
that option and passed throughout the bug: Playwright scrolls an element into
view before clicking, so the test that covered the flow was structurally blind
to a control no human could reach. It now measures the list against the
viewport and any clipping ancestor.

The vault-contents spec moves to MoveDialog.vaultContents intact, and the
secret path gains the component test SecretMoveDialog never had — including
that it never runs the vault machinery, which is the risk a merged dialog
carries.

Every label reuses an existing catalogue key; no new source strings.
BulkMoveDialog held a third copy of the "vault root plus every folder,
path-labelled" builder, with no icons and without turning off the teleport, so
the same choice looked different again and this one still had the dead-control
variant on older library versions.

It now renders DestinationSelect, which also removes a latent trap: the Move
button keyed off `!!targetFolder`, which worked only because the old select
bound whole option objects. With a reduced value and a vault-root option, null
would have been indistinguishable from "nothing picked" and bulk-move to the
vault root would have been impossible. There is no root destination any more,
so null and unchosen are the same state and the simple check is correct.
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 1fc98f0

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 111/111
npm ✅ 536/536
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development
Hydra gates

Quality workflow — 2026-09-02 12:42 UTC

Download the full PDF report from the workflow artifacts.

Three CI checks were red on this branch and none of them should have reached
CI:

- `lint-check` and `Vue Quality (eslint)`: one error, `perfectionist/
  sort-imports` in src/registry.js. Swapping SecretMoveDialog's import for
  MoveDialog in place left the list out of alphabetical order.
- `Hydra Gates`: gate-16 spec-coverage, four changed methods with no `@spec`
  annotation — BulkMoveDialog::moveOne, secret store::fetchAllSecrets, and
  SecretList::reload / ::goToPage. Each gets the anchor its neighbours already
  use: the bulk-operations requirement for the per-item move, and
  list-and-pagination for the three list/paging methods.

Also declares SecretEditDialog's `emits: ['saved', 'close']`. It emitted both
without declaring either, which `vue/require-explicit-emits` warned about in a
file this branch already touches.

Verified locally against the same gate runner CI uses, rather than by pushing
and waiting: eslint 0 errors, stylelint clean, hydra exit 0 over 72 gates,
prettier clean, 744 unit tests green.
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 2f26219

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 111/111
npm ✅ 536/536
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development
Hydra gates

Quality workflow — 2026-09-02 13:20 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 4bfc1b0

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 111/111
npm ✅ 536/536
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development
Hydra gates

Quality workflow — 2026-09-03 07:21 UTC

Download the full PDF report from the workflow artifacts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant