From 3e5422cce173cc7352b0bfba25c3222d4bc7a5c6 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 19:57:08 +0200 Subject: [PATCH 1/3] docs(openspec): five vault changes from the parity gap rows Trash and archive (vault-04, vault-27), favourites, tags and a last-used sort (vault-09, vault-10, vault-24), one-time codes on logins (vault-16), clone, attachment preview and print (vault-22, vault-26, vault-30), and a duplicate finder (vault-25). Specs only. --- .../changes/vault-duplicate-finder/design.md | 50 +++++++++++++++ .../vault-duplicate-finder/proposal.md | 48 ++++++++++++++ .../specs/vault-duplicates/spec.md | 29 +++++++++ .../changes/vault-duplicate-finder/tasks.md | 21 ++++++ .../design.md | 50 +++++++++++++++ .../proposal.md | 64 +++++++++++++++++++ .../specs/vault-list-organisation/spec.md | 51 +++++++++++++++ .../tasks.md | 29 +++++++++ .../design.md | 51 +++++++++++++++ .../proposal.md | 60 +++++++++++++++++ .../specs/secret-item-actions/spec.md | 45 +++++++++++++ .../tasks.md | 27 ++++++++ .../changes/vault-login-totp-codes/design.md | 50 +++++++++++++++ .../vault-login-totp-codes/proposal.md | 54 ++++++++++++++++ .../specs/login-one-time-codes/spec.md | 39 +++++++++++ .../changes/vault-login-totp-codes/tasks.md | 24 +++++++ .../changes/vault-trash-and-archive/design.md | 53 +++++++++++++++ .../vault-trash-and-archive/proposal.md | 60 +++++++++++++++++ .../specs/vault-trash-and-archive/spec.md | 61 ++++++++++++++++++ .../changes/vault-trash-and-archive/tasks.md | 30 +++++++++ 20 files changed, 896 insertions(+) create mode 100644 openspec/changes/vault-duplicate-finder/design.md create mode 100644 openspec/changes/vault-duplicate-finder/proposal.md create mode 100644 openspec/changes/vault-duplicate-finder/specs/vault-duplicates/spec.md create mode 100644 openspec/changes/vault-duplicate-finder/tasks.md create mode 100644 openspec/changes/vault-favourites-tags-and-last-used/design.md create mode 100644 openspec/changes/vault-favourites-tags-and-last-used/proposal.md create mode 100644 openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md create mode 100644 openspec/changes/vault-favourites-tags-and-last-used/tasks.md create mode 100644 openspec/changes/vault-item-clone-preview-and-print/design.md create mode 100644 openspec/changes/vault-item-clone-preview-and-print/proposal.md create mode 100644 openspec/changes/vault-item-clone-preview-and-print/specs/secret-item-actions/spec.md create mode 100644 openspec/changes/vault-item-clone-preview-and-print/tasks.md create mode 100644 openspec/changes/vault-login-totp-codes/design.md create mode 100644 openspec/changes/vault-login-totp-codes/proposal.md create mode 100644 openspec/changes/vault-login-totp-codes/specs/login-one-time-codes/spec.md create mode 100644 openspec/changes/vault-login-totp-codes/tasks.md create mode 100644 openspec/changes/vault-trash-and-archive/design.md create mode 100644 openspec/changes/vault-trash-and-archive/proposal.md create mode 100644 openspec/changes/vault-trash-and-archive/specs/vault-trash-and-archive/spec.md create mode 100644 openspec/changes/vault-trash-and-archive/tasks.md diff --git a/openspec/changes/vault-duplicate-finder/design.md b/openspec/changes/vault-duplicate-finder/design.md new file mode 100644 index 000000000..1a0b8bd31 --- /dev/null +++ b/openspec/changes/vault-duplicate-finder/design.md @@ -0,0 +1,50 @@ +# Design: find duplicate items in the vault and merge them + +## Context + +At development `4c214a9d`: + +- `src/store/modules/health.js:109` `analyseVault()` fetches the owner-scoped list and `:159` `loadDecryptedRows()` decrypts each value in the browser, excluding authenticator seeds; the engine runs in a web worker (`src/health/worker.js`, `src/health/engine.js`) and is terminated on lock. +- `src/health/engine.js:97-111` already hashes every value and buckets identical digests to mark reuse. +- `openspec/specs/password-health/spec.md:111-112` forbids any endpoint that accepts scores, digests, reuse data or verdicts. +- The import wizard's duplicate step (`src/store/modules/import.js:58-61`, `:83-89`) compares incoming rows with the vault by name and address. +- `src/views/HealthReportView.vue` renders the categories weak, reused, stale, breached, compromised and rotation (`:95-153`). +- `SecretService::update()` (`lib/Service/SecretService.php:825`) accepts new ciphertext for the owner; `delete()` (`:931`) removes a secret and its shares. +- `src/utils/favicon.js` `extractDomain()` turns a stored address into a host. + +## Goals / Non-Goals + +**Goals** +- Show the user where their vault holds the same credential more than once, and let them collapse it in one guided step. + +**Non-Goals** +- Merging recipients' copies of shared items, or items owned by someone else. +- Merging attachments or version histories. The kept item keeps its own; the others' go with them (to the trash when it exists). +- Fuzzy name matching. Grouping is by host, username and value only. + +## Decisions + +**D1. Group on host, username and value.** Exact duplicate: same host (from `extractDomain()` over `url`), same decrypted username and same decrypted value. Likely duplicate: same host and username, different value. Items without an address are grouped on exact name, username and value only. Alternative: reuse the import wizard's name and address match. Rejected: names differ between browsers ("GitHub" and "github.com") while host and username do not. + +**D2. Detection runs with the health engine.** `src/health/duplicates.js` is a pure function called from the same worker, on rows that now also carry the decrypted username. Nothing new is kept after lock. + +**D3. Merge is update then delete.** The kept item is re-saved with the folded additional fields, encrypted in the browser for the owner's suite; the others are deleted through the existing route, so their shares end as today. With `vault-trash-and-archive` in place the delete is a trash move and the merge can be undone item by item. Alternative: a server-side merge endpoint. Rejected: the server cannot read the fields it would merge. + +**D4. Shared items are allowed but announced.** An owned item that is shared is marked in its group, and choosing to merge it away shows how many people lose access. Recipient copies (rows whose source is someone else's) are never listed. + +## Security and zero-knowledge + +Grouping and merging run in the browser on decrypted data the user already may read. No digest, group or count reaches the server, which keeps `password-health` "No Server-Side Health Knowledge". The merge writes ciphertext through the owner's existing update path; the delete audit events carry identifiers and names only, as today. + +## Risks / Trade-offs + +- Two genuinely different accounts with one username on one host (a personal and a work login on one site with the same email) show as a likely duplicate. Likely duplicates are never pre-selected for merge. +- Before the trash lands, a merge deletes for good. The confirmation says so until then. + +## Seed data + +Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register. The dev fixture owner `admin` gets two identical logins for `example.org` and one with a different password, so both group kinds appear. + +## Migration + +None. diff --git a/openspec/changes/vault-duplicate-finder/proposal.md b/openspec/changes/vault-duplicate-finder/proposal.md new file mode 100644 index 000000000..1930c097e --- /dev/null +++ b/openspec/changes/vault-duplicate-finder/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +--- + +# Find duplicate items in the vault and merge them + +## Why + +Duplicates are caught only while importing: the import wizard compares each incoming row with the existing vault by name and address and asks whether to skip it or import it as a copy (`src/store/modules/import.js:58-61`, the `duplicates` step). Items that are already duplicated stay duplicated: two imports from two browsers, a login saved once by hand and once by the extension, or a colleague's shared copy next to one's own. Nothing finds them, and nothing merges them. The password health report already sees part of the problem, since it marks values that are reused, but it treats two copies of the same login as two logins with a reused password. + +### Matrix rows (keepiq `openspec/parity/capabilities.json`) + +| row | capability | Keepiq today | +|---|---|---| +| `vault-25` | Find duplicate items already in the vault and merge them. | `no`: duplicates are caught only while importing; nothing finds or merges duplicates already stored | + +### Demand + +- Feature request, https://community.bitwarden.com/t/duplicate-removal-tool-report-including-merge/648 +- The row is in the core area (vault). + +### Competitors rated yes + +No competitor is rated yes on this row. + +## What Changes + +- **A Duplicates section in the password health report.** Run in the browser over the decrypted vault, it groups the user's own secrets into exact duplicates (same address host, same username and same value) and likely duplicates (same address host and same username, different value). +- **Merge.** For a group the user picks the item to keep. Keepiq folds the other items' additional fields into it (the kept item wins on a clash, and a clashing value is kept under a suffixed key), saves the kept item, and deletes the others. When the trash exists (`vault-trash-and-archive`), the others go to the trash and can be restored. +- **Guard rails.** Only items the user owns can be merged. A shared item in a group is marked, and merging it away says that its recipients lose access. Passkey and authenticator items are never grouped. + +## Capabilities + +### New Capabilities + +- `vault-duplicates`: client-side detection of duplicate items in the user's own vault and a guided merge. + +### Modified Capabilities + +- None in delta form. `password-health` keeps its "No Server-Side Health Knowledge" requirement, which this change honours by running entirely in the browser. + +## Impact + +- **Backend**: none. Merge uses `PUT /api/v1/secrets/{id}` for the kept item and the existing delete route for the others. +- **Frontend**: a pure `src/health/duplicates.js`, a Duplicates section in `src/views/HealthReportView.vue`, a `src/modals/DuplicateMergeModal.vue`, and a decrypt step that includes the username. +- **Database**: none. +- **Security**: no digest, grouping or verdict reaches the server; the merge writes only fresh ciphertext through the existing update path. +- **Cross-app**: none. diff --git a/openspec/changes/vault-duplicate-finder/specs/vault-duplicates/spec.md b/openspec/changes/vault-duplicate-finder/specs/vault-duplicates/spec.md new file mode 100644 index 000000000..b8619dac7 --- /dev/null +++ b/openspec/changes/vault-duplicate-finder/specs/vault-duplicates/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Detect duplicates in the browser + +The system MUST detect duplicate secrets among the secrets the user owns, in the browser, while the vault is unlocked, as part of the password health analysis. Secrets with the same address host, the same decrypted username and the same decrypted value MUST be grouped as exact duplicates; secrets with the same host and username and a different value MUST be grouped as likely duplicates. Passkey and authenticator secrets and recipient copies of other people's secrets MUST NOT be grouped. No group, digest or count MUST be sent to the server. + +#### Scenario: A vault user sees duplicate logins + +- **GIVEN** a vault user who owns two logins for `github.com` with the same username and password, saved from two browser imports +- **WHEN** the user opens the password health report at /password-health +- **THEN** the Duplicates section lists the two logins as one exact duplicate group +- **AND** no request to the server carries the group + +### Requirement: Merge a duplicate group + +The system MUST let the user merge a duplicate group by choosing the secret to keep. The kept secret MUST be saved with the other secrets' additional fields folded in, the kept secret's value winning on a clash and a clashing value being kept under a suffixed key, encrypted in the browser for the owner's active suite. The other secrets MUST then be deleted through the existing delete path. Before confirming, the system MUST say how many people lose access through shares of the secrets being removed. Likely duplicates MUST never be pre-selected for merging. + +#### Scenario: A vault user merges an exact duplicate group + +- **GIVEN** an exact duplicate group of two logins, one with an additional field `recovery email` +- **WHEN** the user keeps the other login and confirms the merge +- **THEN** one login remains and it carries the `recovery email` field +- **AND** the removed login no longer appears in the vault list + +#### Scenario: Merging away a shared item is announced + +- **GIVEN** a duplicate group where the item not kept is shared with two colleagues +- **WHEN** the user opens the merge confirmation +- **THEN** the confirmation says that two people lose access diff --git a/openspec/changes/vault-duplicate-finder/tasks.md b/openspec/changes/vault-duplicate-finder/tasks.md new file mode 100644 index 000000000..3ba23fc95 --- /dev/null +++ b/openspec/changes/vault-duplicate-finder/tasks.md @@ -0,0 +1,21 @@ +# Tasks: find duplicate items in the vault and merge them + +## 1. Detection + +- [ ] 1.1 Add `src/health/duplicates.js` returning exact and likely groups from rows with host, username, value and ownership. Verify: vitest for exact, likely, no-address, passkey and authenticator exclusion, and a recipient copy never listed. +- [ ] 1.2 Decrypt the username in `loadDecryptedRows()` and call the detector from the health worker. Verify: vitest that locking the vault drops the groups. + +## 2. Report and merge + +- [ ] 2.1 Add a Duplicates section to `HealthReportView.vue` listing groups with host, username, folder and a shared marker. Verify: Playwright flow on the fixture vault shows one exact and one likely group. +- [ ] 2.2 Add `src/modals/DuplicateMergeModal.vue`: pick the item to keep, preview the folded additional fields, confirm with the count of people who lose access, then update the kept item and delete the others. Verify: vitest on the field folding with a clash, a Playwright flow merge the exact group, and the hydra modal-isolation gate. + +## 3. Docs + +- [ ] 3.1 Document the Duplicates section and the merge rules in `docs/password-health.md`. Verify: docs build. + +## Acceptance criteria + +- The health report lists items that hold the same credential more than once, split into exact and likely duplicates. +- A merge keeps one item with the others' additional fields folded in and removes the rest. +- Nothing about duplicates is sent to the server. diff --git a/openspec/changes/vault-favourites-tags-and-last-used/design.md b/openspec/changes/vault-favourites-tags-and-last-used/design.md new file mode 100644 index 000000000..83e81e16f --- /dev/null +++ b/openspec/changes/vault-favourites-tags-and-last-used/design.md @@ -0,0 +1,50 @@ +# Design: favourites, tags and a last-used sort in the vault list + +## Context + +At development `4c214a9d`: + +- `lib/Db/SecretMapper.php:48-53` `SORTABLE_COLUMNS` is `name`, `url`, `created_at`, `updated_at`; `findByOwner()` (`:115`) and `countByOwner()` (`:261`) filter on owner, folder and type. +- `lib/Service/SecretService.php:1000` `list()` passes those filters from `lib/Controller/SecretController.php` `index()` (`:102-115`). +- `lib/Service/SecretService.php:781` `get()` is the single encrypted-blob fetch and already emits `secret.read` (`:790-799`); list and search never call it. +- The browser extension fills from blob rows cached at match time (`browser-extension/src/background/service-worker.js:105-125` `doFill`), so a fill does not call `get()`. +- `src/views/SecretList.vue:190-233` is the filter menu (type filter and `sortOptions` at `:787-792`); `:294-333` is the bulk selection strip; rows render through `src/components/SecretListItem.vue`. +- Share copies are full `Secret` rows per recipient. `lib/Service/ShareSyncService.php:317-345` `applyRecipientBlob()` copies only `key`, `login` and `additionalFields` onto a copy, so per-row fields added here are never overwritten by an owner's edit. +- Folder names are stored unencrypted as organisational metadata (`openspec/specs/secrets/spec.md:44`). + +## Goals / Non-Goals + +**Goals** +- A person finds the items they use most in one click, labels items across folders, and sorts by what they used last. + +**Non-Goals** +- Shared tags that the owner sets for all recipients. Each holder tags their own row. +- Encrypted tags. See D2. +- A recently-used widget on the dashboard; `vault-21` is building that separately. + +## Decisions + +**D1. Favourite and last-used are columns on the holder's row.** `is_favourite` (boolean, default false) and `last_used_at` (datetime, nullable) on `keepiq_secrets`. Because every recipient has their own row, both are per holder with no extra table. + +**D2. Tags are plain text, like folder names.** A tag is organisation, not a secret, and filtering by tag must run in the list query. Encrypting tags would force the whole vault to be decrypted before any filter. Stored in `keepiq_secret_tags` (`secret_id`, `owner_id`, `tag`, unique on `secret_id` + `tag`), normalised to trimmed lowercase, at most 32 characters, at most 20 tags per item. The tags field says in its help text that tags are not encrypted. Alternative: encrypted tags inside `additionalFields`. Rejected for the filter reason above. + +**D3. Last used means a value was opened or filled.** `SecretService::get()` sets `last_used_at` next to its existing `secret.read` event. A new route `POST /api/v1/extension/used/{id}` (session or paired app password, owner-scoped) lets the extension report a fill after `doFill()` succeeds. Opening the list does not count. Alternative: derive last used from the audit log (`AuditService::recentlyAccessed()`). Rejected: the audit log is pruned by retention and is not indexed for a sort. + +**D4. Filters join the existing query.** `findByOwner()` and `countByOwner()` take `?bool $favourite` and `?string $tag`; the tag filter is an `EXISTS` subquery on `keepiq_secret_tags`. `last_used_at` sorts with nulls last. + +## Security and zero-knowledge + +No secret value is read or stored. The star and the last-used time are metadata about the holder's own row. Tags are plain text by decision D2 and the UI says so. The used-route is owner-scoped: it accepts only ids of rows the caller holds, and returns 404 otherwise, so it cannot probe other users' secrets. + +## Risks / Trade-offs + +- A tag can leak meaning ("board-salaries") to a database reader, the same way a folder name or item name can today. The help text is the mitigation. +- Stamping `last_used_at` on every `get()` adds one write per reveal; it is a single-row update on an indexed key. + +## Seed data + +Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register. The dev fixture owner `admin` gets two starred secrets, tags `finance` and `on call` on three secrets, and `last_used_at` on four, so the filter and sort have visible results. + +## Migration + +One migration after `Version001000Date20260908000000`: `is_favourite` (boolean, default false) and `last_used_at` (datetime, nullable) on `keepiq_secrets`; table `keepiq_secret_tags` with an index on (`owner_id`, `tag`). `` bumps. The GDPR export (`docs/gdpr.md`) adds tags and favourites to the metadata package; the account deletion cascade deletes the holder's tag rows. diff --git a/openspec/changes/vault-favourites-tags-and-last-used/proposal.md b/openspec/changes/vault-favourites-tags-and-last-used/proposal.md new file mode 100644 index 000000000..54b76f9e4 --- /dev/null +++ b/openspec/changes/vault-favourites-tags-and-last-used/proposal.md @@ -0,0 +1,64 @@ +--- +kind: code +--- + +# Favourites, tags and a last-used sort in the vault list + +## Why + +A vault list in Keepiq can be narrowed by folder and by secret type, and sorted by name, address, date created and date changed (`src/views/SecretList.vue:787-792`, `lib/Db/SecretMapper.php:48-53`). A person with two hundred logins has no way to keep the ten they use daily at hand, no way to label items across folders ("finance", "on call"), and no way to see what they actually used last. `docs/FEATURES.md:74` lists favourite secrets as a V1 feature and `:80` lists tags as an Enterprise feature; neither is built. + +The three rows share one screen, the secret list and its filter menu, and one service, the paged list query, so they are one change. + +### Matrix rows (keepiq `openspec/parity/capabilities.json`) + +| row | capability | Keepiq today | +|---|---|---| +| `vault-09` | Mark items as favourites and filter on them. | `no`: no favourites concept on the entity, store or UI | +| `vault-10` | Label items with tags and filter by tag. | `no`: no tag storage, chip UI or filter | +| `vault-24` | Sort items by date added, date changed or date last used. | `partial`: name, date created and date updated sort; there is no last-used timestamp | + +For `vault-24` the missing half is sorting by date last used. Sorting by date added and date changed is built. + +### Demand + +- `vault-24`: feature request, https://community.bitwarden.com/t/sorting-options-by-date-of-modification-addition-last-use-etc/2484 +- `vault-09`, `vault-10`: no demand row. Both are in the core area (vault) with five and three competitors rating yes. + +### Competitors rated yes + +- `vault-09`, Bitwarden: "libs/common/src/vault/models/view/cipher.view.ts:46 favorite flag; libs/vault/src/services/vault-filter.service.ts:130 'favorites' filter ... Favourite flag per item and a favourites filter in every client." +- `vault-09`, 1Password: "select Add to Favorites... select Favorites in the sidebar" (https://support.1password.com/favorites-tags/). +- `vault-09`, Passbolt: "config/routes.php:81 POST /favorites/resource/{foreignId} ... DisplayResourcesList.js:156 CellFavorite star, ... ResourceWorkspaceContext.js:929 FAVORITE filter." +- `vault-09`, Keeper: "Record Favorites are used to easily identify your most frequently used records. Right-click on a record and select Add to Favorites" (https://docs.keeper.io/user-guides/web-vault#favorites). +- `vault-09`, Nextcloud Passwords: "src/vue/Section/Favorites.vue:32 API.findPasswords({favorite: true}) ... Favourite flag on passwords and folders, with a Favorites section that filters on it." +- `vault-10`, 1Password: "no limit the number of tags", "choose a tag in the sidebar" to filter (https://support.1password.com/favorites-tags/). +- `vault-10`, Passbolt: "plugins/PassboltEe/Tags/config/routes.php:28 POST /tags/{id} ... ResourceWorkspaceContext.js:924 TAG filter, :977 searchByTag ... Tags are a Pro plugin." +- `vault-10`, Nextcloud Passwords: "src/vue/Section/Tags.vue:51 find passwords by tag; src/vue/Dialog/CreatePassword/TagsField.vue ... Tags are first class objects, set in the password dialog or by batch, and the Tags section lists passwords per tag." +- `vault-24`: no competitor rated yes. + +## What Changes + +- **Favourites.** A star on each list row and in the secret detail sidebar marks the item as a favourite for the person who holds it. A Favourites filter in the list's filter menu shows only starred items. +- **Tags.** The create and edit dialogs get a tags field. Tags show as chips on list rows. The filter menu lists the holder's tags; picking one narrows the list. The bulk strip gets Add tag and Remove tag. +- **Last used.** Keepiq records when the holder last opened a secret's value in the web app or filled it from the browser extension, and the sort menu gets Last used. +- All three are per holder: a recipient's copy of a shared secret carries its own star, tags and last-used time, and the owner's changes never overwrite them. + +## Capabilities + +### New Capabilities + +- `vault-list-organisation`: favourites, tags and a last-used sort on the vault list, per holder. + +### Modified Capabilities + +- None in delta form. The `secrets` list requirement (`openspec/specs/secrets/spec.md`, list and pagination) keeps its sort columns and gains one through this change's own requirement. + +## Impact + +- **Backend**: columns `is_favourite` and `last_used_at` on `keepiq_secrets`; a new table `keepiq_secret_tags`; `SecretMapper::findByOwner()` and `countByOwner()` learn a favourite and a tag filter; `SORTABLE_COLUMNS` gains `last_used_at`; `SecretService::get()` stamps `last_used_at`; a new extension route records a fill. +- **Frontend**: star toggle on `SecretListItem.vue` and the detail sidebar, tags field in `SecretCreateDialog.vue` and `SecretEditDialog.vue`, filter and sort options in `SecretList.vue`, bulk tag actions. +- **Browser extension**: after a fill, `service-worker.js` reports the used secret id. +- **Database**: one migration, `` bump. +- **Security**: tags are stored in plain text, like folder names, and the proposal says so to the user in the tags field help text. No secret value is involved. +- **Cross-app**: none. diff --git a/openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md b/openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md new file mode 100644 index 000000000..0683ac639 --- /dev/null +++ b/openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Favourite items per holder + +The system MUST let the holder of a secret, owner or recipient, mark it as a favourite through `PUT /api/v1/secrets/{id}/favourite` and from a star on the secret list row and in the secret detail sidebar. The flag MUST belong to the holder's own row: marking a shared copy MUST NOT change the owner's row or any other recipient's row, and an owner's edit of the secret MUST NOT clear a recipient's flag. The secret list MUST offer a Favourites filter that shows only the holder's favourites. + +#### Scenario: A vault user stars a login and filters on favourites + +- **GIVEN** a vault user with 120 secrets on the secret list at /secrets +- **WHEN** the user clicks the star on two secrets and picks Favourites in the filter menu +- **THEN** the list shows exactly those two secrets +- **AND** the filter button shows that a filter is active + +#### Scenario: A recipient's star survives the owner's edit + +- **GIVEN** a colleague who starred their copy of a shared secret +- **WHEN** the owner changes the secret's password +- **THEN** the colleague's copy is still starred + +### Requirement: Tags per holder + +The system MUST let the holder of a secret set tags on it in the create and edit dialogs, through `PUT /api/v1/secrets/{id}/tags`, and in bulk from the selection strip. Tags MUST be trimmed, lowercased, at most 32 characters each and at most 20 per secret. Tags MUST be stored in plain text and the tags field MUST say so. The secret list MUST show tags as chips on each row and MUST offer the holder's tags in the filter menu; picking a tag MUST narrow the list to secrets with that tag. + +#### Scenario: A vault user labels items across folders and filters by tag + +- **GIVEN** a vault user with secrets in three folders +- **WHEN** the user tags one secret in each folder with `on call` and picks `on call` in the filter menu +- **THEN** the list shows those three secrets and no others + +#### Scenario: A vault user removes a tag in bulk + +- **GIVEN** three secrets tagged `finance` +- **WHEN** the user selects them and chooses Remove tag `finance` in the selection strip +- **THEN** none of the three shows the `finance` chip and the tag no longer appears in the filter menu + +### Requirement: Sort by date last used + +The system MUST record `last_used_at` on the holder's row when the holder opens the secret's value through `GET /api/v1/secrets/{id}` or fills it from the browser extension, which reports the fill through `POST /api/v1/extension/used/{id}`. The used-route MUST return 404 for a secret the caller does not hold. Loading the list or searching MUST NOT change `last_used_at`. The secret list MUST offer Last used in its sort options, with never-used secrets last. + +#### Scenario: A vault user sorts by last used + +- **GIVEN** a vault user who opened secret A yesterday and filled secret B from the extension an hour ago +- **WHEN** the user picks Last used in the sort menu of the secret list +- **THEN** B is first and A is second +- **AND** secrets never opened or filled come after all used ones + +#### Scenario: The used-route cannot probe another user's secret + +- **GIVEN** a paired browser extension of one user +- **WHEN** it calls `POST /api/v1/extension/used/{id}` with the id of another user's secret +- **THEN** the response is 404 and nothing is recorded diff --git a/openspec/changes/vault-favourites-tags-and-last-used/tasks.md b/openspec/changes/vault-favourites-tags-and-last-used/tasks.md new file mode 100644 index 000000000..eba825a56 --- /dev/null +++ b/openspec/changes/vault-favourites-tags-and-last-used/tasks.md @@ -0,0 +1,29 @@ +# Tasks: favourites, tags and a last-used sort in the vault list + +## 1. Data + +- [ ] 1.1 Add the migration (`is_favourite`, `last_used_at`, table `keepiq_secret_tags`), the entity fields, a `SecretTag` entity and mapper; bump ``. Verify: PHPUnit on the mapper and `occ migrations:status keepiq`. +- [ ] 1.2 Extend `SecretMapper::findByOwner()` and `countByOwner()` with the favourite and tag filters and add `last_used_at` (nulls last) to `SORTABLE_COLUMNS`. Verify: PHPUnit for each filter and the sort order. + +## 2. API + +- [ ] 2.1 Add `PUT /api/v1/secrets/{id}/favourite` (body `{favourite: bool}`), `PUT /api/v1/secrets/{id}/tags` (body `{tags: string[]}`), `GET /api/v1/tags` (the holder's distinct tags with counts) and the `favourite` and `tag` query parameters on `GET /api/v1/secrets`. Verify: hydra route-auth and no-admin-idor gates, PHPUnit for tag normalisation and the 20-tag cap. +- [ ] 2.2 Stamp `last_used_at` in `SecretService::get()` and add `POST /api/v1/extension/used/{id}`. Verify: PHPUnit that `get()` stamps the time and that the used-route returns 404 for a secret the caller does not hold. +- [ ] 2.3 Add tags and favourites to the GDPR metadata export and the account deletion cascade. Verify: PHPUnit on `AccountDeletionService` and the GDPR package. + +## 3. Frontend + +- [ ] 3.1 Add the star toggle to `SecretListItem.vue` and the detail sidebar, and a Favourites option to the filter menu in `SecretList.vue`. Verify: vitest on the secret store action and a Playwright flow star, filter, unstar. +- [ ] 3.2 Add a tags field (with the not-encrypted help text) to the create and edit dialogs, tag chips on list rows, the tag list in the filter menu, and Add tag and Remove tag in the bulk strip. Verify: Playwright flow tag two items, filter on the tag, remove it in bulk. +- [ ] 3.3 Add Last used to `sortOptions`. Verify: Playwright flow open a secret, sort by last used, it is first. + +## 4. Browser extension + +- [ ] 4.1 After a successful `doFill()`, call the used-route. Verify: extension unit test that a fill posts the id once and a failed fill posts nothing. + +## Acceptance criteria + +- A person stars an item and the Favourites filter shows only starred items. +- A person tags items, sees the tags as chips, filters on a tag and removes a tag in bulk. +- Sorting by Last used puts the item most recently opened or filled first. +- A recipient's star, tags and last-used time are theirs alone and survive the owner's edits. diff --git a/openspec/changes/vault-item-clone-preview-and-print/design.md b/openspec/changes/vault-item-clone-preview-and-print/design.md new file mode 100644 index 000000000..1aee1aa87 --- /dev/null +++ b/openspec/changes/vault-item-clone-preview-and-print/design.md @@ -0,0 +1,51 @@ +# Design: clone an item, preview an attachment, print a login or show it as a QR code + +## Context + +At development `4c214a9d`: + +- `src/components/SecretDetailSidebar.vue:56-113` has the action row (Edit `:62`, Share `:74`, a More menu with Move `:87` and Delete `:96`, Close `:107`). Dialogs open through `cnOpenModal()` (`:1499`, `:1516`, `:1534`, `:1550`) with keys from `src/registry.js` (`'secret-create'` at `:76`). +- `src/dialogs/SecretCreateDialog.vue:212-224` props are `folderId` and `onSaved`; `data()` (`:226-240`) starts every field empty. It encrypts with the user's active suite before `POST /api/v1/secrets` (`src/store/modules/secret.js:348,385`). +- `src/components/AttachmentPanel.vue:22-60` lists attachments with Download and Delete; `src/store/modules/attachment.js:251-281` `download()` fetches `GET /api/v1/attachments/{id}/blob`, unwraps the file key, decrypts with AES and triggers a download from an object URL. The attachment's decrypted `contentType` and `filename` are known. +- `src/crypto/reauth.js:117` `verifyMasterPassword()` is the client-side proof of knowledge used before a plaintext export (`src/dialogs/ExportDialog.vue:130`). +- The only print today is `src/dialogs/ComplianceSnapshotDialog.vue:201`. +- The admin limit `attachment_max_bytes` (`lib/Service/AdminSettingsService.php:166`) caps attachment size. + +## Goals / Non-Goals + +**Goals** +- Clone in two clicks with nothing sensitive leaking to the new item that the user did not see. +- Look at an attachment without writing it to disk. +- Hand a password to a device without Keepiq, deliberately. + +**Non-Goals** +- Copying attachments or passkeys into a clone. A passkey credential is bound to one item and must not be duplicated; attachments would need a re-encryption of every file. +- Previews of office documents or archives. +- A server-side PDF. + +## Decisions + +**D1. Clone is a prefilled create, not a server copy.** `SecretCreateDialog.vue` gets a `prefill` prop; the sidebar passes the decrypted fields it already holds. Saving is an ordinary create, so the new item gets a fresh id, its own ciphertext, no shares and no history. Alternative: a server-side `POST /secrets/{id}/clone`. Rejected: the server cannot re-encrypt, and copying ciphertext would tie two items to one blob. + +**D2. Preview only safe types, only from memory.** Images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`), `application/pdf` and `text/plain`. The decrypted bytes become a `Blob` and an object URL, shown in an ``, a sandboxed `