From c7a505608718278b85df8b93e63137cf016d6a99 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 21:14:04 +0200
Subject: [PATCH 001/245] docs(openspec): decide keepiq's owned parity rows,
nine new changes and 29 decisions (#837)
---
.../admin-secret-type-editor/design.md | 31 +++
.../admin-secret-type-editor/proposal.md | 48 ++++
.../specs/admin-secret-types/spec.md | 33 +++
.../changes/admin-secret-type-editor/tasks.md | 20 ++
.../design.md | 28 ++
.../proposal.md | 50 ++++
.../specs/clients-browser-builds/spec.md | 23 ++
.../tasks.md | 15 ++
.../design.md | 34 +++
.../proposal.md | 59 ++++
.../specs/clients-passkey-origin/spec.md | 17 ++
.../specs/clients-save-prompt/spec.md | 23 ++
.../tasks.md | 16 ++
.../design.md | 35 +++
.../proposal.md | 57 ++++
.../specs/vault-session-lock/spec.md | 39 +++
.../tasks.md | 15 ++
.../design.md | 29 ++
.../proposal.md | 49 ++++
.../specs/portability-cxp/spec.md | 33 +++
.../tasks.md | 16 ++
.../design.md | 29 ++
.../proposal.md | 48 ++++
.../specs/portability-import-mapping/spec.md | 27 ++
.../portability-import-field-mapping/tasks.md | 13 +
.../sharing-group-share-entry-point/design.md | 30 +++
.../proposal.md | 52 ++++
.../specs/sharing-group/spec.md | 23 ++
.../sharing-group-share-entry-point/tasks.md | 16 ++
.../design.md | 36 +++
.../proposal.md | 60 +++++
.../specs/vault-custom-fields/spec.md | 27 ++
.../specs/vault-ssh-key-item/spec.md | 23 ++
.../tasks.md | 19 ++
.../design.md | 31 +++
.../proposal.md | 52 ++++
.../specs/vault-defaults/spec.md | 23 ++
.../specs/vault-recently-used/spec.md | 23 ++
.../tasks.md | 17 ++
openspec/parity/capabilities.json | 254 +++++++++++-------
openspec/parity/gap-decisions.json | 232 ++++++++++++++++
41 files changed, 1609 insertions(+), 96 deletions(-)
create mode 100644 openspec/changes/admin-secret-type-editor/design.md
create mode 100644 openspec/changes/admin-secret-type-editor/proposal.md
create mode 100644 openspec/changes/admin-secret-type-editor/specs/admin-secret-types/spec.md
create mode 100644 openspec/changes/admin-secret-type-editor/tasks.md
create mode 100644 openspec/changes/clients-extension-firefox-and-safari-builds/design.md
create mode 100644 openspec/changes/clients-extension-firefox-and-safari-builds/proposal.md
create mode 100644 openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md
create mode 100644 openspec/changes/clients-extension-firefox-and-safari-builds/tasks.md
create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/design.md
create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/proposal.md
create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md
create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md
create mode 100644 openspec/changes/clients-extension-save-prompt-and-passkey-origin/tasks.md
create mode 100644 openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md
create mode 100644 openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md
create mode 100644 openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md
create mode 100644 openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md
create mode 100644 openspec/changes/portability-cxp-cross-provider-transfer/design.md
create mode 100644 openspec/changes/portability-cxp-cross-provider-transfer/proposal.md
create mode 100644 openspec/changes/portability-cxp-cross-provider-transfer/specs/portability-cxp/spec.md
create mode 100644 openspec/changes/portability-cxp-cross-provider-transfer/tasks.md
create mode 100644 openspec/changes/portability-import-field-mapping/design.md
create mode 100644 openspec/changes/portability-import-field-mapping/proposal.md
create mode 100644 openspec/changes/portability-import-field-mapping/specs/portability-import-mapping/spec.md
create mode 100644 openspec/changes/portability-import-field-mapping/tasks.md
create mode 100644 openspec/changes/sharing-group-share-entry-point/design.md
create mode 100644 openspec/changes/sharing-group-share-entry-point/proposal.md
create mode 100644 openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md
create mode 100644 openspec/changes/sharing-group-share-entry-point/tasks.md
create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/design.md
create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/proposal.md
create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-custom-fields/spec.md
create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-ssh-key-item/spec.md
create mode 100644 openspec/changes/vault-custom-field-kinds-and-ssh-key/tasks.md
create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/design.md
create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/proposal.md
create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md
create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md
create mode 100644 openspec/changes/vault-defaults-and-recently-used-widget/tasks.md
diff --git a/openspec/changes/admin-secret-type-editor/design.md b/openspec/changes/admin-secret-type-editor/design.md
new file mode 100644
index 000000000..627a434cf
--- /dev/null
+++ b/openspec/changes/admin-secret-type-editor/design.md
@@ -0,0 +1,31 @@
+# Design: an administrator defines item types and the fields they carry
+
+## Context
+
+At development `156cd800`:
+
+- `lib/Db/SecretType.php` has `name`, `label`, `scope`, `ownerId`, `createdAt` and no fields.
+- `lib/Controller/SecretTypeController.php:99` `create` passes `$isAdmin` to `typeService->createType`; global scope needs the admin role.
+- `src/store/modules/secretType.js:73-110` has `createType`, `updateType`, `deleteType` and no caller for the first.
+- `lib/Repair/SeedSecretTypes.php` seeds the built-in types; built-ins have no field list and keep their current forms.
+- `src/dialogs/SecretCreateDialog.vue` picks the form from the type name.
+
+## Goals / Non-Goals
+
+**Goals**
+- An administrator can define a type and its fields without code.
+
+**Non-Goals**
+- Per-role publishing of a type.
+- Field validation patterns.
+- User-scope custom types beyond what the API already allows.
+
+## Decisions
+
+### D1: Fields are metadata, values are ciphertext
+
+The definition (labels and kinds) is not secret and is stored in plain text on the type, like folder names. Values go into the existing encrypted blob, so the server never sees them.
+
+### D2: Built-in types keep their forms
+
+Only types with a non-empty `fields` list render the generic typed form, so nothing changes for login, note or SSH key.
diff --git a/openspec/changes/admin-secret-type-editor/proposal.md b/openspec/changes/admin-secret-type-editor/proposal.md
new file mode 100644
index 000000000..6cde7ca68
--- /dev/null
+++ b/openspec/changes/admin-secret-type-editor/proposal.md
@@ -0,0 +1,48 @@
+---
+kind: code
+---
+
+# An administrator defines item types and the fields they carry
+
+## Why
+
+Custom secret types exist in the API (`appinfo/routes.php:74-77`) and in the store (`src/store/modules/secretType.js:73` `createType`), but no page lets anyone create one, and a type is only `name`, `label`, `scope` and `ownerId` (`lib/Db/SecretType.php`): it cannot say which fields an item of that type carries. The row has a feature request from the Passbolt community and Keeper rates yes (a record template with labelled and required fields, published to roles). A request plus one competitor yes is a build under the decision rule.
+
+One row, one change.
+
+### Matrix rows (`keepiq` `openspec/parity/capabilities.json`)
+
+| row | capability | Today |
+|---|---|---|
+| `admin-18` | An administrator defines new item types and the fields they carry. | `no`: `no`: the secret-type routes and store exist, no page creates a type, and a type has no field definition at all |
+
+### Demand
+
+- `admin-18`: featureRequest, https://community.passbolt.com/t/as-an-administrator-i-can-create-new-secret-types-and-define-their-associated-input-fields/19
+
+### Competitors rated yes
+
+- `admin-18`, keeper: "https://docs.keeper.io/enterprise-guide/creating-new-record-types : an admin with 'Manage Record Types in Vault' creates a record template with labelled and required fields and publishes it to roles."
+
+## What Changes
+
+- Add a `fields` definition to a secret type: an ordered list of `{key, label, kind, required}` with kinds `text`, `hidden`, `url`, `email`.
+- Add an admin page, Item types, to create, relabel, edit fields and delete global types.
+- Make the create and edit dialogs render the fields of the chosen type, storing values in the encrypted additional fields blob under the field label.
+
+## Capabilities
+
+### New Capabilities
+
+- `admin-secret-types`
+
+### Modified Capabilities
+
+- None in delta form.
+
+## Impact
+
+- **Database**: one migration adds a `fields` JSON text column to the secret types table; `` bump.
+- **Backend**: `SecretType` entity, `SecretTypeService::createType` and `updateType` validate the field list.
+- **Frontend**: new admin view and route, changes in `SecretCreateDialog.vue` and `SecretEditDialog.vue`.
+- **Cross-row**: reuses the hidden field kind from `vault-custom-field-kinds-and-ssh-key`; the two land in either order because a `hidden` kind falls back to text until that change lands.
diff --git a/openspec/changes/admin-secret-type-editor/specs/admin-secret-types/spec.md b/openspec/changes/admin-secret-type-editor/specs/admin-secret-types/spec.md
new file mode 100644
index 000000000..93ee59974
--- /dev/null
+++ b/openspec/changes/admin-secret-type-editor/specs/admin-secret-types/spec.md
@@ -0,0 +1,33 @@
+## ADDED Requirements
+
+### Requirement: Item type definitions
+
+The system MUST let an administrator create a global item type with a name, a label and an ordered list of fields, each with a label, a kind and a required flag, and MUST let the administrator edit and delete it. A type definition MUST be visible to every user as a choice in the create dialog. Deleting a type MUST leave its secrets readable and move them to the login type as the type service already does.
+
+#### Scenario: An administrator creates a type
+
+- **GIVEN** an administrator on the Item types admin page
+- **WHEN** the administrator creates Server access with fields Host (url, required), Port (text) and Root password (hidden) and saves
+- **THEN** the type appears in every user's create dialog
+
+#### Scenario: A user fills a typed item
+
+- **GIVEN** a user choosing Server access in the create dialog
+- **WHEN** the user leaves Host empty and saves
+- **THEN** the dialog blocks the save and marks Host as required
+
+#### Scenario: A regular user cannot define a global type
+
+- **GIVEN** a user without the admin role
+- **WHEN** the user posts a global type to the secret types route
+- **THEN** the server answers 403
+
+### Requirement: Typed fields storage
+
+Values entered for the fields of a type MUST be stored inside the encrypted additional fields blob, and the server MUST NOT receive them in plain text.
+
+#### Scenario: Values stay encrypted
+
+- **GIVEN** a user saving a Server access item
+- **WHEN** the request is sent to the server
+- **THEN** the request body holds only ciphertext for the field values
diff --git a/openspec/changes/admin-secret-type-editor/tasks.md b/openspec/changes/admin-secret-type-editor/tasks.md
new file mode 100644
index 000000000..3812f371c
--- /dev/null
+++ b/openspec/changes/admin-secret-type-editor/tasks.md
@@ -0,0 +1,20 @@
+# Tasks: an administrator defines item types and the fields they carry
+
+## 1. Data and API
+
+- [ ] 1.1 Add the migration for `fields` and extend the entity and `SecretTypeService` validation (unique keys, at most 30 fields, known kinds). Verify: PHPUnit for valid, duplicate key and unknown kind.
+- [ ] 1.2 Accept and return `fields` on the create and update routes. Verify: PHPUnit on the controller; hydra route-auth and semantic-auth gates.
+
+## 2. Admin page
+
+- [ ] 2.1 Build the Item types admin view and route with a field list editor, in its own dialog files. Verify: vitest on the editor; Playwright flow create a type as admin.
+
+## 3. Typed form
+
+- [ ] 3.1 Render a generic typed form in the create and edit dialogs for types with fields and store values in the encrypted blob. Verify: vitest for required and hidden; Playwright flow create and open a typed item.
+
+## 4. Close out
+
+- [ ] 4.1 Add strings to every shipped locale. Verify: `npm run test:l10n`.
+- [ ] 4.2 Set row `admin-18` to built and archive the change. Verify: parity_verify --strict.
+
diff --git a/openspec/changes/clients-extension-firefox-and-safari-builds/design.md b/openspec/changes/clients-extension-firefox-and-safari-builds/design.md
new file mode 100644
index 000000000..85773d376
--- /dev/null
+++ b/openspec/changes/clients-extension-firefox-and-safari-builds/design.md
@@ -0,0 +1,28 @@
+# Design: a Firefox build that loads, and a Safari build
+
+## Context
+
+At development `156cd800`:
+
+- `browser-extension/manifest.json:3` `manifest_version` 3; `:22` declares only `background.service_worker`.
+- `browser-extension/build.mjs:24` builds one bundle for `chrome110` and `firefox110` targets.
+- `.github/workflows` holds `cli-release.yml` only; the extension has no CI build.
+
+## Goals / Non-Goals
+
+**Goals**
+- Each browser gets a package it can load.
+
+**Non-Goals**
+- Store listings and signing (`clients-extension-store-release`).
+- Opera and other Chromium forks beyond what the Chromium package covers.
+
+## Decisions
+
+### D1: Templates over one manifest
+
+A base manifest plus a small per-browser overlay keeps one source of truth and makes the differences reviewable.
+
+### D2: Safari is a separate task that may block
+
+It needs macOS. It is last, so a missing runner cannot hold up the Firefox fix.
diff --git a/openspec/changes/clients-extension-firefox-and-safari-builds/proposal.md b/openspec/changes/clients-extension-firefox-and-safari-builds/proposal.md
new file mode 100644
index 000000000..1c589c39a
--- /dev/null
+++ b/openspec/changes/clients-extension-firefox-and-safari-builds/proposal.md
@@ -0,0 +1,50 @@
+---
+kind: code
+---
+
+# A Firefox build that loads, and a Safari build
+
+## Why
+
+The extension has one `browser-extension/manifest.json` with `background.service_worker` (`:22`) and `build.mjs:24` targets `chrome110` and `firefox110`. Firefox MV3 needs `background.scripts`, so the Firefox build probably fails to start; there is no Safari project. Four competitors rate yes. Store publication is covered by `clients-extension-store-release`; this change makes the builds themselves right.
+
+One row, one change.
+
+### Matrix rows (`keepiq` `openspec/parity/capabilities.json`)
+
+| row | capability | Today |
+|---|---|---|
+| `clients-12` | Use browser extensions for Chrome, Firefox, Edge and Safari. | `partial`: `partial`: one MV3 manifest declares only a service worker, so Firefox probably does not start it; there is no Safari build; only source installs exist |
+
+### Demand
+
+- `clients-12`: no demand row.
+
+### Competitors rated yes
+
+- `clients-12`, bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/package.json:7 build:chrome, plus build:firefox, build:edge, build:opera, build:safari; apps/browser/src/manifest.json, manifest.v3.json Note: One extension codebase built for Chrome, F"
+- `clients-12`, onepassword: "https://releases.1password.com/b5x/stable/ : Chrome, Edge, Brave, Firefox and Safari extensions"
+- `clients-12`, passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/chrome, src/chrome-mv3, src/firefox, src/safari build targets (Edge uses the Chromium build) Note: The extension is built for Chrome and other Chromium browsers including Edge, Firef"
+- `clients-12`, keeper: "https://docs.keeper.io/user-guides/browser-extensions : KeeperFill for Chrome, Firefox, Safari, Microsoft Edge, Opera and other Chromium browsers"
+
+## What Changes
+
+- Generate a per-browser manifest in `build.mjs`: `service_worker` for Chromium, `scripts` plus `browser_specific_settings.gecko` for Firefox.
+- Add a Safari web extension conversion step and document it, run on a macOS runner.
+- Add a CI job that builds all three and loads Chromium and Firefox headless to check the background starts.
+
+## Capabilities
+
+### New Capabilities
+
+- `clients-browser-builds`
+
+### Modified Capabilities
+
+- None in delta form.
+
+## Impact
+
+- **Extension**: `browser-extension/build.mjs`, manifest templates, CI workflow.
+- **Backend**: none.
+- **Risk**: the Safari step needs macOS and an Apple developer identity to sign; producing the unsigned project is in scope, signing and store release are `clients-extension-store-release`. If no macOS runner is available the Safari task stays open and is reported, and the other tasks still ship.
diff --git a/openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md b/openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md
new file mode 100644
index 000000000..830d2c34e
--- /dev/null
+++ b/openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: One build per browser
+
+The build MUST produce a loadable extension package for Chromium browsers (Chrome, Edge), for Firefox and for Safari, each with the manifest keys that browser requires. The Firefox package MUST start its background script, and the Chromium package MUST keep its service worker.
+
+#### Scenario: Firefox starts the background
+
+- **GIVEN** the Firefox package installed as a temporary add-on
+- **WHEN** the browser loads it
+- **THEN** the background script runs and the popup can reach it
+
+#### Scenario: Chromium is unchanged
+
+- **GIVEN** the Chromium package loaded unpacked
+- **WHEN** the browser loads it
+- **THEN** the service worker registers as before
+
+#### Scenario: Safari package is produced
+
+- **GIVEN** a macOS build runner
+- **WHEN** the Safari conversion step runs
+- **THEN** an Xcode project or app extension bundle is produced and its build log is kept
diff --git a/openspec/changes/clients-extension-firefox-and-safari-builds/tasks.md b/openspec/changes/clients-extension-firefox-and-safari-builds/tasks.md
new file mode 100644
index 000000000..b0961b6aa
--- /dev/null
+++ b/openspec/changes/clients-extension-firefox-and-safari-builds/tasks.md
@@ -0,0 +1,15 @@
+# Tasks: a Firefox build that loads, and a Safari build
+
+## 1. Manifests and builds
+
+- [ ] 1.1 Split the manifest into a base and Chromium and Firefox overlays in `build.mjs` and emit `dist/chromium` and `dist/firefox`. Verify: node test that each manifest has the keys its browser requires.
+- [ ] 1.2 Add a Safari conversion step and notes for a macOS runner. Verify: build log from a macOS runner, or report it blocked.
+
+## 2. CI
+
+- [ ] 2.1 Add a workflow job that builds all packages and loads Chromium and Firefox headless to check the background starts. Verify: the workflow run.
+
+## 3. Close out
+
+- [ ] 3.1 Set row `clients-12` to built (or `building` with the Safari task named) and archive when all tasks are done. Verify: parity_verify --strict.
+
diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/design.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/design.md
new file mode 100644
index 000000000..bcf0207ae
--- /dev/null
+++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/design.md
@@ -0,0 +1,34 @@
+# Design: offer to save or update a login at once, and pin passkey requests to the page origin
+
+## Context
+
+At development `156cd800`:
+
+- `browser-extension/src/content/content-script.js:134` `attachSubmitCapture`, `:150` `captureCurrent` (no id), `:192` `injectShim`, `:204-212` relay forwards `data.origin`.
+- `browser-extension/src/background/service-worker.js:186` `doSaveCapture`, `:198` updates only with `payload.id`, `:230` passkey routes, `:240` `pendingCapture` in memory.
+- `browser-extension/src/popup/popup.js:79` shows the `pending-capture` prompt only in the popup.
+- `browser-extension/src/passkey/orchestrator.js:74` `handleCreate` and `handleGet`; `src/passkey/registration.js:23` native proxy registration behind an optional permission that is never requested.
+
+## Goals / Non-Goals
+
+**Goals**
+- A submit leads to a visible offer at once, and an update never duplicates.
+- A passkey request cannot claim an origin it does not have.
+
+**Non-Goals**
+- Store release and Safari (see the two sibling changes).
+- Changing passkey storage.
+
+## Decisions
+
+### D1: A shadow root bar, not a popup
+
+The prompt is injected in a closed shadow root and takes no input from the page, so page script cannot read or click it. The popup prompt stays as a fallback.
+
+### D2: Match in the service worker
+
+The service worker already holds the vault cache; it matches by origin and username and returns the id with the capture, so the content script never sees saved passwords.
+
+### D3: Registrable suffix rule
+
+The rpId check follows the WebAuthn rule: equal to the host or a parent domain that is not a public suffix.
diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/proposal.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/proposal.md
new file mode 100644
index 000000000..8cb3c7755
--- /dev/null
+++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/proposal.md
@@ -0,0 +1,59 @@
+---
+kind: code
+---
+
+# Offer to save or update a login at once, and pin passkey requests to the page origin
+
+## Why
+
+After a form submit the extension captures the login (`browser-extension/src/content/content-script.js:134-150`) but holds it in memory until the user happens to open the popup (`service-worker.js:240`), and `doSaveCapture` updates only when `payload.id` is set (`:198`) while the capture never carries an id, so a changed password creates a duplicate. For passkeys, the content script forwards `data.origin` from the page's own message instead of `location.origin` (`content-script.js:212`) and nothing checks the relying party id against the origin, so a hostile page can ask for an assertion for another site. Five and three competitors rate yes. Both rows are the same extension surface, so they are one change.
+
+The rows share one screen or service, so they are one change.
+
+### Matrix rows (`keepiq` `openspec/parity/capabilities.json`)
+
+| row | capability | Today |
+|---|---|---|
+| `clients-03` | Be offered to save or update a login after submitting a form. | `partial`: `partial`: a submitted login is captured but the offer appears only when the popup is opened, and an existing login is never updated |
+| `clients-05` | Use the extension as a passkey provider on websites. | `partial`: `partial`: passkey create and sign work, but the relay trusts an origin the page supplies and the native proxy path never activates |
+
+### Demand
+
+- `clients-03`: no demand row.
+- `clients-05`: no demand row.
+
+### Competitors rated yes
+
+- `clients-03`, bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/background/notification.background.ts:638 triggerAddLoginNotification, :663 getEnableAddedLoginPrompt, :144 bgSaveCipher Note: Add-login and change-password prompts after f"
+- `clients-03`, onepassword: "https://support.1password.com/save-fill-passwords/ : '1Password will automatically offer to save your login' and asks to update an existing item"
+- `clients-03`, passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/all/background_page/controller/webIntegration/webIntegrationController.js:34 autosave opens the save-credentials flow (feature autosave-credentials); passbolt/passbolt_styleguide@v5."
+- `clients-03`, keeper: "https://docs.keeper.io/user-guides/browser-extensions#save-prompt : 'Keeper will offer to save a password to the vault, if you login manually on a site'; 'Prompt to Change' policy for updates"
+- `clients-03`, nextcloud-passwords: "not in the cloned repos, docs rating kept: marius-wieschollek/passwords@2026.9.0 code not public for this (passwords-webextension repo not read); docs rating kept: https://git.mdns.eu/nextcloud/passwords/-/wikis/Administrators/Fea"
+- `clients-05`, bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/fido2/background/fido2.background.ts; libs/common/src/platform/services/fido2/fido2-authenticator.service.ts:188 creates passkey in a login Note: The extension intercepts W"
+- `clients-05`, onepassword: "https://support.1password.com/save-use-passkeys/ : save and sign in with passkeys in the browser extension"
+- `clients-05`, keeper: "https://docs.keeper.io/user-guides/browser-extensions#passkeys : create a passkey and log in with a passkey through KeeperFill"
+
+## What Changes
+
+- Show an in-page prompt (a closed shadow root bar) after a submit: Save, Update or Not now, with Update offered when a saved login matches the site and username.
+- Match the captured login against saved logins by origin and username and send the matched id with the capture.
+- Take the origin for passkey requests from `location.origin` in the content script and check that the relying party id is a registrable suffix of it before any assertion.
+- Request the optional native proxy permission when the user enables the passkey provider, or remove the dead path.
+
+## Capabilities
+
+### New Capabilities
+
+- `clients-save-prompt`
+- `clients-passkey-origin`
+
+### Modified Capabilities
+
+- None in delta form.
+
+## Impact
+
+- **Extension**: `content-script.js`, `service-worker.js`, `popup.js`, `src/passkey/orchestrator.js`, manifest permissions.
+- **Backend**: none.
+- **Security**: closes the forged origin defect recorded on `clients-05`.
+- **Dependency**: none; store release is `clients-extension-store-release`.
diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md
new file mode 100644
index 000000000..4e2a38541
--- /dev/null
+++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md
@@ -0,0 +1,17 @@
+## ADDED Requirements
+
+### Requirement: Passkey origin binding
+
+The extension MUST derive the origin of a passkey request from the page location in the content script and MUST refuse a request whose relying party id is not equal to, or a registrable suffix of, that origin's host. The `clientDataJSON` origin MUST be that derived origin.
+
+#### Scenario: A page asks for another site's rpId
+
+- **GIVEN** a page on evil.example calling navigator.credentials.get with rpId bank.example
+- **WHEN** the request reaches the extension
+- **THEN** the extension refuses and no assertion is created
+
+#### Scenario: A page uses its own rpId
+
+- **GIVEN** a page on login.bank.example with rpId bank.example
+- **WHEN** the user consents
+- **THEN** the assertion is created and its client data origin is https://login.bank.example
diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md
new file mode 100644
index 000000000..8efc9ede2
--- /dev/null
+++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: Save or update prompt
+
+After a login form is submitted, the extension MUST show a prompt on the page offering to save the login. When a saved login exists for the same origin and username with a different password, the prompt MUST offer Update and MUST update that secret instead of creating a new one. The prompt MUST NOT appear for a submit that matches an existing saved password, and MUST NOT be readable by page scripts.
+
+#### Scenario: A new login is offered
+
+- **GIVEN** a user logged in to the extension who submits a login form on a site with no saved login
+- **WHEN** the page reloads after the submit
+- **THEN** a prompt offers Save, and Save creates one secret
+
+#### Scenario: A changed password is offered as an update
+
+- **GIVEN** a saved login for the same site and username
+- **WHEN** the user submits a different password
+- **THEN** the prompt offers Update and choosing it changes that secret without creating a duplicate
+
+#### Scenario: An unchanged login is not offered
+
+- **GIVEN** a saved login
+- **WHEN** the user submits the same password
+- **THEN** no prompt appears
diff --git a/openspec/changes/clients-extension-save-prompt-and-passkey-origin/tasks.md b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/tasks.md
new file mode 100644
index 000000000..e44f0cb52
--- /dev/null
+++ b/openspec/changes/clients-extension-save-prompt-and-passkey-origin/tasks.md
@@ -0,0 +1,16 @@
+# Tasks: offer to save or update a login at once, and pin passkey requests to the page origin
+
+## 1. Save prompt
+
+- [ ] 1.1 Return a matched secret id from the service worker for a capture and pass it to `doSaveCapture`. Verify: node tests on the service worker for match, no match and unchanged password.
+- [ ] 1.2 Inject the closed shadow root prompt from the content script with Save, Update and Not now. Verify: extension test in the existing `tests/extension` harness; Playwright flow with the extension loaded.
+
+## 2. Passkey origin
+
+- [ ] 2.1 Use `location.origin` in the content script relay and add the rpId suffix check in the orchestrator. Verify: node tests for own rpId, parent domain, foreign rpId and a public suffix.
+- [ ] 2.2 Request the optional native proxy permission on enable, or delete the path. Verify: node test on the enable flow.
+
+## 3. Close out
+
+- [ ] 3.1 Set rows `clients-03` and `clients-05` to built and clear their defects, archive the change. Verify: parity_verify --strict.
+
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md
new file mode 100644
index 000000000..82edc7828
--- /dev/null
+++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md
@@ -0,0 +1,35 @@
+# Design: an inactivity lock that follows activity, and a timeout the user keeps
+
+## Context
+
+At development `156cd800`:
+
+- `src/store/modules/session.js:41` initialises `lastActivity`; `:121` and `:159` set it at unlock; `:194` `checkTimeout` compares against it; `:204` `updateActivity()` is never called.
+- `src/App.vue:137-143` renders an in-memory timeout select; `:873-874` `saveTimeout` maps `session`, 10 and 30 minutes and falls back with `|| 600000`; `:497` always starts at `session`; `:779` polls `checkTimeout`.
+- `src/components/settings/SessionTimeoutSection.vue:52-66` does GET and PUT of `session_timeout` and is mounted nowhere.
+- `browser-extension/src/background/service-worker.js:37-38` already has a true idle lock; the extension is not changed.
+
+## Goals / Non-Goals
+
+**Goals**
+- The lock means inactivity, everywhere in the web app.
+- A timeout choice is remembered.
+
+**Non-Goals**
+- An administrator maximum (`admin-24`, decided no).
+- Changing the extension idle lock.
+- Lock on system sleep.
+
+## Decisions
+
+### D1: Throttle activity events
+
+A single listener set on `document` calls `updateActivity()` at most once every 15 seconds, so typing does not cost a store write per key.
+
+### D2: Hold the value as milliseconds with an explicit never
+
+The store keeps `null` for Nextcloud session and a number for 10 or 30 minutes, so the falsy-zero fallback that caused the defect cannot recur.
+
+### D3: One control
+
+The select in `App.vue` is removed and the settings section is the only place to change it, so the saved and the applied value cannot diverge.
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md
new file mode 100644
index 000000000..b32a45401
--- /dev/null
+++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md
@@ -0,0 +1,57 @@
+---
+kind: code
+---
+
+# An inactivity lock that follows activity, and a timeout the user keeps
+
+## Why
+
+The web vault locks itself after a timeout, but `src/store/modules/session.js:194` compares against `lastActivity`, which is set only at unlock (`:121`, `:159`); `updateActivity()` (`:204`) has no caller. An active user is locked out after the timeout however busy they are. The user's timeout choice in `src/App.vue:873-874` lives in memory only, the component that saves it (`src/components/settings/SessionTimeoutSection.vue:52-66`) is mounted nowhere, and choosing Nextcloud session maps to 0 which `|| 600000` turns into 10 minutes. Four competitors rate yes on each row. Both rows are one service (the session store) and one control (the timeout select), so they are one change.
+
+The rows share one screen or service, so they are one change.
+
+### Matrix rows (`keepiq` `openspec/parity/capabilities.json`)
+
+| row | capability | Today |
+|---|---|---|
+| `crypto-06` | Lock the vault by hand, and have it lock itself after a period of inactivity. | `partial`: `partial`: the web app auto-lock is a timer from unlock, because `updateActivity` has no caller, so an active user is locked out mid-work |
+| `crypto-07` | Choose your own session timeout. | `partial`: `partial`: the chosen timeout applies to the page session only, is never saved, and the Nextcloud session choice silently becomes 10 minutes |
+
+### Demand
+
+- `crypto-06`: no demand row.
+- `crypto-07`: no demand row.
+
+### Competitors rated yes
+
+- `crypto-06`, bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts:59 checkVaultTimeout (periodic, :36); apps/web/src/locales/en/messages.json:2536 'lockNow'; apps/browser/src/manifest.v"
+- `crypto-06`, onepassword: "https://support.1password.com/unlock-auto-lock/ : 'Lock after system is idle for' minutes, also locks on sleep"
+- `crypto-06`, keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#account-settings : Logout Timer 'to automatically log out a user from Keeper when they are inactive' for Web, Mobile and Desktop"
+- `crypto-06`, hashicorp-vault: "hashicorp/vault@v2.1.1 ui/lib/core/addon/components/sidebar/user-menu.hbs:63 Log out; ui/app/services/auth.js:32 IDLE_TIMEOUT 3 min, :382 stops token renewal after idle so the session ends at token expiry; ui/app/components/token-"
+- `crypto-07`, bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts timeout value and action (lock or log out); apps/web/src/locales/en/messages.json:7534 'vaultTimeout'; bitward"
+- `crypto-07`, onepassword: "https://support.1password.com/unlock-auto-lock/ : adjust Auto-Lock minutes; Business presets in https://support.1password.com/unlock-auto-lock-policy/"
+- `crypto-07`, keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#account-settings : the admin timer is the maximum; users choose their own timer up to it ('If a Keeper user's current timer is set greater than this value, it will"
+- `crypto-07`, nextcloud-passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Section/Settings.vue:92-106 'End session after' select (1 to 60 minutes) bound to user.session.lifetime; src/lib/Helper/Settings/UserSettingsHelper.php session/lifetime default 600 Not"
+
+## What Changes
+
+- Call `updateActivity()` on pointer, key and scroll events, throttled, so the lock counts inactivity.
+- Mount `SessionTimeoutSection` in personal settings, remove the in-memory select from `App.vue`, and load the saved value at unlock.
+- Fix the Nextcloud session option so it means no idle timer beyond the Nextcloud session, not 10 minutes.
+
+## Capabilities
+
+### New Capabilities
+
+- `vault-session-lock`
+
+### Modified Capabilities
+
+- None in delta form.
+
+## Impact
+
+- **Frontend**: `src/store/modules/session.js`, `src/App.vue`, `SessionTimeoutSection.vue`, the settings page that hosts it.
+- **Backend**: none; `session_timeout` is already a user preference (`lib/Service/SettingsService.php:93`).
+- **Database**: none.
+- **Cross-row**: `admin-24` (an administrator cap on the timeout) is decided no on its own; this change leaves the select ready for a maximum but adds none.
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md
new file mode 100644
index 000000000..ec972158b
--- /dev/null
+++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md
@@ -0,0 +1,39 @@
+## ADDED Requirements
+
+### Requirement: Inactivity lock
+
+The system MUST lock the web vault after the configured period without user activity. Pointer movement, key presses, scrolling and touch MUST reset the period. Activity in another browser tab MUST NOT keep a locked tab unlocked, and a lock MUST still clear the master key from memory.
+
+#### Scenario: An active user is not locked out
+
+- **GIVEN** a user with a 10 minute timeout who has been working for 25 minutes with clicks every minute
+- **WHEN** the user keeps working
+- **THEN** the vault stays unlocked
+
+#### Scenario: An idle user is locked
+
+- **GIVEN** a user with a 10 minute timeout who stops interacting
+- **WHEN** ten minutes pass
+- **THEN** the lock screen is shown and the master key is cleared
+
+#### Scenario: Manual lock still works
+
+- **GIVEN** an unlocked user
+- **WHEN** the user chooses Lock vault from the menu
+- **THEN** the lock screen is shown at once
+
+### Requirement: Saved session timeout
+
+The system MUST let a user choose a timeout in personal settings, MUST persist it through `PUT` on the session timeout preference, and MUST apply the saved value when the vault is unlocked in a new page load. The option Nextcloud session MUST mean no separate idle timer and MUST NOT be converted to another value.
+
+#### Scenario: The choice survives a reload
+
+- **GIVEN** a user who picked 30 minutes in personal settings
+- **WHEN** the user reloads the page and unlocks
+- **THEN** the vault uses a 30 minute timeout and the select shows 30 minutes
+
+#### Scenario: Nextcloud session means no idle timer
+
+- **GIVEN** a user who picked Nextcloud session
+- **WHEN** the user stays idle for an hour within the Nextcloud session
+- **THEN** the vault does not lock on an idle timer
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md
new file mode 100644
index 000000000..f93d1f9a7
--- /dev/null
+++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md
@@ -0,0 +1,15 @@
+# Tasks: an inactivity lock that follows activity, and a timeout the user keeps
+
+## 1. Inactivity
+
+- [ ] 1.1 Attach throttled activity listeners in `App.vue` that call `sessionStore.updateActivity()`. Verify: vitest with fake timers for reset, no reset while idle, and throttling.
+- [ ] 1.2 Represent Nextcloud session as `null` in the session store and stop the `|| 600000` fallback. Verify: vitest that `null` never locks and a number does.
+
+## 2. Saved choice
+
+- [ ] 2.1 Mount `SessionTimeoutSection` in personal settings, delete the in-memory select and `saveTimeout`, load the saved value in the unlock path. Verify: vitest for load at unlock; Playwright flow pick 30 minutes, reload, unlock, read the select.
+
+## 3. Close out
+
+- [ ] 3.1 Set rows `crypto-06` and `crypto-07` to built, clear their defects, archive the change. Verify: parity_verify --strict.
+
diff --git a/openspec/changes/portability-cxp-cross-provider-transfer/design.md b/openspec/changes/portability-cxp-cross-provider-transfer/design.md
new file mode 100644
index 000000000..9b0a9d399
--- /dev/null
+++ b/openspec/changes/portability-cxp-cross-provider-transfer/design.md
@@ -0,0 +1,29 @@
+# Design: exchange a vault with another provider through Credential Exchange files
+
+## Context
+
+At development `156cd800`:
+
+- `src/views/SecretList.vue:163` opens the transfer dialog; `src/dialogs/CxpTransferDialog.vue:275` `startReceive` and `:360` `doSend`.
+- `src/crypto/cxp.js:110` `createImportRequest`, `:136` `sealForRequest`, `:187` `openEnvelope` are already transport free.
+- `lib/Controller/CxpRelayController.php:219` and routes `:400-401` are a same-instance mailbox.
+- `src/store/modules/export.js:206` `exportCxpSealed` exports the whole vault.
+
+## Goals / Non-Goals
+
+**Goals**
+- A user can move a vault to or from a provider that speaks the standard, without an intermediary account.
+
+**Non-Goals**
+- Operating system credential exchange APIs, which a web page cannot reach.
+- A hosted public relay.
+
+## Decisions
+
+### D1: File and QR transport over the existing crypto
+
+`cxp.js` already separates the envelope from the mailbox, so a file or QR carries the same request and response the relay carries. Nothing new is trusted.
+
+### D2: Vectors first
+
+The first task checks the sealing against the published protocol test vectors, so a mismatch is found before any UI is built.
diff --git a/openspec/changes/portability-cxp-cross-provider-transfer/proposal.md b/openspec/changes/portability-cxp-cross-provider-transfer/proposal.md
new file mode 100644
index 000000000..cc8107b3c
--- /dev/null
+++ b/openspec/changes/portability-cxp-cross-provider-transfer/proposal.md
@@ -0,0 +1,49 @@
+---
+kind: code
+---
+
+# Exchange a vault with another provider through Credential Exchange files
+
+## Why
+
+Keepiq implements the Credential Exchange Protocol handshake between two sessions on one server (`src/dialogs/CxpTransferDialog.vue`, `src/crypto/cxp.js`, `lib/Controller/CxpRelayController.php`), so it cannot receive from or send to another provider, which is the point of the standard. The send side always sends the whole vault (`src/store/modules/export.js:206`). Bitwarden and 1Password document the standard for direct transfer. Two competitors rate yes.
+
+One row, one change.
+
+### Matrix rows (`keepiq` `openspec/parity/capabilities.json`)
+
+| row | capability | Today |
+|---|---|---|
+| `portability-08` | Move a vault straight to or from another provider with the Credential Exchange Protocol. | `partial`: `partial`: the sealed handshake works through a relay on the same Nextcloud only, so both sides must be Keepiq; there is no way to a different provider, and sending always sends the whole vault |
+
+### Demand
+
+- `portability-08`: no demand row.
+
+### Competitors rated yes
+
+- `portability-08`, bitwarden: "code not public in the cloned repos (bitwarden/clients@web-v2026.9.0 has no CXP code; the iOS and Android apps are separate repos); docs rating kept: https://bitwarden.com/help/import-data/ Note: CXP direct import and export is a "
+- `portability-08`, onepassword: "https://support.1password.com/import/ : Credential Exchange standard on iOS 26+ and Android 14+ for direct transfer"
+
+## What Changes
+
+- Add Create import request as a file or QR: the receive side publishes its HPKE public key and request in the CXP request format so another provider can seal to it, and accepts the sealed CXF file it returns.
+- Add Send to another provider: the user pastes or loads another provider's request and downloads a sealed CXF file for it.
+- Let the send side choose what goes: all items, a folder or a selection.
+
+## Capabilities
+
+### New Capabilities
+
+- `portability-cxp`
+
+### Modified Capabilities
+
+- None in delta form.
+
+## Impact
+
+- **Frontend**: `CxpTransferDialog.vue` gains file and QR modes; `src/crypto/cxp.js` exposes request creation and open for file transport; `export.js` takes an item filter.
+- **Backend**: none for file transport; the relay stays for same-server transfers.
+- **Security**: sealing is HPKE to the requester key as the protocol defines; no new key material is stored.
+- **Risk**: interoperability depends on the other provider's current draft of the protocol; the task list starts with a test against the published test vectors.
diff --git a/openspec/changes/portability-cxp-cross-provider-transfer/specs/portability-cxp/spec.md b/openspec/changes/portability-cxp-cross-provider-transfer/specs/portability-cxp/spec.md
new file mode 100644
index 000000000..f337171db
--- /dev/null
+++ b/openspec/changes/portability-cxp-cross-provider-transfer/specs/portability-cxp/spec.md
@@ -0,0 +1,33 @@
+## ADDED Requirements
+
+### Requirement: Cross provider transfer
+
+The system MUST let a user create a CXP import request that another provider can answer without a Keepiq relay, and MUST accept the sealed CXF response as a file. The system MUST also let a user seal an export to a request produced by another provider and download it. Only the recipient's public key from the request MUST be used to seal; the sealed file MUST NOT be readable by Keepiq or the relay.
+
+#### Scenario: A user receives from another provider
+
+- **GIVEN** a user on the secret list choosing Encrypted transfer and Receive from another provider
+- **WHEN** the user downloads the request file, has the other provider seal an export to it and loads the response file
+- **THEN** the import wizard opens with the received items
+
+#### Scenario: A user sends to another provider
+
+- **GIVEN** a user with a request file from another provider
+- **WHEN** the user loads it, chooses one folder and confirms
+- **THEN** a sealed file is downloaded that contains only that folder's items
+
+#### Scenario: A tampered response is refused
+
+- **GIVEN** a user loading a response file whose envelope was altered
+- **WHEN** the file is opened
+- **THEN** the dialog says the file could not be verified and imports nothing
+
+### Requirement: Choose what to send
+
+The send side MUST let the user pick all items, one folder or a selection, and the sealed file MUST contain only that choice.
+
+#### Scenario: A selection is sealed
+
+- **GIVEN** a user with three items selected on the list
+- **WHEN** the user starts Send and confirms
+- **THEN** the sealed file holds exactly the three items
diff --git a/openspec/changes/portability-cxp-cross-provider-transfer/tasks.md b/openspec/changes/portability-cxp-cross-provider-transfer/tasks.md
new file mode 100644
index 000000000..8e33733b4
--- /dev/null
+++ b/openspec/changes/portability-cxp-cross-provider-transfer/tasks.md
@@ -0,0 +1,16 @@
+# Tasks: exchange a vault with another provider through Credential Exchange files
+
+## 1. Protocol
+
+- [ ] 1.1 Verify `createImportRequest`, `sealForRequest` and `openEnvelope` against the published CXP test vectors and fix drift. Verify: vitest with the vectors.
+
+## 2. Receive and send
+
+- [ ] 2.1 Add request-as-file and response-as-file to the receive path of the dialog. Verify: vitest of a full request, foreign seal and open; Playwright flow with a fixture response file.
+- [ ] 2.2 Add Send to another provider with a loaded request file, and the item filter (all, folder, selection) in `export.js`. Verify: vitest that only the chosen items are sealed.
+
+## 3. Close out
+
+- [ ] 3.1 Add strings to every shipped locale. Verify: `npm run test:l10n`.
+- [ ] 3.2 Set row `portability-08` to built and archive the change. Verify: parity_verify --strict.
+
diff --git a/openspec/changes/portability-import-field-mapping/design.md b/openspec/changes/portability-import-field-mapping/design.md
new file mode 100644
index 000000000..8f86cab34
--- /dev/null
+++ b/openspec/changes/portability-import-field-mapping/design.md
@@ -0,0 +1,29 @@
+# Design: adjust the column mapping in the import wizard
+
+## Context
+
+At development `156cd800`:
+
+- `src/dialogs/ImportWizardDialog.vue:66-117` preview table; `:468` masks sensitive cells but nothing sets `revealed[key]`; `:506` `parseFile` call passes only the passphrase.
+- `src/import/parsers/csv.js:120` accepts `options.mapping`; `:142` declares `adjustableMapping`.
+- `src/store/modules/import.js:57` holds a `mapping` state that nothing writes.
+- Vendor formats (Bitwarden, 1Password, and so on) have fixed mappings and keep them.
+
+## Goals / Non-Goals
+
+**Goals**
+- The user controls the column mapping of a generic CSV before anything is stored.
+
+**Non-Goals**
+- Mapping for vendor formats.
+- Saving a mapping as a preset.
+
+## Decisions
+
+### D1: Mapping for generic CSV only
+
+Vendor exports have a known shape; showing selects for them adds a way to break a working import. The step appears only when the parser reports `adjustableMapping`.
+
+### D2: The store owns the mapping
+
+The wizard writes the mapping into the import store and both the preview and the import read it from there, so they cannot disagree.
diff --git a/openspec/changes/portability-import-field-mapping/proposal.md b/openspec/changes/portability-import-field-mapping/proposal.md
new file mode 100644
index 000000000..821e4fc5c
--- /dev/null
+++ b/openspec/changes/portability-import-field-mapping/proposal.md
@@ -0,0 +1,48 @@
+---
+kind: code
+---
+
+# Adjust the column mapping in the import wizard
+
+## Why
+
+The import wizard shows a five-row preview (`src/dialogs/ImportWizardDialog.vue:66-117`) but the user cannot change which column feeds which field. The CSV parser already accepts `options.mapping` and declares `adjustableMapping` (`src/import/parsers/csv.js:120,142`), yet the wizard calls `parseFile(text, format, {passphrase})` (`:506`) and the import store's `mapping` state (`src/store/modules/import.js:57`) is never written. A wrongly guessed column silently imports a password as a note. Keeper and Nextcloud Passwords rate yes.
+
+One row, one change.
+
+### Matrix rows (`keepiq` `openspec/parity/capabilities.json`)
+
+| row | capability | Today |
+|---|---|---|
+| `portability-03` | Preview and adjust the field mapping before importing. | `partial`: `partial`: the wizard previews the first five rows read-only; the CSV parser accepts a mapping but the wizard never passes one |
+
+### Demand
+
+- `portability-03`: no demand row.
+
+### Competitors rated yes
+
+- `portability-03`, keeper: "https://docs.keeper.io/user-guides/web-vault#field-mapping : field mapping screen ('Click any field to open a dropdown menu'), then 'a summary screen will display a preview of your vault' before import"
+- `portability-03`, nextcloud-passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Components/Import.vue:172-189 'Preview Line' select and csv-mapping field selectors via csvFieldMapping() Note: Custom CSV imports show a preview row and let you map each column; prede"
+
+## What Changes
+
+- Add a mapping step for CSV imports: one select per target field (name, url, login, password, notes, folder, type) pre-filled from the detected header.
+- Re-run the preview when the mapping changes, and pass the mapping to `parseFile`.
+- Make masked preview cells revealable, which the current code cannot do.
+
+## Capabilities
+
+### New Capabilities
+
+- `portability-import-mapping`
+
+### Modified Capabilities
+
+- None in delta form.
+
+## Impact
+
+- **Frontend**: `ImportWizardDialog.vue` (split the mapping step into its own dialog file per the modal-isolation gate), `import.js` store, `csv.js` call site.
+- **Backend**: none; import is client-side then batch create.
+- **Database**: none.
diff --git a/openspec/changes/portability-import-field-mapping/specs/portability-import-mapping/spec.md b/openspec/changes/portability-import-field-mapping/specs/portability-import-mapping/spec.md
new file mode 100644
index 000000000..b95995f13
--- /dev/null
+++ b/openspec/changes/portability-import-field-mapping/specs/portability-import-mapping/spec.md
@@ -0,0 +1,27 @@
+## ADDED Requirements
+
+### Requirement: Adjustable CSV mapping
+
+The import wizard MUST show, for a generic CSV file, the detected mapping from each target field to a source column and MUST let the user change any of them before the import starts. The preview MUST update to the changed mapping, and the import MUST use exactly the mapping shown.
+
+#### Scenario: A user fixes a wrong guess
+
+- **GIVEN** a user importing a CSV whose password column is named Secret Key
+- **WHEN** the wizard guessed Notes for it and the user picks Password for that column
+- **THEN** the preview shows the values under Password and the import stores them as passwords
+
+#### Scenario: A required field is unmapped
+
+- **GIVEN** a user in the mapping step
+- **WHEN** the user sets Name to no column
+- **THEN** the Import button is disabled and the step says a name column is required
+
+### Requirement: Revealing sensitive preview cells
+
+The preview MUST mask login and password cells and MUST let the user reveal one cell at a time.
+
+#### Scenario: A user checks a password cell
+
+- **GIVEN** the preview of a CSV import
+- **WHEN** the user chooses Reveal on one password cell
+- **THEN** that cell shows its value and the others stay masked
diff --git a/openspec/changes/portability-import-field-mapping/tasks.md b/openspec/changes/portability-import-field-mapping/tasks.md
new file mode 100644
index 000000000..c5b7c9a49
--- /dev/null
+++ b/openspec/changes/portability-import-field-mapping/tasks.md
@@ -0,0 +1,13 @@
+# Tasks: adjust the column mapping in the import wizard
+
+## 1. Mapping
+
+- [ ] 1.1 Write and read `mapping` in the import store and pass it to `parseFile`. Verify: vitest that a changed mapping changes the parsed rows.
+- [ ] 1.2 Add the mapping step as its own dialog component with the target field selects and the required-name rule. Verify: vitest for the disabled Import button; Playwright flow import a CSV with a renamed column.
+- [ ] 1.3 Wire the per-cell reveal in the preview. Verify: vitest that only the chosen cell is revealed.
+
+## 2. Close out
+
+- [ ] 2.1 Add strings to every shipped locale. Verify: `npm run test:l10n`.
+- [ ] 2.2 Set row `portability-03` to built, clear its defects, archive the change. Verify: parity_verify --strict.
+
diff --git a/openspec/changes/sharing-group-share-entry-point/design.md b/openspec/changes/sharing-group-share-entry-point/design.md
new file mode 100644
index 000000000..d2386cac3
--- /dev/null
+++ b/openspec/changes/sharing-group-share-entry-point/design.md
@@ -0,0 +1,30 @@
+# Design: share a secret with a Nextcloud group from the secret sidebar
+
+## Context
+
+At development `156cd800`:
+
+- `lib/Controller/GroupShareController.php` `index`, `create` (`:103`), `destroy` (`:143`), `approveNewMember` (`:179`), `denyNewMember` (`:233`) are routed at `appinfo/routes.php:133-135` and onward.
+- `lib/Service/GroupShareService.php:100` `createGroupShare($secretId, $groupId, $userId)` encrypts per member server-side; `:224` `getGroupMembers`; `:250` `handleNewGroupMember`.
+- `src/components/share/GroupShareForm.vue` takes a free text group id and a `members` prop no caller supplies, and calls `useShareStore.encryptForRecipient`.
+- `src/components/SecretDetailSidebar.vue:680-687` mounts the sharing components for owners and recipients.
+- `grep -rn group-shares src browser-extension cli` finds no caller.
+
+## Goals / Non-Goals
+
+**Goals**
+- An owner reaches the finished group share backend from the UI.
+
+**Non-Goals**
+- Changing the group share crypto.
+- Group roles on a share (`sharing-team-folder-manager-role`).
+
+## Decisions
+
+### D1: The server encrypts for members
+
+The backend already builds each member copy, so the form drops the per-member client encryption loop and only sends the group id. This removes the `members` prop nobody supplied.
+
+### D2: Pick, do not type
+
+A group search select replaces the free text field so a typo cannot target the wrong group.
diff --git a/openspec/changes/sharing-group-share-entry-point/proposal.md b/openspec/changes/sharing-group-share-entry-point/proposal.md
new file mode 100644
index 000000000..023dcca2c
--- /dev/null
+++ b/openspec/changes/sharing-group-share-entry-point/proposal.md
@@ -0,0 +1,52 @@
+---
+kind: code
+---
+
+# Share a secret with a Nextcloud group from the secret sidebar
+
+## Why
+
+A user cannot share a secret with a Nextcloud group today. The backend is finished: `POST /api/v1/secrets/{secretId}/group-shares` (`appinfo/routes.php:134`) and `GroupShareService::createGroupShare()` (`lib/Service/GroupShareService.php:100`). The form `src/components/share/GroupShareForm.vue` is registered but has no opener, and its submit path calls the per-user store rather than the group route. Five competitors rate yes. It is a share-path completion, the same class as the shipped user sharing.
+
+One row, one change.
+
+### Matrix rows (`keepiq` `openspec/parity/capabilities.json`)
+
+| row | capability | Today |
+|---|---|---|
+| `sharing-02` | Share a secret with a Nextcloud group. | `no`: `no`: `GroupShareController` and `GroupShareService` are complete, but nothing in the app opens the group share form or calls the group-share routes |
+
+### Demand
+
+- `sharing-02`: no demand row.
+
+### Competitors rated yes
+
+- `sharing-02`, bitwarden: "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/GroupsController.cs:23 organizations/{orgId}/groups, :123 POST, :147 PUT with collection access; bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizati"
+- `sharing-02`, onepassword: "https://support.1password.com/custom-groups/ : 'give everyone in a group access to specific vaults and assign vault permissions'"
+- `sharing-02`, passbolt: "passbolt/passbolt_api@v5.16.0 src/Controller/Share/ShareController.php:101 share (groups are AROs); passbolt/passbolt_styleguide@v5.16.0 src/react-extension/components/Share/GroupPermissionItem.js, ShareDialog.js:515 Note: Groups "
+- `sharing-02`, keeper: "https://docs.keeper.io/enterprise-guide/teams : 'Teams can be added to Shared Folders in the vault', teams provisioned from the IdP via SCIM or AD Bridge"
+- `sharing-02`, hashicorp-vault: "hashicorp/vault@v2.1.1 ui/app/models/identity/group.js:15 fields name, type, policies, metadata (internal or external group); ui/app/router.js access.identity create/edit routes; vault/identity_store_util.go:3164 refreshExternalGr"
+
+## What Changes
+
+- Add a Share with group action to the sharing section of the secret sidebar, next to Share with user.
+- Rework `GroupShareForm.vue` to pick a group (search over the caller's Nextcloud groups) and to call a `useGroupShareStore` action that posts to the group-share route.
+- List the group shares of a secret with a revoke action, using `GET /api/v1/secrets/{secretId}/group-shares` and `DELETE /api/v1/group-shares/{id}`.
+
+## Capabilities
+
+### New Capabilities
+
+- `sharing-group`
+
+### Modified Capabilities
+
+- None in delta form.
+
+## Impact
+
+- **Frontend**: `SecretDetailSidebar.vue`, `GroupShareForm.vue`, a new `src/store/modules/groupShare.js`.
+- **Backend**: a group search endpoint limited to the caller's visible groups if none exists; the group-share routes are unchanged.
+- **Database**: none.
+- **Security**: the group id comes from a picker but the server already validates membership visibility; the change adds a test that a user cannot share into a group they cannot see.
diff --git a/openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md b/openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md
new file mode 100644
index 000000000..eb2b74038
--- /dev/null
+++ b/openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: Share with a group
+
+The system MUST let the owner of a secret share it with a Nextcloud group from the secret detail sidebar. The action MUST call `POST /api/v1/secrets/{secretId}/group-shares` and MUST show how many members received the share and how many were skipped for lack of an active encryption suite. Members who join the group later MUST follow the existing approval path of `GroupShareService::handleNewGroupMember()`.
+
+#### Scenario: An owner shares with a group
+
+- **GIVEN** an owner viewing a secret in the sidebar at /secrets and a group Finance with four members of whom three have an encryption suite
+- **WHEN** the owner picks Share with group, selects Finance and confirms
+- **THEN** the sidebar lists a Finance group share and says three members received it and one was skipped
+
+#### Scenario: A non-owner cannot share with a group
+
+- **GIVEN** a recipient who holds a shared copy
+- **WHEN** the recipient opens the sidebar
+- **THEN** the Share with group action is not offered
+
+#### Scenario: The owner revokes a group share
+
+- **GIVEN** a secret shared with Finance
+- **WHEN** the owner revokes the Finance share in the sidebar
+- **THEN** the group share is gone and members of Finance lose their copies
diff --git a/openspec/changes/sharing-group-share-entry-point/tasks.md b/openspec/changes/sharing-group-share-entry-point/tasks.md
new file mode 100644
index 000000000..cd5ce1197
--- /dev/null
+++ b/openspec/changes/sharing-group-share-entry-point/tasks.md
@@ -0,0 +1,16 @@
+# Tasks: share a secret with a Nextcloud group from the secret sidebar
+
+## 1. Wire the form
+
+- [ ] 1.1 Create `src/store/modules/groupShare.js` with list, create and revoke actions. Verify: vitest with mocked axios asserting the three routes and payloads.
+- [ ] 1.2 Rework `GroupShareForm.vue` to a group picker and the store action, and open it from the sidebar. Verify: vitest on the form; Playwright flow share with a group as owner, see the result counts.
+- [ ] 1.3 List and revoke group shares in the sidebar. Verify: Playwright flow revoke, recipient loses access.
+
+## 2. Backend check
+
+- [ ] 2.1 Add a PHPUnit test that a user cannot create a group share for a secret they do not own and cannot target a group they cannot see. Verify: PHPUnit; hydra no-admin-idor gate.
+
+## 3. Close out
+
+- [ ] 3.1 Set row `sharing-02` to built, clear its defect, archive the change. Verify: parity_verify --strict.
+
diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/design.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/design.md
new file mode 100644
index 000000000..1d618a8fe
--- /dev/null
+++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/design.md
@@ -0,0 +1,36 @@
+# Design: hidden custom fields and a real SSH key item
+
+## Context
+
+At development `156cd800`:
+
+- `src/components/AdditionalFieldsEditor.vue:50` renders each value in a plain `NcTextField`; the field model is `{name, value}`.
+- `src/store/modules/secret.js:313-321` decrypts `additionalFields` from JSON and `:377` encrypts it before `POST /api/v1/secrets` (`appinfo/routes.php:89`); the server never sees the shape.
+- `src/components/SecretDetailSidebar.vue:570-577` renders each additional field value as plain `
` text.
+- `lib/Repair/SeedSecretTypes.php:64` seeds `ssh_key`; `src/dialogs/SecretCreateDialog.vue:95-108` gives it the generic key field.
+- `src/cxf/cxf.js:205,355` maps CXF `ssh-key` items and custom fields to and from secrets.
+
+## Goals / Non-Goals
+
+**Goals**
+- A value that must not be shown by default can be marked hidden and stays hidden until asked for.
+- An SSH key pair is one item with its own fields, and a pair can be generated without leaving the browser.
+
+**Non-Goals**
+- Linked or file field kinds.
+- Serving the key over an SSH agent; that is `clients-ssh-agent`.
+- A server-side key generator.
+
+## Decisions
+
+### D1: The kind lives in the encrypted blob
+
+Adding a `kind` property to each entry of the encrypted additional fields needs no migration and no server change, and an old blob reads as text. A separate column would leak which fields are hidden.
+
+### D2: Generate in the browser
+
+The private key is created with WebCrypto in the create dialog and encrypted with the item, so it is never in plain text on the server. Ed25519 is used where `crypto.subtle.generateKey` supports it, otherwise RSA 4096.
+
+### D3: Fingerprint is derived, not typed
+
+A SHA256 fingerprint is computed from the public key on save so it can never disagree with the key.
diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/proposal.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/proposal.md
new file mode 100644
index 000000000..60609ceef
--- /dev/null
+++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/proposal.md
@@ -0,0 +1,60 @@
+---
+kind: code
+---
+
+# Hidden custom fields and a real SSH key item
+
+## Why
+
+A person can add named custom fields to an item, but every value is a plain text field and is printed unmasked on the detail sidebar (`src/components/AdditionalFieldsEditor.vue:50`, `src/components/SecretDetailSidebar.vue:570-577`). A recovery code or an API pin typed there is readable by anyone looking over a shoulder. The SSH Key type is seeded (`lib/Repair/SeedSecretTypes.php:64`) but the create dialog offers one value field for it (`src/dialogs/SecretCreateDialog.vue:95-108`), so a key pair cannot be stored as a key pair. Both rows sit in the vault area, the core area, with six and three competitors rating yes. They share one form (the create and edit dialogs) and one blob (the encrypted additional fields), so they are one change.
+
+The rows share one screen or service, so they are one change.
+
+### Matrix rows (`keepiq` `openspec/parity/capabilities.json`)
+
+| row | capability | Today |
+|---|---|---|
+| `vault-11` | Add your own custom fields to an item, including hidden ones. | `partial`: `partial`: named custom fields exist and are stored encrypted, but there is no hidden kind, so every value is typed and shown as plain text |
+| `vault-14` | Store an SSH key pair as its own item type. | `partial`: `partial`: an SSH Key type exists but holds one value field, with no separate private key, public key or fingerprint and no key pair generation |
+
+### Demand
+
+- `vault-11`: no demand row.
+- `vault-14`: no demand row.
+
+### Competitors rated yes
+
+- `vault-11`, bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/vault/enums/field-type.enum.ts:4 Text, :5 Hidden, :6 Boolean, :7 Linked; libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts:244 addField, :200 hidden fiel"
+- `vault-11`, onepassword: "https://support.1password.com/custom-fields/ : 11 field types including Password ('copy, reveal, or enlarge')"
+- `vault-11`, passbolt: "passbolt/passbolt_api@v5.16.0 config/Migrations/20250704120736_V530AddCustomFieldStandaloneResourceType.php, plugins/PassboltCe/ResourceTypes/src/Model/Entity/ResourceType.php:56 v5-custom-fields; passbolt/passbolt_styleguide@v5.1"
+- `vault-11`, keeper: "https://docs.keeper.io/user-guides/web-vault#custom-fields : custom fields incl. 'Hidden Field', 'Security Question & Answer', 'Multi-line Text'"
+- `vault-11`, hashicorp-vault: "hashicorp/vault@v2.1.1 ui/lib/core/addon/components/kv-object-editor.hbs:25 free key input per row, :36 MaskedInput for values when @isMasked; ui/lib/kv/addon/components/kv-create-edit-form.hbs:42 KvObjectEditor Note: A KV secret "
+- `vault-11`, nextcloud-passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Dialog/CreatePassword/CustomFields/CustomFieldType.vue:14-19 types text, secret, email, url, file, data; src/vue/Dialog/CreatePassword.vue:132 up to 20 custom fields; src/vue/Dialog/Cr"
+- `vault-14`, bitwarden: "bitwarden/server@v2026.9.1 src/Core/Vault/Enums/CipherType.cs:11 SSHKey = 5; bitwarden/clients@web-v2026.9.0 libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts:55 privateKey field (public key and fing"
+- `vault-14`, onepassword: "https://support.1password.com/item-categories/ : 'SSH Key: contain an option to generate new SSH keys or import existing'"
+- `vault-14`, keeper: "https://docs.keeper.io/user-guides/record-types : 'SSH Key: SSH information, such as public and private key strings'"
+
+## What Changes
+
+- Give each custom field a kind: text, hidden or boolean. Hidden values render masked with a reveal and copy control, in the editor and on the detail sidebar.
+- Give the SSH Key type its own form: private key, public key and fingerprint, with a Generate key pair action that runs in the browser (WebCrypto Ed25519 where the browser supports it, else RSA 4096) and an Import from file action.
+- Keep the encrypted blob shape backward compatible: a field without a `kind` reads as text.
+- Map the new fields through the CXF export and import in `src/cxf/cxf.js` so a hidden field and an SSH key round trip.
+
+## Capabilities
+
+### New Capabilities
+
+- `vault-custom-fields`
+- `vault-ssh-key-item`
+
+### Modified Capabilities
+
+- None in delta form.
+
+## Impact
+
+- **Frontend**: `AdditionalFieldsEditor.vue`, `SecretDetailSidebar.vue`, `SecretCreateDialog.vue`, `SecretEditDialog.vue`, a new SSH key section component, `src/cxf/cxf.js`.
+- **Backend**: none. The blob is opaque to the server; no migration, no route.
+- **Security**: the private key is generated and encrypted in the browser. A hidden field is a display control only; the value is as encrypted as before.
+- **l10n**: new strings in every locale the app ships.
diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-custom-fields/spec.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-custom-fields/spec.md
new file mode 100644
index 000000000..64ed915db
--- /dev/null
+++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-custom-fields/spec.md
@@ -0,0 +1,27 @@
+## ADDED Requirements
+
+### Requirement: Hidden custom fields
+
+The system MUST let the owner of a secret give each custom field a kind of `text`, `hidden` or `boolean` in the create and edit dialogs. A `hidden` value MUST be rendered masked in the editor and on the detail sidebar until the user chooses Reveal, and MUST have a Copy action that does not reveal it. The kind MUST be stored inside the same encrypted additional-fields blob as the name and value, and a field without a kind MUST be read as `text`.
+
+#### Scenario: An owner hides a recovery code
+
+- **GIVEN** an owner editing a login at /secrets with an existing text custom field named Recovery code
+- **WHEN** the owner sets the field kind to Hidden and saves
+- **THEN** the detail sidebar shows the value as dots and a Reveal button, and Copy places the real value on the clipboard
+
+#### Scenario: An old secret still opens
+
+- **GIVEN** a secret saved before this change whose additional fields have no kind
+- **WHEN** the owner opens its detail sidebar
+- **THEN** every custom field shows as plain text exactly as before
+
+### Requirement: Hidden values in exports
+
+Export and import through CXF MUST carry the field kind: a `hidden` field MUST be exported as a concealed string field and imported back as `hidden`.
+
+#### Scenario: A hidden field survives a CXF round trip
+
+- **GIVEN** a secret with one hidden and one text custom field
+- **WHEN** the owner exports it as CXF and imports the file into an empty vault
+- **THEN** the imported secret has both fields and the second one is still hidden
diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-ssh-key-item/spec.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-ssh-key-item/spec.md
new file mode 100644
index 000000000..b200b204c
--- /dev/null
+++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/specs/vault-ssh-key-item/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: SSH key item form
+
+The system MUST show private key, public key and fingerprint fields when the item type is SSH Key, and MUST derive the fingerprint from the public key on save. The private key MUST be stored with the secret value and the public key and fingerprint MUST be stored in the same encrypted blob. The form MUST offer Generate key pair, which creates the pair in the browser and never sends the private key anywhere before encryption, and Import from file.
+
+#### Scenario: A user generates an SSH key pair
+
+- **GIVEN** a vault user creating a new item of type SSH Key on the secret list
+- **WHEN** the user chooses Generate key pair and saves
+- **THEN** the item stores the private key encrypted, the public key and a SHA256 fingerprint, and the detail sidebar shows the public key with a Copy action
+
+#### Scenario: A user imports an existing private key
+
+- **GIVEN** a vault user with an OpenSSH private key file
+- **WHEN** the user chooses Import from file in the SSH Key form
+- **THEN** the private key and the derived public key and fingerprint are filled in before saving
+
+#### Scenario: A malformed key is refused
+
+- **GIVEN** a vault user in the SSH Key form
+- **WHEN** the user pastes text that is not a key into the private key field and saves
+- **THEN** the form shows an error on that field and nothing is stored
diff --git a/openspec/changes/vault-custom-field-kinds-and-ssh-key/tasks.md b/openspec/changes/vault-custom-field-kinds-and-ssh-key/tasks.md
new file mode 100644
index 000000000..2cc63c62d
--- /dev/null
+++ b/openspec/changes/vault-custom-field-kinds-and-ssh-key/tasks.md
@@ -0,0 +1,19 @@
+# Tasks: hidden custom fields and a real SSH key item
+
+## 1. Custom field kinds
+
+- [ ] 1.1 Add `kind` to the field model in `AdditionalFieldsEditor.vue` with a kind select, and mask hidden values with reveal and copy controls. Verify: vitest on the editor emitting `{name, value, kind}` and on an old blob reading as text.
+- [ ] 1.2 Render hidden fields masked in `SecretDetailSidebar.vue`. Verify: vitest that a hidden value is not in the DOM text until Reveal, and a Playwright flow hide, reveal, copy.
+- [ ] 1.3 Carry the kind through `src/cxf/cxf.js` export and import. Verify: vitest round trip with one hidden and one text field.
+
+## 2. SSH key item
+
+- [ ] 2.1 Build the SSH key section (private key, public key, fingerprint) and show it for type `ssh_key` in the create and edit dialogs. Verify: vitest that the fingerprint is derived and a malformed key is refused.
+- [ ] 2.2 Add Generate key pair and Import from file. Verify: vitest generating a pair and parsing the OpenSSH public key; Playwright flow create, save, open, copy public key.
+- [ ] 2.3 Update the CXF mapping for the new SSH fields. Verify: vitest round trip.
+
+## 3. Close out
+
+- [ ] 3.1 Add strings to every shipped locale through the writing skill, never `test:l10n:write`. Verify: `npm run test:l10n`.
+- [ ] 3.2 Set rows `vault-11` and `vault-14` to built with evidence paths and lines, and archive the change. Verify: parity_verify --strict.
+
diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/design.md b/openspec/changes/vault-defaults-and-recently-used-widget/design.md
new file mode 100644
index 000000000..4171a644d
--- /dev/null
+++ b/openspec/changes/vault-defaults-and-recently-used-widget/design.md
@@ -0,0 +1,31 @@
+# Design: default item type and view, and a recently used list on the dashboard
+
+## Context
+
+At development `156cd800`:
+
+- `lib/Service/SettingsService.php:98-99` holds `default_secret_type` (default `login`) and `default_view` (default `list`); `src/store/modules/dashboardSettings.js` already allow-lists `default_view`.
+- `src/views/DashboardSettingsView.vue:36` binds `form.default_view` and is in no route or registry (matrix defect, issue #208).
+- `src/components/settings/SessionTimeoutSection.vue` shows the pattern for a mounted personal settings section.
+- `lib/Service/AuditService.php:200` `recentlyAccessed()` calls `findRecentReadsByActor()`; the audit rows carry an object id and a name.
+- `src/manifest.json:294` is the `recent-activity-feed` widget on `/api/v1/audit/me`.
+
+## Goals / Non-Goals
+
+**Goals**
+- Preferences that the server already stores take effect.
+- A user sees the secrets they used last, not a log of events.
+
+**Non-Goals**
+- A last-used timestamp on the secret row; that belongs to `vault-favourites-tags-and-last-used`.
+- Per-folder defaults.
+
+## Decisions
+
+### D1: Mount a section, not the old view
+
+The old `DashboardSettingsView` mixes unrelated keys. A small `DefaultsSection.vue` next to `SessionTimeoutSection.vue` edits only the two keys and reuses the store; the dead view is deleted.
+
+### D2: Distinct secrets from the audit query
+
+The widget endpoint asks `recentlyAccessed()` for more rows than it shows, groups by secret id and drops ids the user no longer holds, so a secret opened twice appears once.
diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/proposal.md b/openspec/changes/vault-defaults-and-recently-used-widget/proposal.md
new file mode 100644
index 000000000..335440d42
--- /dev/null
+++ b/openspec/changes/vault-defaults-and-recently-used-widget/proposal.md
@@ -0,0 +1,52 @@
+---
+kind: code
+---
+
+# Default item type and view, and a recently used list on the dashboard
+
+## Why
+
+The backend keeps two per-user preferences, `default_secret_type` and `default_view` (`lib/Service/SettingsService.php:98-99`), but the only form that edits `default_view` is `src/views/DashboardSettingsView.vue`, which no route mounts, and neither the create dialog nor the list reads them (issue #208 is recorded on the matrix row). On the dashboard, `recent-activity-feed` lists the last five audit events of any kind (`src/manifest.json:294`), while `AuditService::recentlyAccessed()` (`lib/Service/AuditService.php:200`) is a finished query with no caller. Both rows are in the vault area, the core area, and both are wiring of finished backend parts, so one change fixes the two.
+
+The rows share one screen or service, so they are one change.
+
+### Matrix rows (`keepiq` `openspec/parity/capabilities.json`)
+
+| row | capability | Today |
+|---|---|---|
+| `vault-20` | Choose a default item type and default view for new items. | `no`: `no`: the server stores `default_secret_type` and `default_view` but no reached screen edits them and nothing reads them |
+| `vault-21` | See the secrets you used most recently on the dashboard. | `partial`: `partial`: the dashboard shows the last five audit events of any kind; the recently-accessed query has no caller |
+
+### Demand
+
+- `vault-20`: no demand row.
+- `vault-21`: no demand row.
+
+### Competitors rated yes
+
+- `vault-20`: no competitor rated yes.
+- `vault-21`: no competitor rated yes.
+
+## What Changes
+
+- Mount the preferences form in personal settings so a user can pick a default item type and a default list view.
+- Preselect the saved default type in the create dialog, and open the secret list in the saved view.
+- Add a Recently used widget to the dashboard, fed by `recentlyAccessed()`, one row per secret (not per event), where a row opens the secret.
+
+## Capabilities
+
+### New Capabilities
+
+- `vault-defaults`
+- `vault-recently-used`
+
+### Modified Capabilities
+
+- None in delta form.
+
+## Impact
+
+- **Frontend**: a preferences section in personal settings, `SecretCreateDialog.vue`, `SecretList.vue`, a new dashboard widget entry in `src/manifest.json`.
+- **Backend**: one route that returns recently read secrets joined to the caller's secrets, using `AuditService::recentlyAccessed()`.
+- **Database**: none.
+- **l10n**: new strings in every shipped locale.
diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md b/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md
new file mode 100644
index 000000000..3a53a93e9
--- /dev/null
+++ b/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: Default item type and view
+
+The system MUST let a user choose a default item type and a default list view in personal settings and MUST persist both through the existing preferences endpoint. The create dialog MUST preselect the saved type, and the secret list MUST open in the saved view. A saved type that no longer exists MUST fall back to `login`.
+
+#### Scenario: A user sets SSH Key as the default type
+
+- **GIVEN** a signed-in user on personal settings
+- **WHEN** the user picks SSH Key as the default item type and saves, then clicks New secret
+- **THEN** the create dialog opens with SSH Key selected
+
+#### Scenario: The list opens in the saved view
+
+- **GIVEN** a user who saved the grid view
+- **WHEN** the user opens /secrets in a new session
+- **THEN** the list renders in the grid view
+
+#### Scenario: A deleted type falls back
+
+- **GIVEN** a user whose saved default type was deleted by an administrator
+- **WHEN** the user clicks New secret
+- **THEN** the dialog preselects Login and shows no error
diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md b/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md
new file mode 100644
index 000000000..879d0f564
--- /dev/null
+++ b/openspec/changes/vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: Recently used on the dashboard
+
+The dashboard MUST show a Recently used widget that lists the signed-in user's most recently read secrets, newest first, at most five, one row per secret with its name and a relative time. A row MUST open that secret. The widget MUST list only secrets the user still holds and MUST show an empty state when there are none.
+
+#### Scenario: A user sees what they opened last
+
+- **GIVEN** a user who opened three different secrets and one of them twice
+- **WHEN** the user opens the dashboard
+- **THEN** the widget lists three rows, the twice-opened secret once, newest first
+
+#### Scenario: A row opens the secret
+
+- **GIVEN** the Recently used widget with rows
+- **WHEN** the user clicks a row
+- **THEN** the secret list opens with that secret selected in the sidebar
+
+#### Scenario: A deleted secret is not listed
+
+- **GIVEN** a user who read a secret that has since been deleted
+- **WHEN** the user opens the dashboard
+- **THEN** the widget does not list it
diff --git a/openspec/changes/vault-defaults-and-recently-used-widget/tasks.md b/openspec/changes/vault-defaults-and-recently-used-widget/tasks.md
new file mode 100644
index 000000000..eabe6a7aa
--- /dev/null
+++ b/openspec/changes/vault-defaults-and-recently-used-widget/tasks.md
@@ -0,0 +1,17 @@
+# Tasks: default item type and view, and a recently used list on the dashboard
+
+## 1. Defaults
+
+- [ ] 1.1 Add `DefaultsSection.vue` to personal settings, delete `DashboardSettingsView.vue`, and save through the settings store. Verify: vitest on the section, and a Playwright flow save then reload.
+- [ ] 1.2 Read `default_secret_type` in `SecretCreateDialog.vue` and `default_view` in `SecretList.vue`, with the fallback to `login`. Verify: vitest for the saved and the missing type.
+
+## 2. Recently used
+
+- [ ] 2.1 Add `GET /api/v1/secrets/recent` returning distinct secrets from `recentlyAccessed()` filtered to the caller's live secrets. Verify: PHPUnit for distinctness, ordering and a deleted secret; hydra route-auth and no-admin-idor gates.
+- [ ] 2.2 Add the widget to `src/manifest.json` with a row route to the secret. Verify: Playwright flow open two secrets, open the dashboard, click a row.
+
+## 3. Close out
+
+- [ ] 3.1 Add strings to every shipped locale. Verify: `npm run test:l10n`.
+- [ ] 3.2 Set rows `vault-20` and `vault-21` to built, close the defect on `vault-20`, archive the change. Verify: parity_verify --strict.
+
diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json
index f41d2b2b0..9959d0388 100644
--- a/openspec/parity/capabilities.json
+++ b/openspec/parity/capabilities.json
@@ -539,7 +539,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/vault-trash-and-archive on 2026-09-27. Before: grep -rniF trash|deletedAt lib src: no soft-delete column or restore path. SecretDeleteConfirmDialog.vue:30 and BulkDeleteDialog.vue:35 both say there is no trash and the delete cannot be undone",
"owner": "ConductionNL/keepiq",
- "note": "Deliberate design choice, stated in the delete-confirmation copy itself, not a gap that was missed. Decision 2026-09-27: specified as vault-trash-and-archive; the bulk-actions spec names a trash a separate change (openspec/specs/bulk-actions/spec.md:94), so it is not a recorded non-goal."
+ "note": "Deliberate design choice, stated in the delete-confirmation copy itself, not a gap that was missed. Decision 2026-09-27: specified as vault-trash-and-archive; the bulk-actions spec names a trash a separate change (openspec/specs/bulk-actions/spec.md:94), so it is not a recorded non-goal.",
+ "change": "openspec/changes/vault-trash-and-archive"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -696,7 +697,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/vault-favourites-tags-and-last-used on 2026-09-27. Before: grep -rniF favourite|favorite lib src: no hits anywhere in the codebase",
"owner": "ConductionNL/keepiq",
- "note": "No favourites concept exists on the secret entity, store, or UI."
+ "note": "No favourites concept exists on the secret entity, store, or UI.",
+ "change": "openspec/changes/vault-favourites-tags-and-last-used"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -725,7 +727,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/vault-favourites-tags-and-last-used on 2026-09-27. Before: grep -rniF '\"tags\"' lib src: no hits; Secret entity (lib/Db) has no tags column and no additionalFields convention is treated as tags",
"owner": "ConductionNL/keepiq",
- "note": "No tagging system. A user could ad-hoc name an additional field 'tag' but there is no dedicated tag storage, chip UI, or filter-by-tag."
+ "note": "No tagging system. A user could ad-hoc name an additional field 'tag' but there is no dedicated tag storage, chip UI, or filter-by-tag.",
+ "change": "openspec/changes/vault-favourites-tags-and-last-used"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -755,7 +758,8 @@
"evidence": "src/dialogs/SecretCreateDialog.vue:122 and src/dialogs/SecretEditDialog.vue:125 AdditionalFieldsEditor -> src/store/modules/secret.js:377 encrypts additionalFields blob -> POST /api/v1/secrets (appinfo/routes.php:89); src/components/AdditionalFieldsEditor.vue:50 value is a plain NcTextField; src/components/SecretDetailSidebar.vue:570-577 renders every value as plain
text",
"owner": "ConductionNL/keepiq",
"reachedOn": "SecretList page (/secrets) -> New secret / Edit secret dialog -> Additional fields section",
- "note": "Owners can add, rename and remove named custom fields on create and edit, stored encrypted. There is no hidden or concealed field kind: values are typed in plain text fields and shown unmasked on the detail sidebar."
+ "note": "Owners can add, rename and remove named custom fields on create and edit, stored encrypted. There is no hidden or concealed field kind: values are typed in plain text fields and shown unmasked on the detail sidebar.",
+ "change": "openspec/changes/vault-custom-field-kinds-and-ssh-key"
},
"rowSource": "own",
"provider": "keepiq",
@@ -851,7 +855,8 @@
"evidence": "lib/Repair/SeedSecretTypes.php:64 seeds 'ssh_key' => 'SSH Key'; src/dialogs/SecretCreateDialog.vue:95-108 offers only one value field for it; src/cxf/cxf.js:205,355 maps CXF ssh-key items; grep -rni ssh src --include=*.vue: no SSH-specific form, public-key field or key-pair generator",
"owner": "ConductionNL/keepiq",
"reachedOn": "SecretList page (/secrets) -> New secret dialog -> Type: SSH Key",
- "note": "SSH Key exists as its own type, but it stores a single secret value like any other type: there are no separate private key, public key and fingerprint fields and no key-pair generation. A user can put the public key in an additional field by hand."
+ "note": "SSH Key exists as its own type, but it stores a single secret value like any other type: there are no separate private key, public key and fingerprint fields and no key-pair generation. A user can put the public key in an additional field by hand.",
+ "change": "openspec/changes/vault-custom-field-kinds-and-ssh-key"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -921,7 +926,8 @@
"evidence": "Specified in openspec/changes/vault-login-totp-codes on 2026-09-27 for the missing half: a code from a seed kept on a login; the separate authenticator item is built. Before: lib/Repair/SeedSecretTypes.php:67 seeds 'totp' => 'Authenticator (TOTP)'; src/components/SecretDetailSidebar.vue:214-228 renders TotpDisplay only when the type is totp (:1243 isTotp); src/totp/totp.js:7 parses otpauth URI or base32; src/import/parsers/bitwarden.js:70-71 puts a login's seed into additionalFields.totp, which the sidebar shows as plain text, not a code",
"owner": "ConductionNL/keepiq",
"reachedOn": "SecretList page (/secrets) -> detail sidebar of an Authenticator (TOTP) item -> One-time code row",
- "note": "A live one-time code works, but only on a separate Authenticator item whose value is the seed. A seed kept on a login (for example imported from Bitwarden into additionalFields.totp) is shown as raw text and never becomes a code."
+ "note": "A live one-time code works, but only on a separate Authenticator item whose value is the seed. A seed kept on a login (for example imported from Bitwarden into additionalFields.totp) is shown as raw text and never becomes a code.",
+ "change": "openspec/changes/vault-login-totp-codes"
},
"rowSource": "own",
"provider": "keepiq",
@@ -1056,7 +1062,8 @@
"issue": "#208",
"needsLiveCheck": false
}
- ]
+ ],
+ "change": "openspec/changes/vault-defaults-and-recently-used-widget"
},
"rowSource": "own",
"provider": "keepiq",
@@ -1088,7 +1095,8 @@
"evidence": "src/manifest.json:294 recent-activity-feed widget -> GET /api/v1/audit/me (appinfo/routes.php:378) listing the user's own audit events with item names; lib/Service/AuditService.php:200 recentlyAccessed (secret.read only) has no caller (grep recentlyAccessed lib src: definition only)",
"owner": "ConductionNL/keepiq",
"reachedOn": "Dashboard page (/) -> Recent activity widget",
- "note": "The dashboard shows your last five audit events of any kind, which includes secret reads by name, but it is an activity log rather than a list of recently used secrets and rows do not open the secret. The dedicated recently-accessed query exists but nothing calls it."
+ "note": "The dashboard shows your last five audit events of any kind, which includes secret reads by name, but it is an activity log rather than a list of recently used secrets and rows do not open the secret. The dedicated recently-accessed query exists but nothing calls it.",
+ "change": "openspec/changes/vault-defaults-and-recently-used-widget"
},
"rowSource": "own",
"provider": "keepiq",
@@ -1119,7 +1127,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/vault-item-clone-preview-and-print on 2026-09-27. Before: grep -rni 'clone\\|duplicate\\|make a copy' src --include=*.vue: only import-wizard duplicate handling; no clone action in SecretDetailSidebar.vue or SecretList.vue menus",
"owner": "ConductionNL/keepiq",
- "note": "There is no clone or duplicate action on a secret."
+ "note": "There is no clone or duplicate action on a secret.",
+ "change": "openspec/changes/vault-item-clone-preview-and-print"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -1181,7 +1190,8 @@
"evidence": "Specified in openspec/changes/vault-favourites-tags-and-last-used on 2026-09-27 for the missing half: sorting by date last used; name, date added and date changed are built. Before: src/views/SecretList.vue:787-792 sortOptions name, created_at, updated_at -> src/store/modules/secret.js:140 sort param -> lib/Db/SecretMapper.php:48 SORTABLE_COLUMNS",
"owner": "ConductionNL/keepiq",
"reachedOn": "SecretList page (/secrets), Filter and sort menu",
- "note": "Sort by name, date created and date updated works from the list's filter menu. There is no last-used timestamp on a secret, so sorting by last use is missing."
+ "note": "Sort by name, date created and date updated works from the list's filter menu. There is no last-used timestamp on a secret, so sorting by last use is missing.",
+ "change": "openspec/changes/vault-favourites-tags-and-last-used"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -1213,7 +1223,8 @@
"evidence": "Specified in openspec/changes/vault-duplicate-finder on 2026-09-27. Before: git grep -i duplicate src lib: only the import wizard dedup (src/dialogs/ImportWizardDialog.vue:7 'duplicates' step); no report or merge over the stored vault",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "Duplicates are caught only while importing (portability-05); nothing finds or merges duplicates already stored."
+ "note": "Duplicates are caught only while importing (portability-05); nothing finds or merges duplicates already stored.",
+ "change": "openspec/changes/vault-duplicate-finder"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -1245,7 +1256,8 @@
"evidence": "Specified in openspec/changes/vault-item-clone-preview-and-print on 2026-09-27. Before: src/components/AttachmentPanel.vue:42-44 offers only 'Download attachment' (store.download); appinfo/routes.php:350 attachment#download returns the ciphertext blob; no preview component",
"owner": "ConductionNL/keepiq",
"reachedOn": "SecretDetailSidebar attachment panel (download only)",
- "note": "Attachments decrypt to a download; there is no in-app preview."
+ "note": "Attachments decrypt to a download; there is no in-app preview.",
+ "change": "openspec/changes/vault-item-clone-preview-and-print"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -1277,7 +1289,8 @@
"evidence": "Specified in openspec/changes/vault-trash-and-archive on 2026-09-27. Before: git grep -i archiv src lib appinfo: no match",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "No archive state; an item is either live or deleted."
+ "note": "No archive state; an item is either live or deleted.",
+ "change": "openspec/changes/vault-trash-and-archive"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -1309,7 +1322,8 @@
"evidence": "Specified in openspec/changes/vault-website-addresses on 2026-09-27. Before: lib/Db/Secret.php:252 a single 'url' string field; git grep -i 'additionalUrl|urls' src lib: no multi-URL model; browser-extension/src/lib/match.js matches on that one url",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "A secret carries one website address."
+ "note": "A secret carries one website address.",
+ "change": "openspec/changes/vault-website-addresses"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -1371,7 +1385,8 @@
"evidence": "Specified in openspec/changes/vault-item-clone-preview-and-print on 2026-09-27. Before: searched 'qrcode', 'QRCode', 'print(' in src/ lib/: only src/dialogs/ComplianceSnapshotDialog.vue:201 prints the compliance report",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "A secret cannot be printed or shown as a QR code."
+ "note": "A secret cannot be printed or shown as a QR code.",
+ "change": "openspec/changes/vault-item-clone-preview-and-print"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -1401,7 +1416,8 @@
"evidence": "Specified in openspec/changes/vault-website-addresses on 2026-09-27 for the missing half: a preview image of the site; site icons are built. Before: lib/Controller/DashboardController.php:101 favicon_service_url admin setting (off by default) -> src/utils/favicon.js:6 -> src/components/SecretListItem.vue:13 favicon in the list; searched 'screenshot', 'preview' for sites: none",
"owner": "ConductionNL/keepiq",
"reachedOn": "Secret list, favicon beside each login once an admin sets a favicon service",
- "note": "Site icons show when an admin configures a favicon service; there is no screenshot preview."
+ "note": "Site icons show when an admin configures a favicon service; there is no screenshot preview.",
+ "change": "openspec/changes/vault-website-addresses"
},
"rowSource": "competitor",
"origin": "changelog",
@@ -1607,7 +1623,8 @@
"issue": null,
"needsLiveCheck": true
}
- ]
+ ],
+ "change": "openspec/changes/crypto-session-timeout-and-inactivity-lock"
},
"rowSource": "own",
"provider": "keepiq",
@@ -1653,7 +1670,8 @@
"issue": null,
"needsLiveCheck": false
}
- ]
+ ],
+ "change": "openspec/changes/crypto-session-timeout-and-inactivity-lock"
},
"rowSource": "own",
"provider": "keepiq",
@@ -1725,7 +1743,8 @@
"evidence": "Specified in openspec/changes/clients-extension-unlock-lock-and-accounts on 2026-09-27 for the missing half: fingerprint or face unlock in the browser extension; the web app unlock with a platform passkey is built. Before: src/components/PasskeyManager.vue:25 offers Touch ID, Windows Hello or a security key; src/store/modules/passkey.js:221 userVerification 'preferred' with the PRF extension; browser-extension/src/popup/popup.js unlocks with the master password only (grep -rni 'biometric\\|webauthn' browser-extension/src/popup: no hits)",
"owner": "ConductionNL/keepiq",
"reachedOn": "Lock page (/lock) -> Unlock with passkey, using a platform authenticator",
- "note": "Fingerprint or face unlock works in the web app only by enrolling a platform passkey (Touch ID, Windows Hello) whose browser supports PRF. The browser extension and CLI have no biometric unlock."
+ "note": "Fingerprint or face unlock works in the web app only by enrolling a platform passkey (Touch ID, Windows Hello) whose browser supports PRF. The browser extension and CLI have no biometric unlock.",
+ "change": "openspec/changes/clients-extension-unlock-lock-and-accounts"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -1784,7 +1803,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/crypto-organisation-account-recovery on 2026-09-27. Before: src/components/settings/AdminSuiteSection.vue:21,180 admin can only force-revoke a suite so the user starts a new empty vault; grep -rni 'escrow\\|recovery key\\|account recovery' lib: only emergency-access envelopes escrowed to a contact's certificate, never to an admin",
"owner": "ConductionNL/keepiq",
- "note": "By zero-knowledge design an administrator cannot restore access to a user's secrets. The admin can force-revoke the locked suite so the user can set up a fresh vault, but the old secrets stay unreadable unless the user has an emergency contact or a backup file."
+ "note": "By zero-knowledge design an administrator cannot restore access to a user's secrets. The admin can force-revoke the locked suite so the user can set up a fresh vault, but the old secrets stay unreadable unless the user has an emergency contact or a backup file.",
+ "change": "openspec/changes/crypto-organisation-account-recovery"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -1843,7 +1863,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/crypto-vault-encryption-details on 2026-09-27. Before: src/crypto/rsa.js:8 RSA_KEY_BITS = 4096 and src/crypto/aes.js:15 PBKDF2 600000 are code constants only; grep -rni 'algorithm\\|key size\\|RSA-OAEP' src --include=*.vue: only template comments; src/views/CertificateInventoryView.vue:116-137 shows vault certificate owner, subject and expiry, no algorithm or key size",
"owner": "ConductionNL/keepiq",
- "note": "No screen tells the user which algorithms or key sizes protect the vault. The certificates page shows the vault certificate's subject and expiry, but not RSA-4096, RSA-OAEP, AES-256-GCM or the key derivation settings."
+ "note": "No screen tells the user which algorithms or key sizes protect the vault. The certificates page shows the vault certificate's subject and expiry, but not RSA-4096, RSA-OAEP, AES-256-GCM or the key derivation settings.",
+ "change": "openspec/changes/crypto-vault-encryption-details"
},
"rowSource": "own",
"provider": "keepiq",
@@ -2071,7 +2092,8 @@
"evidence": "Specified in openspec/changes/crypto-item-reprompt on 2026-09-27. Before: git grep -i 'reprompt|re-prompt' src browser-extension: no match; master-password re-entry exists only before a plaintext export (src/dialogs/ExportDialog.vue:130 via src/crypto/reauth.js verifyMasterPassword)",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "Re-entry of the master password guards export only, not viewing or filling a chosen item."
+ "note": "Re-entry of the master password guards export only, not viewing or filling a chosen item.",
+ "change": "openspec/changes/crypto-item-reprompt"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -2197,7 +2219,8 @@
"evidence": "Specified in openspec/changes/crypto-new-device-approval on 2026-09-27. Before: searched 'device approval', 'auth request', 'approveLogin', 'LoginRequest' in lib/ src/ appinfo/routes.php: no match; the vault unlocks with the user's own passphrase on each device",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "Sign-in is Nextcloud's; a new device unlocks the vault with the user's passphrase, and there is no approve-from-another-device or admin approval flow."
+ "note": "Sign-in is Nextcloud's; a new device unlocks the vault with the user's passphrase, and there is no approve-from-another-device or admin approval flow.",
+ "change": "openspec/changes/crypto-new-device-approval"
},
"rowSource": "competitor",
"origin": "changelog",
@@ -2266,7 +2289,8 @@
"issue": null,
"needsLiveCheck": false
}
- ]
+ ],
+ "change": "openspec/changes/sharing-group-share-entry-point"
},
"rowSource": "own",
"provider": "keepiq",
@@ -2554,8 +2578,8 @@
"hashicorp-vault": "no",
"nextcloud-passwords": "partial",
"built": {
- "state": "building",
- "evidence": "src/components/share/DelegationManager.vue (mounted for owners in SecretDetailSidebar.vue:680) wires a reclaim button -> store.reclaimDelegation -> POST /api/v1/secrets/{id}/delegations/reclaim (appinfo/routes.php:155). But the creation half, useDelegationStore.createDelegation (delegation.js:105, POST .../delegations), has no caller anywhere in src/ -- DelegationManager only lists and reclaims, it never offers to create one.",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: the reclaim half is built and the creation half is a store and dialog gap. Before: src/components/share/DelegationManager.vue (mounted for owners in SecretDetailSidebar.vue:680) wires a reclaim button -> store.reclaimDelegation -> POST /api/v1/secrets/{id}/delegations/reclaim (appinfo/routes.php:155). But the creation half, useDelegationStore.createDelegation (delegation.js:105, POST .../delegations), has no caller anywhere in src/ -- DelegationManager only lists and reclaims, it never offers to create one.",
"owner": "ConductionNL/keepiq",
"reachedOn": "SecretDetailSidebar -> Delegations section (reclaim only)",
"note": "A temporary delegation to a colleague can never be created through the UI, so the reclaim button that IS wired has nothing to act on in normal use: createDelegation (src/store/modules/delegation.js:105) still has no caller in src/. Do not confuse with AdminHandoverPanel.vue (mounted in SecretDetailSidebar.vue:700), a separate vault-admin takeover that has worked end to end since f13ad8e6 (route, controller call and panel, closing #184); it is not an owner-to-colleague temporary handover and does not close this gap.",
@@ -2627,8 +2651,8 @@
"hashicorp-vault": "partial",
"nextcloud-passwords": "no",
"built": {
- "state": "none",
- "evidence": "No feature lets a user who lacks any access to a secret ask its owner for access. The nearest built feature, src/components/share/ShareRequestForm.vue (mounted in SecretDetailSidebar.vue:687 for isRecipient && !isOwner), requires the requester to ALREADY hold a shared copy and asks the owner to share with a DIFFERENT third party (openspec/specs/user-sharing/spec.md#requirement-share-request-recipient-initiated), which is a distinct capability from the one this row describes.",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: No feature lets a user who lacks any access to a secret ask its owner for access. The nearest built feature, src/components/share/ShareRequestForm.vue (mounted in SecretDetailSidebar.vue:687 for isRecipient && !isOwner), requires the requester to ALREADY hold a shared copy and asks the owner to share with a DIFFERENT third party (openspec/specs/user-sharing/spec.md#requirement-share-request-recipient-initiated), which is a distinct capability from the one this row describes.",
"owner": "ConductionNL/keepiq",
"note": "Rename suggestion: the built and reachable ShareRequestForm feature is 'ask the owner to share this secret with someone else', not 'ask the owner for access to it' for yourself. There is no discovery mechanism for a secret you cannot already see, so a self-access request has nothing to attach to."
},
@@ -2764,7 +2788,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/sharing-federated-recipients on 2026-09-27. Before: grep -rn 'federat\\|remote share\\|outside.*organi[sz]ation' lib/Controller lib/Service: no sharing-related hits (the one federated-cloud-ID hit in EncryptionSuiteProvisioningService.php:331 is only used to name a certificate's common name, not to share with an external account). All sharing paths (user-to-user, team folder, link, send) target a Nextcloud user ID, group ID, or an anonymous public link on this instance.",
"owner": "ConductionNL/keepiq",
- "note": "Sharing with an account on a different Nextcloud instance is not supported; a password-protected public link (sharing-14) is the closest substitute."
+ "note": "Sharing with an account on a different Nextcloud instance is not supported; a password-protected public link (sharing-14) is the closest substitute.",
+ "change": "openspec/changes/sharing-federated-recipients"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -2792,7 +2817,8 @@
"built": {
"state": "specified",
"evidence": "Specified in openspec/changes/sharing-team-folder-manager-role on 2026-09-27. Before: The only role-like concept in the codebase is a team folder membership grade of read or write (folder-permission-grades spec, sharing-09); there is no manager/viewer role vocabulary and no 'collection' concept beyond team folders. grep -rln 'manager\\|viewer' src/store/modules lib/Controller for role-purposed hits: none.",
- "owner": "ConductionNL/keepiq"
+ "owner": "ConductionNL/keepiq",
+ "change": "openspec/changes/sharing-team-folder-manager-role"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -2942,8 +2968,8 @@
"hashicorp-vault": "no",
"nextcloud-passwords": "no",
"built": {
- "state": "none",
- "evidence": "grep -rln 'Comment' lib/Controller/*.php src/store/modules/*.js: no hits. No comment/annotation feature exists anywhere in the app.",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -rln 'Comment' lib/Controller/*.php src/store/modules/*.js: no hits. No comment/annotation feature exists anywhere in the app.",
"owner": "ConductionNL/keepiq"
},
"rowSource": "competitor",
@@ -2973,7 +2999,8 @@
"evidence": "Specified in openspec/changes/sharing-use-only-and-expiring-shares on 2026-09-27. Before: git grep -i 'hidePassword|useOnly|can_view' src lib: no match; team folder roles are read-only or edit (sharing-09), both reveal the value",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "Every recipient who can use a secret can also reveal it."
+ "note": "Every recipient who can use a secret can also reveal it.",
+ "change": "openspec/changes/sharing-use-only-and-expiring-shares"
},
"rowSource": "competitor",
"origin": "tender",
@@ -3005,7 +3032,8 @@
"evidence": "Specified in openspec/changes/sharing-use-only-and-expiring-shares on 2026-09-27. Before: lib/Controller/ShareController.php: no expiry field on a user share (git grep -i expir lib/Controller/ShareController.php: no match); time-bound access exists only for public links (sharing-14) and ownership handover (sharing-11)",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "A share to a colleague lasts until it is revoked by hand."
+ "note": "A share to a colleague lasts until it is revoked by hand.",
+ "change": "openspec/changes/sharing-use-only-and-expiring-shares"
},
"rowSource": "competitor",
"origin": "changelog",
@@ -3856,8 +3884,8 @@
"hashicorp-vault": "partial",
"nextcloud-passwords": "partial",
"built": {
- "state": "building",
- "evidence": "lib/Controller/ApplicationController.php:338 destroy() -> lib/Service/ApplicationService.php:201-221 delete(): removes the application row and cascades MachineLease + lease-policy rows only. The method's own docblock (line 216-218) reads: 'The full cascade (Secrets + EncryptionSuite + SecretRequests) lands with the dedicated build cycle once those services accept owner_type=application.' SecretService::deleteByApplication() exists but is only called from ApplicationSecretRequestService and SecretPlaceholderCleaner, never from ApplicationService::delete()",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: ApplicationService::delete cascades leases and lease policies but not secrets. Before: lib/Controller/ApplicationController.php:338 destroy() -> lib/Service/ApplicationService.php:201-221 delete(): removes the application row and cascades MachineLease + lease-policy rows only. The method's own docblock (line 216-218) reads: 'The full cascade (Secrets + EncryptionSuite + SecretRequests) lands with the dedicated build cycle once those services accept owner_type=application.' SecretService::deleteByApplication() exists but is only called from ApplicationSecretRequestService and SecretPlaceholderCleaner, never from ApplicationService::delete()",
"owner": "ConductionNL/keepiq",
"reachedOn": "Application detail page -> admin delete action -> DELETE /apps/keepiq/api/v1/applications/{id}",
"note": "The application-mgmt spec requires that deleting an application permanently deletes the application, its EncryptionSuite, and all its attributed secrets. The shipped delete only removes the application row and its lease rows; secrets and the EncryptionSuite are left behind, contradicting the spec's own scenario.",
@@ -3994,7 +4022,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/apps-kubernetes-injection on 2026-09-27. Before: grep -rli 'kubernetes|k8s|helm' lib src cli browser-extension: no hits",
"note": "No Kubernetes secret injection (operator, CSI driver, sidecar) exists; keepiq's machine surface is a plain HTTP+JWT API a cluster could call itself, but nothing ships to do that integration.",
- "owner": "ConductionNL/keepiq"
+ "owner": "ConductionNL/keepiq",
+ "change": "openspec/changes/apps-kubernetes-injection"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -4023,7 +4052,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/apps-client-libraries-and-ci on 2026-09-27. Before: grep -rli 'github.action|gitlab.ci|.gitlab-ci' lib src cli browser-extension: no hits (only this repo's own CI workflows use GitHub Actions, which is unrelated to a keepiq integration product)",
"note": "No ready-made GitHub Actions or GitLab CI step exists; a pipeline would have to install and script the keepiq CLI itself.",
- "owner": "ConductionNL/keepiq"
+ "owner": "ConductionNL/keepiq",
+ "change": "openspec/changes/apps-client-libraries-and-ci"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -4049,8 +4079,8 @@
"hashicorp-vault": "yes",
"nextcloud-passwords": "no",
"built": {
- "state": "none",
- "evidence": "grep -rli 'dynamic.*database|database.*credential|db.*secret.*engine' lib src: no hits; keepiq only stores and serves secrets a human or app already supplied, it does not generate short-lived database credentials itself",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -rli 'dynamic.*database|database.*credential|db.*secret.*engine' lib src: no hits; keepiq only stores and serves secrets a human or app already supplied, it does not generate short-lived database credentials itself",
"note": "No dynamic secrets engine (database, cloud IAM, etc.) exists, unlike Vault/OpenBao.",
"owner": "ConductionNL/keepiq"
},
@@ -4081,7 +4111,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/apps-secret-sync-and-rotation-runner on 2026-09-27. Before: Matrix corrected 2026-09-27 in the openspec pass: the rating is no and nothing rotates a password at the destination service; the evidence below is the reminder and manual mark-rotated flow only. Before: openspec/specs/rotation-expiry-policies/spec.md:9 describes 'expiry, an admin-default and user-override max-age policy, approaching/overdue reminders ... a proven mark-rotated flow'; this is a reminder + manual mark-rotated flow (lib/Controller/RotationController.php, src/store/modules/rotation.js), not automatic rotation of the underlying credential at the target service",
"owner": "ConductionNL/keepiq",
- "note": "keepiq flags stale/expiring secrets and lets a user mark one rotated, but it never rotates a password at the destination service itself the way Vault/1Password-style rotation connectors do."
+ "note": "keepiq flags stale/expiring secrets and lets a user mark one rotated, but it never rotates a password at the destination service itself the way Vault/1Password-style rotation connectors do.",
+ "change": "openspec/changes/apps-secret-sync-and-rotation-runner"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -4111,7 +4142,8 @@
"evidence": "Specified in openspec/changes/apps-client-libraries-and-ci on 2026-09-27 for the missing half: client libraries for common languages; the Go command-line client is built. Before: cli/ is a single Go CLI (stdlib only) covering human read-only + CI fetch; no client SDKs for other languages exist (no python/node/java package in the repo)",
"reachedOn": "keepiq CLI (Go binary, cross-compiled)",
"note": "There is one cross-compiled CLI binary, not per-language client libraries; a Python or Node consumer would call the documented HTTP+JWT API directly with no official SDK.",
- "owner": "ConductionNL/keepiq"
+ "owner": "ConductionNL/keepiq",
+ "change": "openspec/changes/apps-client-libraries-and-ci"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -4140,7 +4172,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/apps-terraform-provider on 2026-09-27. Before: grep -rli 'terraform' . --include=*.md --include=*.php --include=*.go: no hits",
"note": "No Terraform provider exists for managing keepiq secrets/applications as code.",
- "owner": "ConductionNL/keepiq"
+ "owner": "ConductionNL/keepiq",
+ "change": "openspec/changes/apps-terraform-provider"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -4198,8 +4231,8 @@
"hashicorp-vault": "yes",
"nextcloud-passwords": "no",
"built": {
- "state": "none",
- "evidence": "grep -n 'class.*Controller' lib/Controller | grep -i 'encrypt|transit|crypto': only EncryptionSuiteController (manages the user's own suite/keypair), no generic encrypt/decrypt-as-a-service endpoint that takes arbitrary application data",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -n 'class.*Controller' lib/Controller | grep -i 'encrypt|transit|crypto': only EncryptionSuiteController (manages the user's own suite/keypair), no generic encrypt/decrypt-as-a-service endpoint that takes arbitrary application data",
"note": "keepiq encrypts secret VALUES it stores; it has no Vault-Transit-style API where an application sends it arbitrary data to encrypt/decrypt without storing it.",
"owner": "ConductionNL/keepiq"
},
@@ -4230,7 +4263,8 @@
"evidence": "Specified in openspec/changes/apps-secret-sync-and-rotation-runner on 2026-09-27. Before: git grep -i 'aws|azure|key vault' lib src: no match; no outbound secret sync",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "No push of secrets to cloud secret stores."
+ "note": "No push of secrets to cloud secret stores.",
+ "change": "openspec/changes/apps-secret-sync-and-rotation-runner"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -4288,8 +4322,8 @@
"hashicorp-vault": "partial",
"nextcloud-passwords": "no",
"built": {
- "state": "building",
- "evidence": "Intermediate: lib/BackgroundJob/RenewIntermediateCertificate.php:68 (daily, appinfo/info.xml:111) -> CertificateAuthorityService.php:242 renewIntermediate -> resignAllActiveSuites. Root: CertificateAuthorityService.php:288 renewRoot only reachable through POST /api/v1/ca/renew-root (lib/Controller/CACertificateController.php:140), which no UI calls (routes-unmatched NO-REF); lib/BackgroundJob/CheckRootCertificateExpiry.php:68 only logs",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: the intermediate renews on a daily job and the root renews only by hand. Before: Intermediate: lib/BackgroundJob/RenewIntermediateCertificate.php:68 (daily, appinfo/info.xml:111) -> CertificateAuthorityService.php:242 renewIntermediate -> resignAllActiveSuites. Root: CertificateAuthorityService.php:288 renewRoot only reachable through POST /api/v1/ca/renew-root (lib/Controller/CACertificateController.php:140), which no UI calls (routes-unmatched NO-REF); lib/BackgroundJob/CheckRootCertificateExpiry.php:68 only logs",
"owner": "ConductionNL/keepiq",
"reachedOn": "machine: daily background job (intermediate only)",
"note": "The intermediate is renewed automatically, but because of an inverted date diff it is renewed every day rather than 30 days before expiry. The root is never renewed automatically and its expiry warning never fires.",
@@ -4443,9 +4477,9 @@
"nextcloud-passwords": "no",
"built": {
"state": "built",
- "evidence": "src/views/CertificateInventoryView.vue:292 'Renew…' -> src/store/modules/certificate.js:86 POST /api/v1/certificates/{id}/renewal-checklist -> lib/Service/CertificateLifecycleService.php:228 (externally issued checklist); suite rows: CertificateInventoryView.vue:308 -> certificate.js:108 POST /api/v1/certificates/suites/{id}/reissue -> CertificateLifecycleService.php:282",
+ "evidence": "src/views/CertificateInventoryView.vue:292 'Renew\u2026' -> src/store/modules/certificate.js:86 POST /api/v1/certificates/{id}/renewal-checklist -> lib/Service/CertificateLifecycleService.php:228 (externally issued checklist); suite rows: CertificateInventoryView.vue:308 -> certificate.js:108 POST /api/v1/certificates/suites/{id}/reissue -> CertificateLifecycleService.php:282",
"owner": "ConductionNL/keepiq",
- "reachedOn": "Certificates page -> Renew… on a stored certificate, Re-issue on a suite certificate",
+ "reachedOn": "Certificates page -> Renew\u2026 on a stored certificate, Re-issue on a suite certificate",
"note": "Stored certificates get a fixed checklist for externally issued certificates; certificates Keepiq issued itself (suite certificates) can be re-issued with one click. There is no branch for a stored certificate that Keepiq's CA issued, because Keepiq does not issue those."
},
"rowSource": "own",
@@ -4473,8 +4507,8 @@
"hashicorp-vault": "yes",
"nextcloud-passwords": "no",
"built": {
- "state": "building",
- "evidence": "lib/Service/CertificateAuthorityService.php:223 signCsr and lib/Service/CertificateIssuanceService.php:211 exist but no controller calls signCsr (grep '->signCsr(' lib: only the service wrapper); the only CSR the CA signs is an application's registration CSR (lib/Service/ApplicationSuiteProvisioner.php:67), downloadable on src/views/ApplicationDetail.vue:324 -> GET /api/v1/applications/{id}/certificate",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: lib/Service/CertificateAuthorityService.php:223 signCsr and lib/Service/CertificateIssuanceService.php:211 exist but no controller calls signCsr (grep '->signCsr(' lib: only the service wrapper); the only CSR the CA signs is an application's registration CSR (lib/Service/ApplicationSuiteProvisioner.php:67), downloadable on src/views/ApplicationDetail.vue:324 -> GET /api/v1/applications/{id}/certificate",
"owner": "ConductionNL/keepiq",
"note": "There is no way to issue a certificate for an arbitrary service. The CA only signs keepiq's own suite certificates and the CSR an application submits when it registers with keepiq."
},
@@ -4533,8 +4567,8 @@
"hashicorp-vault": "partial",
"nextcloud-passwords": "no",
"built": {
- "state": "building",
- "evidence": "src/views/ApplicationRegisterView.vue:57 mounts src/dialogs/PrivateKeyDownloadDialog.vue when src/store/modules/application.js:136 finds data.private_key, but no PHP code returns a private_key (grep \"'private_key'\" lib: no hits); lib/Service/ApplicationLifecycleService.php:334 only provisions a suite from a supplied CSR",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Note: PrivateKeyDownloadDialog is mounted but no PHP code returns a private_key, so the dialog is dead UI. Before: src/views/ApplicationRegisterView.vue:57 mounts src/dialogs/PrivateKeyDownloadDialog.vue when src/store/modules/application.js:136 finds data.private_key, but no PHP code returns a private_key (grep \"'private_key'\" lib: no hits); lib/Service/ApplicationLifecycleService.php:334 only provisions a suite from a supplied CSR",
"owner": "ConductionNL/keepiq",
"note": "The one-time private key download is wired in the frontend but the backend never generates a key pair or returns a private key, so the dialog can never open. Applications must bring their own key via a CSR; one registered without a CSR gets no suite.",
"defects": [
@@ -4849,7 +4883,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/health-passphrase-generator on 2026-09-27. Before: grep -rli 'passphrase|diceware|wordlist' src lib browser-extension/src cli: hits are only export-backup passphrases and import parsers; KeyGeneratorService has no word mode",
"owner": "ConductionNL/keepiq",
- "note": "The generator only produces character strings (or regex-shaped ones); there is no word-based passphrase mode."
+ "note": "The generator only produces character strings (or regex-shaped ones); there is no word-based passphrase mode.",
+ "change": "openspec/changes/health-passphrase-generator"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -4875,8 +4910,8 @@
"hashicorp-vault": "partial",
"nextcloud-passwords": "no",
"built": {
- "state": "none",
- "evidence": "grep -rni 'username generat|email alias|simplelogin|anonaddy|forwarder' src lib browser-extension/src cli: no hits",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -rni 'username generat|email alias|simplelogin|anonaddy|forwarder' src lib browser-extension/src cli: no hits",
"owner": "ConductionNL/keepiq",
"note": "No username or email-alias generator anywhere in the app, extension or CLI."
},
@@ -4903,8 +4938,8 @@
"hashicorp-vault": "no",
"nextcloud-passwords": "no",
"built": {
- "state": "none",
- "evidence": "grep -rni 'breachedaccount|email.*breach|breach.*email' src lib: no hits; the only HIBP use is the Pwned Passwords range proxy (lib/Controller/BreachProxyController.php:57)",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, no demand. Before: grep -rni 'breachedaccount|email.*breach|breach.*email' src lib: no hits; the only HIBP use is the Pwned Passwords range proxy (lib/Controller/BreachProxyController.php:57)",
"owner": "ConductionNL/keepiq",
"note": "Breach checking covers password values only, on demand; there is no email or account breach monitoring and no alerting on new breaches."
},
@@ -4935,7 +4970,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/health-site-security-checks on 2026-09-27. Before: grep -rni '2fa.directory|twofactorauth|inactive.*2fa' src lib browser-extension/src: no hits",
"owner": "ConductionNL/keepiq",
- "note": "No inactive two-factor report; the health engine has no such category (src/health/engine.js:114)."
+ "note": "No inactive two-factor report; the health engine has no such category (src/health/engine.js:114).",
+ "change": "openspec/changes/health-site-security-checks"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -4963,7 +4999,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/health-site-security-checks on 2026-09-27. Before: src/health/engine.js flags only weak, reused, stale, compromised, breached; grep for http protocol checks in src and browser-extension/src: no hits",
"owner": "ConductionNL/keepiq",
- "note": "No insecure-URL (http) finding in the health report or the extension."
+ "note": "No insecure-URL (http) finding in the health report or the extension.",
+ "change": "openspec/changes/health-site-security-checks"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -5054,7 +5091,8 @@
"evidence": "Specified in openspec/changes/health-site-security-checks on 2026-09-27. Before: git grep -i 'passkey' src/health src/store/modules/health.js: no passkey-availability check",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "Health report has no passkey-available check."
+ "note": "Health report has no passkey-available check.",
+ "change": "openspec/changes/health-site-security-checks"
},
"rowSource": "competitor",
"origin": "changelog",
@@ -5332,8 +5370,8 @@
"hashicorp-vault": "partial",
"nextcloud-passwords": "no",
"built": {
- "state": "building",
- "evidence": "routes GET/POST/DELETE /api/v1/expiry-policies -> lib/Controller/RotationController.php:161,190,224 and store actions src/store/modules/rotation.js:81,108,124 exist, but grep for fetchPolicies|upsertPolicy|deletePolicy in src components: no callers",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: the expiry policy routes and store actions exist and have no page. Before: routes GET/POST/DELETE /api/v1/expiry-policies -> lib/Controller/RotationController.php:161,190,224 and store actions src/store/modules/rotation.js:81,108,124 exist, but grep for fetchPolicies|upsertPolicy|deletePolicy in src components: no callers",
"owner": "ConductionNL/keepiq",
"note": "Backend and store are done, but no screen calls them; the admin section says type and folder policies are managed from the vault UI, which has no such editor. Open issue #77.",
"defects": [
@@ -5374,7 +5412,8 @@
"evidence": "Specified in openspec/changes/vault-website-addresses on 2026-09-27. Before: searched 'change-password', 'well-known' in src/ lib/ browser-extension/src: no match",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "Keepiq stores the website address but never looks up or opens the site's change-password page."
+ "note": "Keepiq stores the website address but never looks up or opens the site's change-password page.",
+ "change": "openspec/changes/vault-website-addresses"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -5523,7 +5562,8 @@
"issue": null,
"needsLiveCheck": true
}
- ]
+ ],
+ "change": "openspec/changes/admin-member-overview-and-offboarding"
},
"rowSource": "own",
"provider": "keepiq",
@@ -5717,7 +5757,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/admin-vault-policies on 2026-09-27. Before: grep -rni 'export_disabled|allow_export|require_2fa|twofactor' lib/Service/AdminSettingsService.php lib/Controller/ExportController.php src/components/settings: no hits",
"owner": "ConductionNL/keepiq",
- "note": "Keepiq has no policy to require two-factor login or to block personal vault export. Nextcloud can enforce two-factor for the whole login, which also guards keepiq, but that is a server setting, not a vault rule."
+ "note": "Keepiq has no policy to require two-factor login or to block personal vault export. Nextcloud can enforce two-factor for the whole login, which also guards keepiq, but that is a server setting, not a vault rule.",
+ "change": "openspec/changes/admin-vault-policies"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -5748,7 +5789,8 @@
"owner": "ConductionNL/keepiq",
"reachedOn": "Nextcloud admin delegation for the Keepiq settings section; vault_admin group for offboarding and for the admin handover panel in the secret sidebar",
"note": "There are two coarse levers: Nextcloud's delegation of the whole Keepiq admin section, and a hard-coded vault_admin group that unlocks offboarding and admin handover. The handover now has a route, a controller call and a UI panel (f13ad8e6, closing #184), so both levers work end to end. There is still no role editor and no per-permission role, so a person cannot be given only the permissions they need: partial.",
- "defects": []
+ "defects": [],
+ "change": "openspec/changes/admin-scoped-roles"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -5786,7 +5828,8 @@
"issue": "#37",
"needsLiveCheck": false
}
- ]
+ ],
+ "change": "openspec/changes/admin-member-overview-and-offboarding"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -5815,7 +5858,8 @@
"state": "specified",
"evidence": "Specified in openspec/changes/admin-public-api on 2026-09-27. Before: no OpenAPI or admin API docs in docs/; machine routes /api/v1/app/* (appinfo/routes.php:295-321) cover application secrets only; admin endpoints like PUT /api/settings/admin are internal session routes",
"owner": "ConductionNL/keepiq",
- "note": "The admin screens call internal REST routes that a script could reach with a Nextcloud app password, but there is no documented, versioned admin API or scoped admin token."
+ "note": "The admin screens call internal REST routes that a script could reach with a Nextcloud app password, but there is no documented, versioned admin API or scoped admin token.",
+ "change": "openspec/changes/admin-public-api"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -5935,8 +5979,8 @@
"hashicorp-vault": "no",
"nextcloud-passwords": "no",
"built": {
- "state": "building",
- "evidence": "src/manifest.json pending-apps-queue widget (visibleWhen pending_apps_count > 0) -> GET /api/v1/applications/pending; lib/Service/DashboardSummaryService.php:155 computes ca_health for admins but no widget renders it and src/components/dashboard/CaHealthCard.vue has no importer",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: No competitor rated yes, no demand, outside the core area. Reversible: the CA health summary is computed and no widget renders it. Before: src/manifest.json pending-apps-queue widget (visibleWhen pending_apps_count > 0) -> GET /api/v1/applications/pending; lib/Service/DashboardSummaryService.php:155 computes ca_health for admins but no widget renders it and src/components/dashboard/CaHealthCard.vue has no importer",
"owner": "ConductionNL/keepiq",
"reachedOn": "Dashboard (/) Applications awaiting approval table, admins with pending work only",
"note": "Admins see which applications await approval on the dashboard, but cannot approve from there, and the CA status is only in admin settings (CaHealthSection). The dashboard CA card is built but orphaned. The queue's View-all footer needs a live check: the _note at src/manifest.json:208 that ties it to the limit forwarding predates the installed @conduction/nextcloud-vue 2.55.1, which keeps limit, so it is not recorded as a defect.",
@@ -5978,7 +6022,8 @@
"evidence": "appinfo/routes.php:75-77 secretType#create/update/destroy -> lib/Controller/SecretTypeController.php; src/store/modules/secretType.js:73 createType has no caller in src (git grep createType src)",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing, API only",
- "note": "Custom secret types exist in the API and store, but no page lets anyone create one."
+ "note": "Custom secret types exist in the API and store, but no page lets anyone create one.",
+ "change": "openspec/changes/admin-secret-type-editor"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -6106,7 +6151,8 @@
"evidence": "Specified in openspec/changes/admin-vault-policies on 2026-09-27. Before: lib/Service/AdminSettingsService.php: no ownership or personal-vault policy; git grep -i 'personal vault' lib: descriptive text only",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "Every secret starts in the creator's personal vault; nothing forces work logins into a team folder."
+ "note": "Every secret starts in the creator's personal vault; nothing forces work logins into a team folder.",
+ "change": "openspec/changes/admin-vault-policies"
},
"rowSource": "competitor",
"origin": "tender",
@@ -6164,8 +6210,8 @@
"hashicorp-vault": "partial",
"nextcloud-passwords": "no",
"built": {
- "state": "none",
- "evidence": "lib/Service/AdminSettingsService.php:55 VALID_SESSION_TIMEOUTS and :308 default_session_timeout set a DEFAULT only; git grep default_session_timeout src: no caller, so no admin page sets it; src/App.vue:137 user timeout select has no cap",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, changelog signal only, no demand row. Before: lib/Service/AdminSettingsService.php:55 VALID_SESSION_TIMEOUTS and :308 default_session_timeout set a DEFAULT only; git grep default_session_timeout src: no caller, so no admin page sets it; src/App.vue:137 user timeout select has no cap",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing, API only",
"note": "An admin API stores a default session timeout, but it is not a maximum and no admin page reaches it."
@@ -6198,7 +6244,8 @@
"evidence": "Specified in openspec/changes/admin-auto-confirm-members on 2026-09-27. Before: lib/Service/TeamFolderService.php:496 approveJoin returns a fan-out payload the owner's browser must encrypt; src/modals/TeamFolderDialog.vue:469 shows pendingCount and src/store/modules/teamFolder.js:278 runFanOut shares keys when the owner runs it; approveJoin (teamFolder.js:202) has no caller in src",
"owner": "ConductionNL/keepiq",
"reachedOn": "Team folder dialog, pending members count and fan-out run by the owner",
- "note": "A new team folder member gets access only when the owner's browser runs the key fan-out; there is no automatic confirmation."
+ "note": "A new team folder member gets access only when the owner's browser runs the key fan-out; there is no automatic confirmation.",
+ "change": "openspec/changes/admin-auto-confirm-members"
},
"rowSource": "competitor",
"origin": "changelog",
@@ -6258,7 +6305,8 @@
"evidence": "Specified in openspec/changes/admin-scheduled-vault-backups on 2026-09-27. Before: searched 'backup' in lib/Command lib/BackgroundJob: no match; the encrypted-backup export (portability-09) is per user and started by hand",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "There is no scheduled server-side backup of all vaults and no restore command; an instance relies on the Nextcloud database backup."
+ "note": "There is no scheduled server-side backup of all vaults and no restore command; an instance relies on the Nextcloud database backup.",
+ "change": "openspec/changes/admin-scheduled-vault-backups"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -6707,7 +6755,8 @@
"evidence": "Specified in openspec/changes/audit-siem-vendor-connectors on 2026-09-27 for the missing half: named Splunk, Microsoft Sentinel and CEF presets on the SIEM export; the generic syslog and webhook stream is built. Before: lib/Service/SiemTransport.php:100 generic RFC 5424 syslog and :152 generic HTTPS JSON webhook; grep -i 'splunk|sentinel|datadog|CEF|LEEF' lib src/components/settings/SiemSection.vue: no hits",
"owner": "ConductionNL/keepiq",
"reachedOn": "Nextcloud admin settings > Keepiq > SIEM section",
- "note": "No named connectors or vendor formats; Splunk, Sentinel and similar tools can ingest the generic syslog or webhook stream, but the admin has to configure the receiving side."
+ "note": "No named connectors or vendor formats; Splunk, Sentinel and similar tools can ingest the generic syslog or webhook stream, but the admin has to configure the receiving side.",
+ "change": "openspec/changes/audit-siem-vendor-connectors"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -6763,8 +6812,8 @@
"hashicorp-vault": "no",
"nextcloud-passwords": "no",
"built": {
- "state": "none",
- "evidence": "lib/Service/ComplianceReportService.php:56-70 SECTION_ALLOWLIST holds aggregates only (adoption, secretsPerUser, shareHygiene counts); no per-member access listing in lib/ src/",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, changelog signal only, no demand row. Before: lib/Service/ComplianceReportService.php:56-70 SECTION_ALLOWLIST holds aggregates only (adoption, secretsPerUser, shareHygiene counts); no per-member access listing in lib/ src/",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
"note": "The compliance report counts shares but never lists which member can reach which secret or folder."
@@ -6875,7 +6924,8 @@
"issue": null,
"needsLiveCheck": false
}
- ]
+ ],
+ "change": "openspec/changes/portability-import-field-mapping"
},
"rowSource": "own",
"provider": "keepiq",
@@ -7063,7 +7113,8 @@
"evidence": "src/views/SecretList.vue:163 'Encrypted transfer (CXP)' -> src/dialogs/CxpTransferDialog.vue:275 startReceive (createImportRequest src/crypto/cxp.js:110) -> POST /api/v1/cxp/relay (appinfo/routes.php:400 -> lib/Controller/CxpRelayController.php:219) -> poll GET relay/{id}/response (routes.php:401) -> cxp.js:187 openEnvelope -> import wizard; send: CxpTransferDialog.vue:360 doSend -> src/store/modules/export.js:206 exportCxpSealed (sealForRequest cxp.js:136)",
"owner": "ConductionNL/keepiq",
"reachedOn": "SecretList page (/secrets) -> actions menu 'Encrypted transfer (CXP)' -> CXP transfer dialog",
- "note": "The HPKE-sealed handshake works through a relay on this Nextcloud instance only, so both sides must be Keepiq sessions on the same server. There is no way to transfer to or from a different provider, and the send side always sends the whole vault."
+ "note": "The HPKE-sealed handshake works through a relay on this Nextcloud instance only, so both sides must be Keepiq sessions on the same server. There is no way to transfer to or from a different provider, and the send side always sends the whole vault.",
+ "change": "openspec/changes/portability-cxp-cross-provider-transfer"
},
"rowSource": "own",
"provider": "keepiq",
@@ -7108,7 +7159,8 @@
"issue": null,
"needsLiveCheck": true
}
- ]
+ ],
+ "change": "openspec/changes/portability-export-choice-and-restore-fidelity"
},
"rowSource": "own",
"provider": "keepiq",
@@ -7172,7 +7224,8 @@
"evidence": "Specified in openspec/changes/portability-export-choice-and-restore-fidelity on 2026-09-27 for the missing half: choosing by type, by selected items and by fields; whole vault or one folder is built. Before: src/dialogs/ExportDialog.vue:250 scopeOptions (Entire vault or one folder) -> :322 buildScope -> src/export/serializer.js:101 serializeVault collectSubtree (folder plus descendants)",
"owner": "ConductionNL/keepiq",
"reachedOn": "SecretList page (/secrets) -> actions menu 'Export data' -> Export dialog -> Scope select",
- "note": "Scope is either the whole vault or one folder subtree. There is no choice by type, by selected items or of which fields to include, and CXP send is always the whole vault."
+ "note": "Scope is either the whole vault or one folder subtree. There is no choice by type, by selected items or of which fields to include, and CXP send is always the whole vault.",
+ "change": "openspec/changes/portability-export-choice-and-restore-fidelity"
},
"rowSource": "own",
"provider": "keepiq",
@@ -7257,7 +7310,8 @@
"issue": null,
"needsLiveCheck": false
}
- ]
+ ],
+ "change": "openspec/changes/clients-extension-store-release"
},
"rowSource": "own",
"provider": "keepiq",
@@ -7354,7 +7408,8 @@
"issue": null,
"needsLiveCheck": true
}
- ]
+ ],
+ "change": "openspec/changes/clients-extension-save-prompt-and-passkey-origin"
},
"rowSource": "own",
"provider": "keepiq",
@@ -7394,7 +7449,8 @@
"issue": null,
"needsLiveCheck": false
}
- ]
+ ],
+ "change": "openspec/changes/clients-extension-store-release"
},
"rowSource": "own",
"provider": "keepiq",
@@ -7446,7 +7502,8 @@
"issue": null,
"needsLiveCheck": false
}
- ]
+ ],
+ "change": "openspec/changes/clients-extension-save-prompt-and-passkey-origin"
},
"rowSource": "own",
"provider": "keepiq",
@@ -7486,7 +7543,8 @@
"issue": null,
"needsLiveCheck": false
}
- ]
+ ],
+ "change": "openspec/changes/clients-extension-unlock-lock-and-accounts"
},
"rowSource": "own",
"provider": "keepiq",
@@ -7703,7 +7761,8 @@
"issue": null,
"needsLiveCheck": true
}
- ]
+ ],
+ "change": "openspec/changes/clients-extension-firefox-and-safari-builds"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -7733,7 +7792,8 @@
"evidence": "Specified in openspec/changes/clients-ssh-agent on 2026-09-27. Before: grep -ril 'ssh-agent|ssh agent' lib src cli browser-extension: no hits; ssh_key exists only as a stored secret type (src/cxf/cxf.js:355)",
"owner": "ConductionNL/keepiq",
"reachedOn": "none",
- "note": "SSH keys can be stored as secrets, but nothing exposes them to an SSH agent."
+ "note": "SSH keys can be stored as secrets, but nothing exposes them to an SSH agent.",
+ "change": "openspec/changes/clients-ssh-agent"
},
"rowSource": "competitor",
"provider": "keepiq",
@@ -7953,7 +8013,8 @@
"evidence": "Specified in openspec/changes/clients-offline-edits on 2026-09-27. Before: offline cache is read-only by design (clients-09; src/App.vue:73 stale-data banner, src/offline)",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "Offline mode reads only; edits need the server."
+ "note": "Offline mode reads only; edits need the server.",
+ "change": "openspec/changes/clients-offline-edits"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -8017,7 +8078,8 @@
"evidence": "Specified in openspec/changes/clients-extension-unlock-lock-and-accounts on 2026-09-27. Before: browser-extension/src/lib/api.js:15-31 stores one paired config under CONFIG_KEY; no account list",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
- "note": "The extension pairs with one Nextcloud account at a time; switching means unpairing."
+ "note": "The extension pairs with one Nextcloud account at a time; switching means unpairing.",
+ "change": "openspec/changes/clients-extension-unlock-lock-and-accounts"
},
"rowSource": "competitor",
"origin": "featureRequest",
@@ -8045,8 +8107,8 @@
"hashicorp-vault": "no",
"nextcloud-passwords": "unknown",
"built": {
- "state": "none",
- "evidence": "searched 'phish', 'blocklist' in browser-extension/ src/ lib/: only browser-extension/src/lib/match.js:11, which matches the saved URL before any fill; no known-phishing list and no warning page",
+ "state": "decided-no",
+ "evidence": "Decided no on 2026-09-29: Single competitor, changelog signal only, no demand row. Before: searched 'phish', 'blocklist' in browser-extension/ src/ lib/: only browser-extension/src/lib/match.js:11, which matches the saved URL before any fill; no known-phishing list and no warning page",
"owner": "ConductionNL/keepiq",
"reachedOn": "nothing",
"note": "The extension refuses to fill on a site whose address does not match the saved login, which blunts phishing, but it never warns about a known phishing site."
diff --git a/openspec/parity/gap-decisions.json b/openspec/parity/gap-decisions.json
index 4dfc50840..9ef769895 100644
--- a/openspec/parity/gap-decisions.json
+++ b/openspec/parity/gap-decisions.json
@@ -726,5 +726,237 @@
"reason": "Build: the core area (vault, the area of the first 30 rows) with a changelog row (https://github.com/marius-wieschollek/passwords/blob/2026.7.0/CHANGELOG.md). Specified for the missing half: a preview image of the site; site icons are built.",
"change": "vault-website-addresses",
"decidedOn": "2026-09-27"
+ },
+ {
+ "row": "vault-11",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Core area (vault) and six competitors rate yes; the missing half is a hidden field kind.",
+ "change": "vault-custom-field-kinds-and-ssh-key",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "vault-14",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Core area (vault) and three competitors rate yes; the missing half is the key pair fields and generation.",
+ "change": "vault-custom-field-kinds-and-ssh-key",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "vault-20",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Core area (vault); the backend stores both preferences and no screen edits or reads them.",
+ "change": "vault-defaults-and-recently-used-widget",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "vault-21",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Core area (vault); the recently-accessed query is finished and has no caller.",
+ "change": "vault-defaults-and-recently-used-widget",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "crypto-06",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Four competitors rate yes; the web auto-lock is a timer from unlock and updateActivity has no caller.",
+ "change": "crypto-session-timeout-and-inactivity-lock",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "crypto-07",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Four competitors rate yes; the timeout choice is not saved and Nextcloud session becomes 10 minutes.",
+ "change": "crypto-session-timeout-and-inactivity-lock",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "sharing-02",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Five competitors rate yes; backend is complete and nothing opens the form.",
+ "change": "sharing-group-share-entry-point",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "admin-18",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Feature request row plus one competitor yes (Keeper); no page creates a type and a type has no field list.",
+ "change": "admin-secret-type-editor",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "portability-03",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Two competitors rate yes; the parser supports a mapping the wizard never passes.",
+ "change": "portability-import-field-mapping",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "portability-08",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Two competitors rate yes; the handshake only works between two Keepiq sessions on one server.",
+ "change": "portability-cxp-cross-provider-transfer",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "clients-03",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Five competitors rate yes; the offer only appears in the popup and never updates an existing login.",
+ "change": "clients-extension-save-prompt-and-passkey-origin",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "clients-05",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Three competitors rate yes; the relay trusts a page-supplied origin.",
+ "change": "clients-extension-save-prompt-and-passkey-origin",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "clients-12",
+ "matrix": "keepiq",
+ "decision": "build",
+ "reason": "Four competitors rate yes; the Firefox build probably does not start and there is no Safari build.",
+ "change": "clients-extension-firefox-and-safari-builds",
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "sharing-11",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "No competitor rated yes, no demand, outside the core area. Reversible: the reclaim half is built and the creation half is a store and dialog gap",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "sharing-13",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, no demand",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "sharing-23",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, no demand",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "apps-13",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "No competitor rated yes, no demand, outside the core area. Reversible: ApplicationService::delete cascades leases and lease policies but not secrets",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "apps-19",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, no demand",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "apps-24",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, no demand",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "pki-02",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "No competitor rated yes, no demand, outside the core area. Reversible: the intermediate renews on a daily job and the root renews only by hand",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "pki-07",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, no demand",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "pki-09",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "No competitor rated yes, no demand, outside the core area. Note: PrivateKeyDownloadDialog is mounted but no PHP code returns a private_key, so the dialog is dead UI",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "health-09",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, no demand",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "health-10",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, no demand",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "rotation-07",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "No competitor rated yes, no demand, outside the core area. Reversible: the expiry policy routes and store actions exist and have no page",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "admin-17",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "No competitor rated yes, no demand, outside the core area. Reversible: the CA health summary is computed and no widget renders it",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "admin-24",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, changelog signal only, no demand row",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "audit-16",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, changelog signal only, no demand row",
+ "change": null,
+ "decidedOn": "2026-09-29"
+ },
+ {
+ "row": "clients-22",
+ "matrix": "keepiq",
+ "decision": "decided-no",
+ "reason": "Single competitor, changelog signal only, no demand row",
+ "change": null,
+ "decidedOn": "2026-09-29"
}
]
From 310c643092245d4855f7042054c081a78a7453b1 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 21:36:27 +0200
Subject: [PATCH 002/245] test(applications): assert no private-key state or
dialog, since the server never returns a key
---
src/dialogs/PrivateKeyDownloadDialog.vue | 253 ------------------
.../dialogs/PrivateKeyDownloadDialog.spec.js | 159 -----------
tests/store/application.spec.js | 35 +--
tests/views/AdminApplicationsView.spec.js | 45 +---
tests/views/ApplicationRegisterView.spec.js | 15 +-
5 files changed, 20 insertions(+), 487 deletions(-)
delete mode 100644 src/dialogs/PrivateKeyDownloadDialog.vue
delete mode 100644 tests/dialogs/PrivateKeyDownloadDialog.spec.js
diff --git a/src/dialogs/PrivateKeyDownloadDialog.vue b/src/dialogs/PrivateKeyDownloadDialog.vue
deleted file mode 100644
index d6bcbf29f..000000000
--- a/src/dialogs/PrivateKeyDownloadDialog.vue
+++ /dev/null
@@ -1,253 +0,0 @@
-
-
-
-
-
-
- {{
- t(
- 'keepiq',
- 'This is the only time this private key will be shown. Save it securely; it cannot be recovered.',
- )
- }}
-
-
-
-
-
-
-
- {{ t('keepiq', 'I have stored the private key in a safe place.') }}
-
-
-
-
-
- {{ t('keepiq', 'Dismiss') }}
-
-
-
-
-
-
-
-
diff --git a/tests/dialogs/PrivateKeyDownloadDialog.spec.js b/tests/dialogs/PrivateKeyDownloadDialog.spec.js
deleted file mode 100644
index 5648f1a04..000000000
--- a/tests/dialogs/PrivateKeyDownloadDialog.spec.js
+++ /dev/null
@@ -1,159 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2026 Conduction / Keepiq Contributors
- * SPDX-License-Identifier: EUPL-1.2
- *
- * Component test for `src/dialogs/PrivateKeyDownloadDialog.vue`.
- *
- * This dialog shows a ONE-TIME private key that cannot be recovered. Its first
- * cut was a bare in document flow, which painted the
- * key below the fold where nobody saw it. What these tests pin:
- *
- * - The dialog renders through NcDialog with `noClose`, so it is a real
- * overlay AND the library's own escape hatches (close button, Esc, outside
- * click) stay shut — the acknowledgment gate is the only way out.
- * - Dismiss is disabled until the acknowledgment box is ticked.
- * - Reopening for a new key starts unacknowledged again.
- *
- * The `@nextcloud/vue` design primitives resolve to the flat stub dict in
- * `tests/vitest/stubs/nextcloud-vue.js` (vitest alias): NcDialog renders its
- * slots in place (no teleport), and undeclared props like `noClose` fall
- * through onto its root element as attributes.
- *
- * @spec openspec/specs/application-mgmt/spec.md#requirement-encryptionsuite-via-csr
- */
-
-import { mount } from '@vue/test-utils'
-import { describe, expect, it, vi } from 'vitest'
-import PrivateKeyDownloadDialog from '../../src/dialogs/PrivateKeyDownloadDialog.vue'
-
-const KEY = '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----'
-
-/**
- * Mount the dialog open with a key, as the parent views render it.
- *
- * @param {object} propsData Extra props to merge over the defaults.
- * @return {object} The wrapper.
- */
-function mountDialog(propsData = {}) {
- return mount(PrivateKeyDownloadDialog, {
- propsData: { open: true, privateKey: KEY, ...propsData },
- })
-}
-
-describe('PrivateKeyDownloadDialog', () => {
- it('renders as an NcDialog overlay with every library escape hatch shut', () => {
- const wrapper = mountDialog()
- const dialog = wrapper.findComponent({ name: 'NcDialog' })
- expect(dialog.exists()).toBe(true)
- expect(dialog.attributes('data-open')).toBe('true')
- // `noClose` is not declared on the stub, so it falls through as an
- // attribute — its presence pins that the real dialog suppresses the
- // close button, Esc and outside clicks.
- expect(dialog.attributes('noclose')).toBe('true')
- })
-
- it('presents the key area and the unmissable one-time warning', () => {
- const wrapper = mountDialog()
- // Masked by default (review call) — the reveal test below covers
- // the plaintext path; here the block must be present and key-sized.
- const textarea = wrapper.find('[data-testid="private-key-textarea"]')
- expect(textarea.exists()).toBe(true)
- expect(textarea.element.value).toHaveLength(KEY.length)
- expect(wrapper.find('[data-testid="private-key-warning"]').text()).toContain(
- 'cannot be recovered',
- )
- })
-
- it('keeps Dismiss locked until the acknowledgment is ticked', async () => {
- const wrapper = mountDialog()
- const dismiss = wrapper.find('[data-testid="private-key-dismiss"]')
- expect(dismiss.attributes('disabled')).toBeDefined()
-
- wrapper
- .findComponent({ name: 'NcCheckboxRadioSwitch' })
- .vm.$emit('update:modelValue', true)
- await wrapper.vm.$nextTick()
-
- expect(dismiss.attributes('disabled')).toBeUndefined()
- await dismiss.trigger('click')
- expect(wrapper.emitted('close')).toBeTruthy()
- })
-
- it('starts unacknowledged again when reopened for a new key', async () => {
- const wrapper = mountDialog()
- wrapper
- .findComponent({ name: 'NcCheckboxRadioSwitch' })
- .vm.$emit('update:modelValue', true)
- await wrapper.vm.$nextTick()
-
- await wrapper.setProps({ open: false })
- await wrapper.setProps({ open: true })
-
- expect(
- wrapper
- .find('[data-testid="private-key-dismiss"]')
- .attributes('disabled'),
- ).toBeDefined()
- })
-
- // Copy goes through CopyButton so the clipboard is CLEARED again on its
- // timer (review call): a one-time key must not outlive its dialog in
- // the paste buffer of a shared workstation.
- it('copies the key and auto-clears the clipboard afterwards', async () => {
- vi.useFakeTimers()
- try {
- const writeText = vi.fn().mockResolvedValue(undefined)
- Object.assign(navigator, { clipboard: { writeText } })
-
- const wrapper = mountDialog()
- await wrapper.find('[data-testid="private-key-copy"]').trigger('click')
- await wrapper.vm.$nextTick()
-
- expect(writeText).toHaveBeenCalledWith(KEY)
-
- await vi.advanceTimersByTimeAsync(30_000)
- expect(writeText).toHaveBeenLastCalledWith('')
- } finally {
- vi.useRealTimers()
- }
- })
-
- // The key renders MASKED until an explicit reveal (review call): the
- // dialog pops open unprompted after registration, possibly mid-
- // screenshare — not one character may show without consent.
- it('masks the key until revealed, and re-masks on hide', async () => {
- const wrapper = mountDialog()
- const textarea = wrapper.find('[data-testid="private-key-textarea"]')
-
- expect(textarea.element.value).not.toContain('PRIVATE KEY')
- expect(textarea.element.value).toContain('•')
- // The line structure survives, so revealing does not reflow.
- expect(textarea.element.value.split('\n')).toHaveLength(
- KEY.split('\n').length,
- )
-
- await wrapper.find('[data-testid="private-key-reveal"]').trigger('click')
- expect(textarea.element.value).toBe(KEY)
-
- await wrapper.find('[data-testid="private-key-reveal"]').trigger('click')
- expect(textarea.element.value).not.toContain('PRIVATE KEY')
- })
-
- // Belt and braces for the noClose gate (review call): the library's Esc
- // handling has historically routed through internals rather than
- // noClose, so the dialog swallows Escape itself.
- it('swallows Escape before it can reach the library', () => {
- const wrapper = mountDialog()
- const container = wrapper.find('.private-key').element
-
- const esc = new KeyboardEvent('keydown', {
- key: 'Escape',
- bubbles: true,
- cancelable: true,
- })
- container.dispatchEvent(esc)
-
- expect(esc.defaultPrevented).toBe(true)
- expect(wrapper.emitted('close')).toBeFalsy()
- })
-})
diff --git a/tests/store/application.spec.js b/tests/store/application.spec.js
index bd84478a5..f57b44a5f 100644
--- a/tests/store/application.spec.js
+++ b/tests/store/application.spec.js
@@ -87,17 +87,16 @@ describe('useApplicationStore', () => {
expect(store.pendingApplications).toHaveLength(1)
})
- it('surfaces the one-time private_key into store state', async () => {
- const pem =
- '-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----'
+ it('keeps no private-key state: the server never returns one (pki-09 decided no)', async () => {
vi.spyOn(axios, 'post').mockResolvedValue({
- data: { id: 'app-x', name: 'X', status: 'active', private_key: pem },
+ data: { id: 'app-x', name: 'X', status: 'active', private_key: 'PEM' },
})
const store = useApplicationStore()
await store.registerApplication({ name: 'X', type: 'internal' })
- expect(store.oneTimePrivateKey).toBe(pem)
- expect(store.oneTimePrivateKeyAppId).toBe('app-x')
+ expect('oneTimePrivateKey' in store.$state).toBe(false)
+ expect('oneTimePrivateKeyAppId' in store.$state).toBe(false)
+ expect(store.clearOneTimePrivateKey).toBeUndefined()
})
})
@@ -117,19 +116,6 @@ describe('useApplicationStore', () => {
expect(store.pendingApplications).toHaveLength(0)
expect(store.applications[0].status).toBe('active')
})
-
- it('surfaces the private_key returned on no-CSR approval', async () => {
- vi.spyOn(axios, 'post').mockResolvedValue({
- data: { id: 'app-1', private_key: 'PEM-2' },
- })
- const store = useApplicationStore()
- store.pendingApplications = [{ id: 'app-1' }]
-
- await store.approveApplication('app-1')
-
- expect(store.oneTimePrivateKey).toBe('PEM-2')
- expect(store.oneTimePrivateKeyAppId).toBe('app-1')
- })
})
describe('rejectApplication', () => {
@@ -165,17 +151,6 @@ describe('useApplicationStore', () => {
})
})
- describe('clearOneTimePrivateKey', () => {
- it('resets transient fields to null', () => {
- const store = useApplicationStore()
- store.oneTimePrivateKey = 'leaked'
- store.oneTimePrivateKeyAppId = 'app-1'
-
- store.clearOneTimePrivateKey()
-
- expect(store.oneTimePrivateKey).toBeNull()
- expect(store.oneTimePrivateKeyAppId).toBeNull()
- })
})
describe('fetchCertificate', () => {
diff --git a/tests/views/AdminApplicationsView.spec.js b/tests/views/AdminApplicationsView.spec.js
index d3de3f98b..87620d515 100644
--- a/tests/views/AdminApplicationsView.spec.js
+++ b/tests/views/AdminApplicationsView.spec.js
@@ -91,45 +91,22 @@ describe('AdminApplicationsView', () => {
)
})
- it('shows the private-key dialog block when the store carries a one-time key', async () => {
- vi.spyOn(axios, 'get').mockResolvedValue({ data: [] })
+ it('shows no private-key block after an approval, since the server never returns a key (pki-09 decided no)', async () => {
+ vi.spyOn(axios, 'get').mockResolvedValue({
+ data: [{ id: 'a1', name: 'A', status: 'pending' }],
+ })
+ vi.spyOn(axios, 'post').mockResolvedValue({
+ data: { id: 'a1', status: 'active', private_key: 'PEM' },
+ })
const wrapper = mount(AdminApplicationsView)
await flushPromises()
-
- const store = useApplicationStore()
- store.oneTimePrivateKey = '-----BEGIN PRIVATE KEY-----\nAAAA'
- store.oneTimePrivateKeyAppId = 'app-1'
- await wrapper.vm.$nextTick()
+ await wrapper.find('[data-testid="approve-button"]').trigger('click')
+ await flushPromises()
expect(wrapper.find('[data-testid="private-key-dialog"]').exists()).toBe(
- true,
+ false,
)
- expect(
- wrapper.find('[data-testid="private-key-text"]').element.value,
- ).toContain('BEGIN PRIVATE KEY')
- })
-
- it('keeps the dismiss button disabled until the acknowledgment checkbox is ticked', async () => {
- vi.spyOn(axios, 'get').mockResolvedValue({ data: [] })
-
- const wrapper = mount(AdminApplicationsView)
- await flushPromises()
-
- const store = useApplicationStore()
- store.oneTimePrivateKey = 'PEM'
- await wrapper.vm.$nextTick()
-
- const dismiss = wrapper.find('[data-testid="dismiss-key"]')
- expect(dismiss.attributes('disabled')).toBeDefined()
-
- await wrapper.find('[data-testid="acknowledge-key"]').setChecked()
- await wrapper.vm.$nextTick()
-
- expect(dismiss.attributes('disabled')).toBeUndefined()
-
- await dismiss.trigger('click')
- await wrapper.vm.$nextTick()
- expect(store.oneTimePrivateKey).toBeNull()
+ expect(wrapper.text()).not.toContain('PEM')
})
})
diff --git a/tests/views/ApplicationRegisterView.spec.js b/tests/views/ApplicationRegisterView.spec.js
index 52432e434..666819153 100644
--- a/tests/views/ApplicationRegisterView.spec.js
+++ b/tests/views/ApplicationRegisterView.spec.js
@@ -76,20 +76,13 @@ describe('ApplicationRegisterView', () => {
expect(dialog.attributes('data-open')).toBe('true')
})
- it('opens the PrivateKeyDownloadDialog when the store has a one-time key', async () => {
+ it('mounts no private-key download dialog (pki-09 decided no)', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({ data: [] })
const wrapper = mount(ApplicationRegisterView)
await flush()
- const dialog = wrapper.find('[data-testid="private-key-dialog"]')
- expect(dialog.attributes('data-open')).toBe('false')
- // Simulate the registration flow having captured the key.
- const { useApplicationStore } =
- await import('../../src/store/modules/application.js')
- const store = useApplicationStore()
- store.oneTimePrivateKey =
- '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----'
- await flush()
- expect(dialog.attributes('data-open')).toBe('true')
+ expect(wrapper.find('[data-testid="private-key-dialog"]').exists()).toBe(
+ false,
+ )
})
// The dashboard's "Register application" tile deep-links to
From cb890a459c3490e4cdd832a80c5afe21b06054d6 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 21:36:27 +0200
Subject: [PATCH 003/245] chore(applications): remove the dead private-key
download dialog
No server code returns a private key: lib/ has no 'private_key' response
key, and ApplicationLifecycleService only provisions a suite from a
supplied CSR. Parity row pki-09 stays decided no (DECISIONS row 19), so
the dialog, the store's one-time key state and the registry entry go, and
the application-mgmt spec now says what the code does without a CSR.
---
openspec/specs/application-mgmt/spec.md | 7 +-
src/dialogs/ApplicationRegisterDialog.vue | 5 --
src/registry.js | 6 --
src/store/modules/application.js | 41 ++----------
src/views/AdminApplicationsView.vue | 82 +----------------------
src/views/ApplicationRegisterView.vue | 19 +-----
6 files changed, 13 insertions(+), 147 deletions(-)
diff --git a/openspec/specs/application-mgmt/spec.md b/openspec/specs/application-mgmt/spec.md
index 1c7a2d2f7..fce76818a 100644
--- a/openspec/specs/application-mgmt/spec.md
+++ b/openspec/specs/application-mgmt/spec.md
@@ -72,8 +72,9 @@ When a CSR is uploaded during registration, the system MUST use the public key f
#### Scenario: Register without CSR
- GIVEN no CSR is uploaded
- WHEN the application is approved
-- THEN the system MUST generate a 4096-bit RSA key pair, sign the certificate, and create an EncryptionSuite
-- AND the private key MUST be returned to the registrant once (never stored in plaintext)
+- THEN the application MUST become active without an EncryptionSuite
+- AND the system MUST NOT generate, store or return a private key for it (parity row pki-09, decided no on 2026-09-29: the key pair stays with the application, which supplies a CSR)
+- AND writing a secret for the application MUST fail with "Application has no active EncryptionSuite" until a suite exists
### Requirement: Delete Application
A vault administrator MUST be able to delete an active application. Deletion is permanent — there is no deactivation or soft-delete state.
@@ -182,7 +183,7 @@ The listing MUST NOT render a request's full token, and MUST NOT expose any subm
- [ ] Vault administrators can approve or reject pending applications
- [ ] An approved application gets an EncryptionSuite (via CSR or generated)
- [ ] If a CSR is uploaded, the private key is not stored — only the signed certificate
-- [ ] If no CSR is uploaded, a key pair is generated and the private key returned once
+- [ ] If no CSR is uploaded, no key pair is generated and no private key is ever returned; the application stays suite-less until it supplies a CSR
- [ ] Secrets cannot be attributed to pending applications
- [ ] Writing a secret for an application encrypts it with the app's public certificate
- [ ] All vault administrators receive a Nextcloud notification when a new application registration is pending
diff --git a/src/dialogs/ApplicationRegisterDialog.vue b/src/dialogs/ApplicationRegisterDialog.vue
index 08ad010d1..ecddf7f50 100644
--- a/src/dialogs/ApplicationRegisterDialog.vue
+++ b/src/dialogs/ApplicationRegisterDialog.vue
@@ -15,11 +15,6 @@
or is clipped by the dialog's content box (invisible control), see
the history in src/dialogs/MoveDialog.vue.
- When the server returns a one-time private key (because no CSR was
- supplied) the parent view should mount PrivateKeyDownloadDialog to
- surface it; that flow is owned by the parent so the key can be
- copied or downloaded before this dialog closes.
-
@spec openspec/changes/implement-application-mgmt/tasks.md#task-10.3
-->
diff --git a/src/registry.js b/src/registry.js
index d653a0ff2..fdbb3e34a 100644
--- a/src/registry.js
+++ b/src/registry.js
@@ -37,7 +37,6 @@ import ShareList from './components/share/ShareList.vue'
import ApplicationRegisterDialog from './dialogs/ApplicationRegisterDialog.vue'
import FolderCreateDialog from './dialogs/FolderCreateDialog.vue'
import MoveDialog from './dialogs/MoveDialog.vue'
-import PrivateKeyDownloadDialog from './dialogs/PrivateKeyDownloadDialog.vue'
import SecretCreateDialog from './dialogs/SecretCreateDialog.vue'
import SecretDeleteConfirmDialog from './dialogs/SecretDeleteConfirmDialog.vue'
import SecretEditDialog from './dialogs/SecretEditDialog.vue'
@@ -96,11 +95,6 @@ export default {
component: ApplicationRegisterDialog,
propsSchema: {},
},
- 'private-key-download': {
- kind: 'modal',
- component: PrivateKeyDownloadDialog,
- propsSchema: {},
- },
'share-dialog': { kind: 'modal', component: ShareDialog, propsSchema: {} },
'share-list': { kind: 'form-field', component: ShareList, propsSchema: {} },
'group-share-form': {
diff --git a/src/store/modules/application.js b/src/store/modules/application.js
index ecc789485..63723c441 100644
--- a/src/store/modules/application.js
+++ b/src/store/modules/application.js
@@ -7,10 +7,9 @@ import { importPublicKey, rsaEncrypt } from '../../crypto/index.js'
* Pinia store for the registered-application admin queue + user
* registration flow (implement-application-mgmt §9).
*
- * The store wraps the `/api/v1/applications` REST surface and surfaces
- * the one-time private key returned by `register` / `approve` for the
- * PrivateKeyDownloadDialog. The private key MUST never be persisted —
- * it lives only in transient store state until the dialog is dismissed.
+ * The store wraps the `/api/v1/applications` REST surface. The server
+ * never generates or returns a private key (parity row pki-09, decided
+ * no): an application keeps its own key pair and supplies a CSR.
*
* @spec openspec/changes/implement-application-mgmt/tasks.md#task-9.1
*/
@@ -26,10 +25,6 @@ export const useApplicationStore = defineStore('application', {
totalCount: 0,
/** @type {boolean} Whether a request is in flight. */
loading: false,
- /** @type {string|null} The one-time private-key PEM returned by register/approve. */
- oneTimePrivateKey: null,
- /** @type {string|null} The application ID the one-time key belongs to. */
- oneTimePrivateKeyAppId: null,
}),
getters: {
@@ -107,9 +102,7 @@ export const useApplicationStore = defineStore('application', {
/**
* Register a new application. Admins auto-approve; non-admin
- * callers create a pending row. When the server generates a
- * keypair (no CSR supplied) the response carries `private_key`
- * — the caller MUST surface it via PrivateKeyDownloadDialog.
+ * callers create a pending row.
*
* @param {object} payload The registration payload.
* @param {string} payload.name The application name.
@@ -133,11 +126,6 @@ export const useApplicationStore = defineStore('application', {
)
const data = response.data || {}
- if (data.private_key) {
- this.oneTimePrivateKey = data.private_key
- this.oneTimePrivateKeyAppId = data.id ?? null
- }
-
this.applications.push(data)
if (data.status === 'pending') {
this.pendingApplications.push(data)
@@ -148,10 +136,6 @@ export const useApplicationStore = defineStore('application', {
/**
* Approve a pending application. Admin-only.
*
- * Mirrors registerApplication: when the original request had no
- * CSR, the approval call generates the keypair server-side and
- * returns `private_key` — surface via PrivateKeyDownloadDialog.
- *
* @param {string} id The application ID.
* @return {Promise
-
-
{{ t('keepiq', 'Save the application private key') }}
-
- {{
- t(
- 'keepiq',
- 'This is the only time the private key is shown. Save it securely; it cannot be recovered.',
- )
- }}
-
-
-
-
-
-
-
@@ -126,8 +89,7 @@ import { useApplicationStore } from '../store/modules/application.js'
* Admin queue view for registered applications.
*
* Loads the pending queue from useApplicationStore, lets admins
- * approve / reject each row, and surfaces the one-time private key
- * returned by an approve-without-CSR via an inline dialog block.
+ * approve / reject each row.
*
* @spec openspec/changes/implement-application-mgmt/tasks.md#task-10.7
*/
@@ -137,7 +99,6 @@ export default {
data() {
return {
store: useApplicationStore(),
- acknowledged: false,
}
},
@@ -149,10 +110,6 @@ export default {
pendingCount() {
return this.store.pendingCount
},
-
- hasPrivateKey() {
- return !!this.store.oneTimePrivateKey
- },
},
async created() {
@@ -161,8 +118,7 @@ export default {
methods: {
/**
- * Approve a pending application — server may return a
- * private_key when the original request had no CSR.
+ * Approve a pending application.
*
* @param {string} id The application ID.
* @return {Promise}
@@ -180,17 +136,6 @@ export default {
async reject(id) {
await this.store.rejectApplication(id)
},
-
- /**
- * Clear the one-time private-key dialog state after the admin
- * has acknowledged saving it.
- *
- * @return {void}
- */
- dismissKey() {
- this.store.clearOneTimePrivateKey()
- this.acknowledged = false
- },
},
}
@@ -242,27 +187,4 @@ export default {
color: var(--color-text-lighter);
}
-.keepiq-applications-view__keydialog {
- margin-top: 2rem;
- padding: 1rem;
- /* --color-warning-rest is not a Nextcloud variable, so this always fell back
- to the pale light-theme yellow and the inherited near-white dark-mode text
- was unreadable on it. The old #f00 border fallback was wrong twice over:
- red for a warning, and unreachable because --color-warning is defined. */
- border: 1px solid var(--color-warning-text);
- background: var(--color-background-dark);
- color: var(--color-main-text);
-}
-
-.keepiq-applications-view__warning {
- font-weight: 600;
- color: var(--color-error-text);
-}
-
-.keepiq-applications-view__keytext {
- width: 100%;
- min-height: 12rem;
- font-family: monospace;
- font-size: 0.8rem;
-}
diff --git a/src/views/ApplicationRegisterView.vue b/src/views/ApplicationRegisterView.vue
index e3a0a8934..7ed0d0b58 100644
--- a/src/views/ApplicationRegisterView.vue
+++ b/src/views/ApplicationRegisterView.vue
@@ -4,9 +4,9 @@
Developer-facing "register an application" page. Shows the user's own
application registrations (on the shared CnIndexPage list view) and lets
- them add new ones via the ApplicationRegisterDialog. When the server returns
- a one-time private key the PrivateKeyDownloadDialog is shown until the user
- acknowledges.
+ them add new ones via the ApplicationRegisterDialog. The server never
+ returns a private key (parity row pki-09, decided no), so there is no key
+ download step.
Admins should use `AdminApplicationsView` for the approval queue;
this view is intentionally non-admin scoped.
@@ -53,20 +53,12 @@
:open="dialogOpen"
@close="dialogOpen = false"
@registered="onRegistered" />
-
-
From 56e643a51ac48de36ec923e51daeb39422f34888 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 21:39:30 +0200
Subject: [PATCH 004/245] test(applications): close the store spec's describe
blocks
---
tests/store/application.spec.js | 2 --
1 file changed, 2 deletions(-)
diff --git a/tests/store/application.spec.js b/tests/store/application.spec.js
index f57b44a5f..f413dc2e6 100644
--- a/tests/store/application.spec.js
+++ b/tests/store/application.spec.js
@@ -151,8 +151,6 @@ describe('useApplicationStore', () => {
})
})
- })
-
describe('fetchCertificate', () => {
it('returns the certificate from the API', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({
From 1d8bc1c791b508652a5648488912660adecd4e9c Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 21:43:35 +0200
Subject: [PATCH 005/245] style(applications): prettier and an unused import
---
src/views/AdminApplicationsView.vue | 2 --
tests/store/application.spec.js | 7 ++++++-
tests/views/AdminApplicationsView.spec.js | 1 -
3 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/views/AdminApplicationsView.vue b/src/views/AdminApplicationsView.vue
index 73f019098..104fc8b5b 100644
--- a/src/views/AdminApplicationsView.vue
+++ b/src/views/AdminApplicationsView.vue
@@ -78,7 +78,6 @@
-
@@ -186,5 +185,4 @@ export default {
.keepiq-applications-view__empty {
color: var(--color-text-lighter);
}
-
diff --git a/tests/store/application.spec.js b/tests/store/application.spec.js
index f413dc2e6..a5d94fb1f 100644
--- a/tests/store/application.spec.js
+++ b/tests/store/application.spec.js
@@ -89,7 +89,12 @@ describe('useApplicationStore', () => {
it('keeps no private-key state: the server never returns one (pki-09 decided no)', async () => {
vi.spyOn(axios, 'post').mockResolvedValue({
- data: { id: 'app-x', name: 'X', status: 'active', private_key: 'PEM' },
+ data: {
+ id: 'app-x',
+ name: 'X',
+ status: 'active',
+ private_key: 'PEM',
+ },
})
const store = useApplicationStore()
await store.registerApplication({ name: 'X', type: 'internal' })
diff --git a/tests/views/AdminApplicationsView.spec.js b/tests/views/AdminApplicationsView.spec.js
index 87620d515..6c75b8d34 100644
--- a/tests/views/AdminApplicationsView.spec.js
+++ b/tests/views/AdminApplicationsView.spec.js
@@ -14,7 +14,6 @@ const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
import axios from '@nextcloud/axios'
import { createPinia, setActivePinia } from 'pinia'
import AdminApplicationsView from '../../src/views/AdminApplicationsView.vue'
-import { useApplicationStore } from '../../src/store/modules/application.js'
describe('AdminApplicationsView', () => {
beforeEach(() => {
From 6a97d878a0173e19bedf85d11c59d335800d2067 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 22:02:40 +0200
Subject: [PATCH 006/245] test(sharing): red tests for sharing a secret with a
group from the sidebar
---
.../Controller/GroupShareControllerTest.php | 42 ++++++
tests/Unit/Service/GroupShareServiceTest.php | 117 +++++++++++++++-
tests/Unit/Service/ShareServiceTest.php | 64 +++++++++
tests/components/GroupShareList.spec.js | 71 ++++++++++
.../SecretDetailSidebar.sharing.spec.js | 9 ++
tests/store/groupShare.spec.js | 129 ++++++++++++++++++
6 files changed, 431 insertions(+), 1 deletion(-)
create mode 100644 tests/components/GroupShareList.spec.js
create mode 100644 tests/store/groupShare.spec.js
diff --git a/tests/Unit/Controller/GroupShareControllerTest.php b/tests/Unit/Controller/GroupShareControllerTest.php
index 99d700d2f..a1ea998ae 100644
--- a/tests/Unit/Controller/GroupShareControllerTest.php
+++ b/tests/Unit/Controller/GroupShareControllerTest.php
@@ -26,6 +26,7 @@
use InvalidArgumentException;
use OCA\Keepiq\Controller\GroupShareController;
+use OCA\Keepiq\Db\GroupShare;
use OCA\Keepiq\Service\GroupShareService;
use OCP\AppFramework\Http;
use OCP\IRequest;
@@ -106,6 +107,47 @@ private function controller(): GroupShareController {
);
}//end controller()
+ /**
+ * sharing-02: creating a group share answers 201 with the member fan-out
+ * and the count of members skipped for want of an encryption suite, so
+ * the sidebar can say how many received it and how many did not.
+ *
+ * @return void
+ */
+ public function testCreateAnswersTheFanOutAndTheSkippedCount(): void {
+ $this->signIn('alice');
+ $row = new GroupShare();
+ $row->setId('gs-1');
+ $row->setGroupId('finance');
+ $this->groupShareService->expects($this->once())->method('createGroupShare')
+ ->with('sec-1', 'finance', 'alice')
+ ->willReturn(['groupShare' => $row, 'members' => [['userId' => 'bob', 'certificate' => 'PEM']], 'skipped' => 1]);
+
+ $response = $this->controller()->create(secretId: 'sec-1', groupId: 'finance');
+
+ $this->assertSame(Http::STATUS_CREATED, $response->getStatus());
+ $this->assertSame(1, $response->getData()['skipped']);
+ $this->assertSame('bob', $response->getData()['members'][0]['userId']);
+ $this->assertSame('gs-1', $response->getData()['groupShare']['id']);
+ }//end testCreateAnswersTheFanOutAndTheSkippedCount()
+
+ /**
+ * sharing-02: a group outside the caller's reach answers 400 with the
+ * service's message and creates nothing.
+ *
+ * @return void
+ */
+ public function testCreateForAGroupOutOfReachIs400(): void {
+ $this->signIn('alice');
+ $this->groupShareService->method('createGroupShare')
+ ->willThrowException(new InvalidArgumentException('Group not found'));
+
+ $response = $this->controller()->create(secretId: 'sec-1', groupId: 'board');
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ $this->assertSame(['message' => 'Group not found'], $response->getData());
+ }//end testCreateForAGroupOutOfReachIs400()
+
/**
* Approving must forward the URL's group-share id, the new member, the
* recipient's encrypted copy and the SESSION user as the approver.
diff --git a/tests/Unit/Service/GroupShareServiceTest.php b/tests/Unit/Service/GroupShareServiceTest.php
index 19368bdc2..0a78b2acf 100644
--- a/tests/Unit/Service/GroupShareServiceTest.php
+++ b/tests/Unit/Service/GroupShareServiceTest.php
@@ -32,10 +32,12 @@
use OCA\Keepiq\Db\ShareTargetMapper;
use OCA\Keepiq\Service\GroupShareService;
use OCA\Keepiq\Service\NotificationService;
+use OCA\Keepiq\Service\ShareRevocationService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUser;
+use OCP\Share\IManager as IShareManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -92,6 +94,30 @@ class GroupShareServiceTest extends TestCase {
*/
private NotificationService $notificationService;
+ /**
+ * @var IShareManager&MockObject
+ */
+ private IShareManager $shareManager;
+
+ /**
+ * @var ShareRevocationService&MockObject
+ */
+ private ShareRevocationService $revocationService;
+
+ /**
+ * Whether the Nextcloud share settings allow group sharing.
+ *
+ * @var boolean
+ */
+ private bool $groupSharingAllowed = true;
+
+ /**
+ * Whether the Nextcloud share settings restrict sharing to own groups.
+ *
+ * @var boolean
+ */
+ private bool $membersOnly = false;
+
/**
* Set up fixtures.
*
@@ -107,6 +133,10 @@ protected function setUp(): void {
$this->groupManager = $this->createMock(originalClassName: IGroupManager::class);
$this->notificationService = $this->createMock(originalClassName: NotificationService::class);
$logger = $this->createMock(originalClassName: LoggerInterface::class);
+ $this->shareManager = $this->createMock(originalClassName: IShareManager::class);
+ $this->shareManager->method('allowGroupSharing')->willReturnCallback(fn (): bool => $this->groupSharingAllowed);
+ $this->shareManager->method('shareWithGroupMembersOnly')->willReturnCallback(fn (): bool => $this->membersOnly);
+ $this->revocationService = $this->createMock(originalClassName: ShareRevocationService::class);
$this->service = new GroupShareService(
mapper: $this->mapper,
@@ -117,7 +147,9 @@ protected function setUp(): void {
delegationMapper: $this->delegationMapper,
groupManager: $this->groupManager,
notificationService: $this->notificationService,
- logger: $logger
+ logger: $logger,
+ shareManager: $this->shareManager,
+ revocationService: $this->revocationService
);
}//end setUp()
@@ -201,8 +233,73 @@ static function (string $ownerType, string $ownerId) use ($bobSuite) {
$this->assertCount(1, $result['members']);
$this->assertSame('bob', $result['members'][0]['userId']);
$this->assertSame('PEM-BOB', $result['members'][0]['certificate']);
+ // carol has no suite: counted as skipped, the owner is not.
+ $this->assertSame(1, $result['skipped']);
}//end testCreateGroupShareReturnsEligibleMembers()
+ /**
+ * sharing-02: when Nextcloud restricts sharing to the caller's own
+ * groups, a group the caller is not in is refused exactly like a
+ * missing group, so the reply does not confirm the group exists.
+ *
+ * @return void
+ */
+ public function testCreateGroupShareRefusesAGroupTheCallerCannotSee(): void {
+ $this->membersOnly = true;
+ $secret = $this->makeOwnerSecret('src-1', 'alice');
+ $this->secretMapper->method('findById')->willReturn($secret);
+ $this->groupManager->method('get')->willReturn($this->createMock(IGroup::class));
+ $this->groupManager->method('isInGroup')->with('alice', 'board')->willReturn(false);
+ $this->mapper->expects($this->never())->method('insert');
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Group not found');
+
+ $this->service->createGroupShare(secretId: 'src-1', groupId: 'board', userId: 'alice');
+ }//end testCreateGroupShareRefusesAGroupTheCallerCannotSee()
+
+ /**
+ * sharing-02: with the restriction on, a group the caller belongs to
+ * is accepted.
+ *
+ * @return void
+ */
+ public function testCreateGroupShareAcceptsOwnGroupWhenRestricted(): void {
+ $this->membersOnly = true;
+ $secret = $this->makeOwnerSecret('src-1', 'alice');
+ $this->secretMapper->method('findById')->willReturn($secret);
+ $group = $this->createMock(IGroup::class);
+ $group->method('getUsers')->willReturn([]);
+ $this->groupManager->method('get')->willReturn($group);
+ $this->groupManager->method('isInGroup')->with('alice', 'finance')->willReturn(true);
+ $this->mapper->method('findBySecretAndGroup')->willThrowException(new DoesNotExistException('no'));
+ $this->mapper->expects($this->once())->method('insert')->willReturnArgument(0);
+
+ $result = $this->service->createGroupShare(secretId: 'src-1', groupId: 'finance', userId: 'alice');
+
+ $this->assertSame('finance', $result['groupShare']->getGroupId());
+ $this->assertSame(0, $result['skipped']);
+ }//end testCreateGroupShareAcceptsOwnGroupWhenRestricted()
+
+ /**
+ * sharing-02: when the administrator disabled group sharing in
+ * Nextcloud, keepiq does not offer a way around it.
+ *
+ * @return void
+ */
+ public function testCreateGroupShareRefusedWhenGroupSharingIsDisabled(): void {
+ $this->groupSharingAllowed = false;
+ $secret = $this->makeOwnerSecret('src-1', 'alice');
+ $this->secretMapper->method('findById')->willReturn($secret);
+ $this->groupManager->method('get')->willReturn($this->createMock(IGroup::class));
+ $this->mapper->expects($this->never())->method('insert');
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Group sharing is disabled');
+
+ $this->service->createGroupShare(secretId: 'src-1', groupId: 'finance', userId: 'alice');
+ }//end testCreateGroupShareRefusedWhenGroupSharingIsDisabled()
+
/**
* Test createGroupShare rejects unauthorized callers.
*
@@ -287,12 +384,30 @@ public function testRevokeGroupShareCascades(): void {
$entity->setSecretId('src-1');
$this->mapper->method('findById')->willReturn($entity);
+ $first = new ShareTarget();
+ $first->setId('st-bob');
+ $second = new ShareTarget();
+ $second->setId('st-carol');
+ $this->bulkGrantMapper->method('findByGroupShare')->with('gs-1')->willReturn([$first, $second]);
+
+ // sharing-02: each member's copy is revoked through the share
+ // revocation path, which deletes the recipient's Secret copy, not
+ // only the ShareTarget row.
+ $revoked = [];
+ $this->revocationService->expects($this->exactly(2))->method('revokeShare')
+ ->willReturnCallback(
+ static function (string $shareId, string $userId) use (&$revoked): void {
+ $revoked[] = $shareId . ':' . $userId;
+ }
+ );
$this->bulkGrantMapper->expects($this->once())
->method('deleteByGroupShare')
->with('gs-1');
$this->mapper->expects($this->once())->method('delete')->with($entity);
$this->service->revokeGroupShare(groupShareId: 'gs-1', userId: 'alice');
+
+ $this->assertSame(['st-bob:alice', 'st-carol:alice'], $revoked);
}//end testRevokeGroupShareCascades()
/**
diff --git a/tests/Unit/Service/ShareServiceTest.php b/tests/Unit/Service/ShareServiceTest.php
index ee5f2ca69..fedc23151 100644
--- a/tests/Unit/Service/ShareServiceTest.php
+++ b/tests/Unit/Service/ShareServiceTest.php
@@ -23,6 +23,8 @@
use InvalidArgumentException;
use OCA\Keepiq\Db\EncryptionSuite;
use OCA\Keepiq\Db\EncryptionSuiteMapper;
+use OCA\Keepiq\Db\GroupShare;
+use OCA\Keepiq\Db\GroupShareMapper;
use OCA\Keepiq\Db\Secret;
use OCA\Keepiq\Db\SecretDelegation;
use OCA\Keepiq\Db\SecretDelegationMapper;
@@ -58,6 +60,11 @@ class ShareServiceTest extends TestCase {
*/
private ShareService $service;
+ /**
+ * @var GroupShareMapper&MockObject
+ */
+ private GroupShareMapper $groupShareMapper;
+
/**
* Mock share-target mapper.
*
@@ -112,6 +119,7 @@ protected function setUp(): void {
$this->delegationMapper = $this->createMock(originalClassName: SecretDelegationMapper::class);
$this->notificationService = $this->createMock(originalClassName: NotificationService::class);
$this->db = $this->createMock(originalClassName: IDBConnection::class);
+ $this->groupShareMapper = $this->createMock(originalClassName: GroupShareMapper::class);
$logger = $this->createMock(originalClassName: LoggerInterface::class);
$this->service = $this->wireService(logger: $logger);
@@ -156,6 +164,7 @@ private function wireService(
secretMapper: $this->secretMapper,
copyFactory: $copyFactory,
notificationService: $this->notificationService,
+ groupShareMapper: $this->groupShareMapper,
),
syncService: new ShareSyncService(
mapper: $this->mapper,
@@ -703,6 +712,61 @@ static function ($row) use (&$inserted) {
$this->assertCount(1, $inserted);
}//end testRegisterDirectSharesIdempotentOwnerScopedReport()
+ /**
+ * sharing-02: a row carrying a groupShareId links the new ShareTarget
+ * to that group share, so revoking the group share revokes the copy.
+ * A group share of a DIFFERENT secret is refused as `invalid`, so a
+ * caller cannot hang a copy on someone else's group share.
+ *
+ * @return void
+ */
+ public function testRegisterDirectSharesLinksAGroupShareOfTheSameSecret(): void {
+ $mine = $this->makeOwnerSecret('sec-mine', 'alice');
+ $this->secretMapper->method('findById')->willReturn($mine);
+ $this->stubRecipientHasSuite();
+ $this->mapper->method('findBySourceSecretAndTargetUser')
+ ->willThrowException(new DoesNotExistException('no row'));
+
+ $ours = new GroupShare();
+ $ours->setId('gs-1');
+ $ours->setSecretId('sec-mine');
+ $theirs = new GroupShare();
+ $theirs->setId('gs-other');
+ $theirs->setSecretId('sec-bobs');
+ $this->groupShareMapper->method('findById')->willReturnCallback(
+ static function (string $id) use ($ours, $theirs): GroupShare {
+ return match ($id) {
+ 'gs-1' => $ours,
+ 'gs-other' => $theirs,
+ default => throw new DoesNotExistException('missing'),
+ };
+ }
+ );
+
+ $inserted = [];
+ $this->mapper->method('insert')->willReturnCallback(
+ static function ($row) use (&$inserted) {
+ $inserted[] = $row;
+ return $row;
+ }
+ );
+
+ $report = $this->service->registerDirectShares(
+ userId: 'alice',
+ shares: [
+ ['sourceSecretId' => 'sec-mine', 'targetUserId' => 'bob', 'encryptedKey' => 'BLOB', 'groupShareId' => 'gs-1'],
+ ['sourceSecretId' => 'sec-mine', 'targetUserId' => 'carol', 'encryptedKey' => 'BLOB', 'groupShareId' => 'gs-other'],
+ ['sourceSecretId' => 'sec-mine', 'targetUserId' => 'dave', 'encryptedKey' => 'BLOB', 'groupShareId' => 'gs-ghost'],
+ ]
+ );
+
+ $this->assertSame('created', $report[0]['status']);
+ $this->assertSame('invalid', $report[1]['status']);
+ $this->assertSame('invalid', $report[2]['status']);
+ $this->assertCount(1, $inserted);
+ $this->assertSame('gs-1', $inserted[0]->getGroupShareId());
+ }//end testRegisterDirectSharesLinksAGroupShareOfTheSameSecret()
+
/**
* bulk-actions §8.3: an already-shared pair is `exists` (idempotent
* resume) and a recipient without a suite is `no_suite` — neither
diff --git a/tests/components/GroupShareList.spec.js b/tests/components/GroupShareList.spec.js
new file mode 100644
index 000000000..52060be68
--- /dev/null
+++ b/tests/components/GroupShareList.spec.js
@@ -0,0 +1,71 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * GroupShareList + GroupShareForm (sharing-02): the owner opens Share with
+ * group, picks a group, and reads how many members received the secret;
+ * a group share can be revoked.
+ *
+ * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ */
+
+import axios from '@nextcloud/axios'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import GroupShareList from '../../src/components/share/GroupShareList.vue'
+import { useGroupShareStore } from '../../src/store/modules/groupShare.js'
+
+describe('GroupShareList', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.restoreAllMocks()
+ vi.spyOn(axios, 'get').mockResolvedValue({
+ data: [{ id: 'gs-1', groupId: 'finance' }],
+ })
+ })
+
+ it('lists the group shares of the secret', async () => {
+ const wrapper = mount(GroupShareList, { props: { secretId: 's-1' } })
+ await flushPromises()
+
+ const rows = wrapper.findAll('[data-testid="group-share-row"]')
+ expect(rows).toHaveLength(1)
+ expect(rows[0].text()).toContain('finance')
+ })
+
+ it('shares with the picked group and says how many members received it', async () => {
+ const wrapper = mount(GroupShareList, { props: { secretId: 's-1' } })
+ await flushPromises()
+ const store = useGroupShareStore()
+ const share = vi
+ .spyOn(store, 'shareWithGroup')
+ .mockResolvedValue({ received: 3, skipped: 1 })
+
+ await wrapper.find('[data-testid="group-share-open-form"]').trigger('click')
+ const form = wrapper.findComponent({ name: 'GroupShareForm' })
+ expect(form.exists()).toBe(true)
+ form.vm.selected = { id: 'finance', label: 'Finance' }
+ await form.find('form').trigger('submit')
+ await flushPromises()
+
+ expect(share).toHaveBeenCalledWith('s-1', 'finance')
+ expect(wrapper.findComponent({ name: 'GroupShareForm' }).exists()).toBe(false)
+ expect(wrapper.find('[data-testid="group-share-result"]').text()).toBe(
+ 'Shared with Finance: 3 members received it, 1 did not because they have no encryption set up yet.',
+ )
+ })
+
+ it('revokes a group share', async () => {
+ const del = vi.spyOn(axios, 'delete').mockResolvedValue({ data: {} })
+ const wrapper = mount(GroupShareList, { props: { secretId: 's-1' } })
+ await flushPromises()
+
+ await wrapper.find('[data-testid="group-share-row-revoke"]').trigger('click')
+ await flushPromises()
+
+ expect(del).toHaveBeenCalledWith('/apps/keepiq/api/v1/group-shares/gs-1')
+ expect(wrapper.findAll('[data-testid="group-share-row"]')).toHaveLength(0)
+ expect(wrapper.find('[data-testid="group-share-list-empty"]').exists()).toBe(true)
+ })
+})
diff --git a/tests/components/SecretDetailSidebar.sharing.spec.js b/tests/components/SecretDetailSidebar.sharing.spec.js
index 7fc40211e..6dc1a467a 100644
--- a/tests/components/SecretDetailSidebar.sharing.spec.js
+++ b/tests/components/SecretDetailSidebar.sharing.spec.js
@@ -37,6 +37,7 @@ const stubAll = {
CopyButton: { template: '' },
PasswordField: { template: '' },
ShareList: { template: '' },
+ GroupShareList: { template: '' },
DelegationManager: { template: '' },
ShareRequestForm: { template: '' },
SecretRequestList: { template: '' },
@@ -95,6 +96,10 @@ describe('SecretDetailSidebar sharing tab (§12.6)', () => {
expect(
wrapper.find('[data-testid="secret-detail-share-list"]').exists(),
).toBe(true)
+ // sharing-02: the owner is offered Share with group.
+ expect(
+ wrapper.find('[data-testid="secret-detail-group-share-list"]').exists(),
+ ).toBe(true)
expect(
wrapper
.find('[data-testid="secret-detail-delegation-manager"]')
@@ -117,6 +122,10 @@ describe('SecretDetailSidebar sharing tab (§12.6)', () => {
expect(
wrapper.find('[data-testid="secret-detail-share-list"]').exists(),
).toBe(false)
+ // sharing-02: a recipient is not offered Share with group.
+ expect(
+ wrapper.find('[data-testid="secret-detail-group-share-list"]').exists(),
+ ).toBe(false)
expect(
wrapper
.find('[data-testid="secret-detail-delegation-manager"]')
diff --git a/tests/store/groupShare.spec.js b/tests/store/groupShare.spec.js
new file mode 100644
index 000000000..9f1066472
--- /dev/null
+++ b/tests/store/groupShare.spec.js
@@ -0,0 +1,129 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * useGroupShareStore: the three group-share routes and the per-member
+ * fan-out through register-batch (sharing-02).
+ *
+ * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ */
+
+import axios from '@nextcloud/axios'
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useGroupShareStore } from '../../src/store/modules/groupShare.js'
+import { useSecretStore } from '../../src/store/modules/secret.js'
+import { useShareStore } from '../../src/store/modules/share.js'
+
+describe('useGroupShareStore', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.restoreAllMocks()
+ })
+
+ it('lists the group shares of a secret', async () => {
+ const get = vi
+ .spyOn(axios, 'get')
+ .mockResolvedValue({ data: [{ id: 'gs-1', groupId: 'finance' }] })
+ const store = useGroupShareStore()
+
+ await store.fetchGroupShares('s-1')
+
+ expect(get).toHaveBeenCalledWith(
+ '/apps/keepiq/api/v1/secrets/s-1/group-shares',
+ )
+ expect(store.groupShares).toEqual([{ id: 'gs-1', groupId: 'finance' }])
+ })
+
+ it('shares with a group: creates the group share, encrypts per member, registers the copies linked to it', async () => {
+ const post = vi.spyOn(axios, 'post').mockImplementation(async (url, body) => {
+ if (url.endsWith('/group-shares')) {
+ return {
+ data: {
+ groupShare: { id: 'gs-1', groupId: 'finance' },
+ members: [
+ { userId: 'bob', certificate: 'PEM-BOB' },
+ { userId: 'carol', certificate: 'PEM-CAROL' },
+ { userId: 'dave', certificate: 'PEM-DAVE' },
+ ],
+ skipped: 1,
+ },
+ }
+ }
+ return {
+ data: {
+ items: body.shares.map((row) => ({
+ targetUserId: row.targetUserId,
+ status: row.targetUserId === 'dave' ? 'no_suite' : 'created',
+ })),
+ },
+ }
+ })
+ useSecretStore().fetchSecret = vi
+ .fn()
+ .mockResolvedValue({ key: 'hunter2', login: 'alice', additionalFields: {} })
+ const encrypt = vi
+ .spyOn(useShareStore(), 'encryptForRecipient')
+ .mockImplementation(async (snapshot, cert) => ({ key: `enc(${snapshot.key},${cert})` }))
+ const store = useGroupShareStore()
+
+ const result = await store.shareWithGroup('s-1', 'finance')
+
+ expect(post).toHaveBeenNthCalledWith(
+ 1,
+ '/apps/keepiq/api/v1/secrets/s-1/group-shares',
+ { groupId: 'finance' },
+ )
+ expect(encrypt).toHaveBeenCalledTimes(3)
+ const [url, body] = post.mock.calls[1]
+ expect(url).toBe('/apps/keepiq/api/v1/shares/register-batch')
+ expect(body.shares[0]).toEqual({
+ sourceSecretId: 's-1',
+ targetUserId: 'bob',
+ encryptedKey: 'enc(hunter2,PEM-BOB)',
+ encryptedLogin: null,
+ encryptedAdditionalFields: null,
+ groupShareId: 'gs-1',
+ })
+ // bob and carol received it; dave failed at registration and the
+ // server skipped one member without a suite.
+ expect(result).toEqual({ received: 2, skipped: 2 })
+ expect(store.groupShares).toEqual([{ id: 'gs-1', groupId: 'finance' }])
+ })
+
+ it('revokes a group share and drops it from the list', async () => {
+ const del = vi.spyOn(axios, 'delete').mockResolvedValue({ data: {} })
+ const store = useGroupShareStore()
+ store.groupShares = [{ id: 'gs-1' }, { id: 'gs-2' }]
+
+ await store.revokeGroupShare('gs-1')
+
+ expect(del).toHaveBeenCalledWith('/apps/keepiq/api/v1/group-shares/gs-1')
+ expect(store.groupShares).toEqual([{ id: 'gs-2' }])
+ })
+
+ it('searches groups through Nextcloud sharee search, groups only', async () => {
+ const get = vi.spyOn(axios, 'get').mockResolvedValue({
+ data: {
+ ocs: {
+ data: {
+ exact: { groups: [{ label: 'Finance', value: { shareWith: 'finance' } }] },
+ groups: [
+ { label: 'Finance', value: { shareWith: 'finance' } },
+ { label: 'Board', value: { shareWith: 'board' } },
+ ],
+ },
+ },
+ },
+ })
+ const store = useGroupShareStore()
+
+ const groups = await store.searchGroups('fin')
+
+ expect(get.mock.calls[0][1].params).toMatchObject({ search: 'fin', shareType: 1 })
+ expect(groups).toEqual([
+ { id: 'finance', label: 'Finance' },
+ { id: 'board', label: 'Board' },
+ ])
+ })
+})
From fa8daf2cd3b1e8cc8c8158edf420c6e958352e0f Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 22:02:41 +0200
Subject: [PATCH 007/245] feat(sharing): share a secret with a Nextcloud group
from the secret sidebar
- GroupShareList in the sidebar (owners only): list, revoke, and a
Share with group action opening the reworked GroupShareForm (group
search via Nextcloud sharee search instead of a free-text id).
- useGroupShareStore: create the group share, encrypt the secret in the
tab for each member, register the copies through register-batch linked
to the group share, report received and skipped counts.
- GroupShareService honours Nextcloud's share settings (group sharing on,
own groups only) and reports members skipped for want of a suite;
revoking a group share revokes each member copy through
ShareRevocationService instead of only deleting the ShareTarget rows.
- DirectShareRegistrar accepts an optional groupShareId per row, only for
a group share of the same source secret.
---
l10n/be.js | 8 +-
l10n/be.json | 8 +-
l10n/bg.js | 8 +-
l10n/bg.json | 8 +-
l10n/bs.js | 8 +-
l10n/bs.json | 8 +-
l10n/ca.js | 8 +-
l10n/ca.json | 8 +-
l10n/cs.js | 8 +-
l10n/cs.json | 8 +-
l10n/da.js | 8 +-
l10n/da.json | 8 +-
l10n/de.js | 8 +-
l10n/de.json | 8 +-
l10n/el.js | 8 +-
l10n/el.json | 8 +-
l10n/en.js | 8 +-
l10n/en.json | 8 +-
l10n/es.js | 8 +-
l10n/es.json | 8 +-
l10n/et.js | 8 +-
l10n/et.json | 8 +-
l10n/fi.js | 8 +-
l10n/fi.json | 8 +-
l10n/fr.js | 8 +-
l10n/fr.json | 8 +-
l10n/ga.js | 8 +-
l10n/ga.json | 8 +-
l10n/hr.js | 8 +-
l10n/hr.json | 8 +-
l10n/hu.js | 8 +-
l10n/hu.json | 8 +-
l10n/is.js | 8 +-
l10n/is.json | 8 +-
l10n/it.js | 8 +-
l10n/it.json | 8 +-
l10n/lb.js | 8 +-
l10n/lb.json | 8 +-
l10n/lt.js | 8 +-
l10n/lt.json | 8 +-
l10n/lv.js | 8 +-
l10n/lv.json | 8 +-
l10n/mk.js | 8 +-
l10n/mk.json | 8 +-
l10n/mt.js | 8 +-
l10n/mt.json | 8 +-
l10n/nb.js | 8 +-
l10n/nb.json | 8 +-
l10n/nl.js | 8 +-
l10n/nl.json | 8 +-
l10n/pl.js | 8 +-
l10n/pl.json | 8 +-
l10n/pt.js | 8 +-
l10n/pt.json | 8 +-
l10n/rm.js | 8 +-
l10n/rm.json | 8 +-
l10n/ro.js | 8 +-
l10n/ro.json | 8 +-
l10n/ru.js | 8 +-
l10n/ru.json | 8 +-
l10n/sk.js | 8 +-
l10n/sk.json | 8 +-
l10n/sl.js | 8 +-
l10n/sl.json | 8 +-
l10n/sq.js | 8 +-
l10n/sq.json | 8 +-
l10n/sr.js | 8 +-
l10n/sr.json | 8 +-
l10n/sv.js | 8 +-
l10n/sv.json | 8 +-
l10n/tr.js | 8 +-
l10n/tr.json | 8 +-
l10n/uk.js | 8 +-
l10n/uk.json | 8 +-
lib/Controller/GroupShareController.php | 1 +
lib/Service/DirectShareRegistrar.php | 45 ++++-
lib/Service/GroupShareService.php | 56 +++++-
src/components/SecretDetailSidebar.vue | 7 +
src/components/share/GroupShareForm.vue | 160 ++++++-----------
src/components/share/GroupShareList.vue | 184 ++++++++++++++++++++
src/store/modules/groupShare.js | 222 ++++++++++++++++++++++++
81 files changed, 1085 insertions(+), 182 deletions(-)
create mode 100644 src/components/share/GroupShareList.vue
create mode 100644 src/store/modules/groupShare.js
diff --git a/l10n/be.js b/l10n/be.js
index 50406b31e..94cad4d96 100644
--- a/l10n/be.js
+++ b/l10n/be.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ратацыя ключа была адноўлена, таму гэтыя экстраныя кантакты не ўдалося перанесці, і іх надзвычайны доступ выдалены. Дадайце іх зноў у раздзеле «Надзвычайны доступ», калі яны вам яшчэ патрэбныя.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ратацыя ключа выдаліла %n экстраны кантакт. Праверце «Надзвычайны доступ» і дадайце яго зноў, калі ён вам яшчэ патрэбны.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ратацыя ключа выдаліла %n экстраных кантактаў. Праверце «Надзвычайны доступ» і дадайце іх зноў, калі яны вам яшчэ патрэбныя.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны.",
+ "Shared with groups": "Абагулена з групамі",
+ "Not shared with any group yet.": "Яшчэ не абагулена ні з адной групай.",
+ "Revoke the share with {group}": "Адклікаць абагульванне з {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Абагулена з {group}: {received} удзельнікаў атрымалі, {skipped} не, бо ў іх яшчэ не наладжана шыфраванне.",
+ "Search groups": "Шукаць групы",
+ "Failed to share": "Не ўдалося абагуліць"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/be.json b/l10n/be.json
index 0c3c43f17..add6f0ebc 100644
--- a/l10n/be.json
+++ b/l10n/be.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ратацыя ключа была адноўлена, таму гэтыя экстраныя кантакты не ўдалося перанесці, і іх надзвычайны доступ выдалены. Дадайце іх зноў у раздзеле «Надзвычайны доступ», калі яны вам яшчэ патрэбныя.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ратацыя ключа выдаліла %n экстраны кантакт. Праверце «Надзвычайны доступ» і дадайце яго зноў, калі ён вам яшчэ патрэбны.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ратацыя ключа выдаліла %n экстраных кантактаў. Праверце «Надзвычайны доступ» і дадайце іх зноў, калі яны вам яшчэ патрэбныя.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны.",
+ "Shared with groups": "Абагулена з групамі",
+ "Not shared with any group yet.": "Яшчэ не абагулена ні з адной групай.",
+ "Revoke the share with {group}": "Адклікаць абагульванне з {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Абагулена з {group}: {received} удзельнікаў атрымалі, {skipped} не, бо ў іх яшчэ не наладжана шыфраванне.",
+ "Search groups": "Шукаць групы",
+ "Failed to share": "Не ўдалося абагуліць"
},
"plurals": null
}
diff --git a/l10n/bg.js b/l10n/bg.js
index d50004eed..193fa3ae1 100644
--- a/l10n/bg.js
+++ b/l10n/bg.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацията на ключа беше възобновена, затова тези контакти за спешен достъп не можаха да бъдат пренесени и достъпът им при спешност беше премахнат. Добавете ги отново от „Достъп при спешност“, ако все още ги искате.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротацията на ключа премахна %n контакт за спешен достъп. Проверете „Достъп при спешност“ и го добавете отново, ако все още го искате.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротацията на ключа премахна %n контакта за спешен достъп. Проверете „Достъп при спешност“ и ги добавете отново, ако все още ги искате.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате.",
+ "Shared with groups": "Споделено с групи",
+ "Not shared with any group yet.": "Все още не е споделено с група.",
+ "Revoke the share with {group}": "Оттегляне на споделянето с {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Споделено с {group}: {received} членове го получиха, {skipped} не, защото все още нямат настроено шифроване.",
+ "Search groups": "Търсене на групи",
+ "Failed to share": "Споделянето не бе успешно"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/bg.json b/l10n/bg.json
index c28e98156..608e38034 100644
--- a/l10n/bg.json
+++ b/l10n/bg.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацията на ключа беше възобновена, затова тези контакти за спешен достъп не можаха да бъдат пренесени и достъпът им при спешност беше премахнат. Добавете ги отново от „Достъп при спешност“, ако все още ги искате.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротацията на ключа премахна %n контакт за спешен достъп. Проверете „Достъп при спешност“ и го добавете отново, ако все още го искате.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротацията на ключа премахна %n контакта за спешен достъп. Проверете „Достъп при спешност“ и ги добавете отново, ако все още ги искате.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате.",
+ "Shared with groups": "Споделено с групи",
+ "Not shared with any group yet.": "Все още не е споделено с група.",
+ "Revoke the share with {group}": "Оттегляне на споделянето с {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Споделено с {group}: {received} членове го получиха, {skipped} не, защото все още нямат настроено шифроване.",
+ "Search groups": "Търсене на групи",
+ "Failed to share": "Споделянето не бе успешно"
},
"plurals": null
}
diff --git a/l10n/bs.js b/l10n/bs.js
index c636188c9..275d8e52e 100644
--- a/l10n/bs.js
+++ b/l10n/bs.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa je nastavljena pa ovi kontakti za pristup u nuždi nisu mogli biti preneseni i njihov pristup u nuždi je uklonjen. Dodajte ih ponovo u odjeljku Pristup u nuždi ako ih još želite.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa je uklonila %n kontakt za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ga ponovo ako ga još želite.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa je uklonila %n kontakta za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ih ponovo ako ih još želite.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je uklonila pristup u nuždi ovog kontakta. Odredite ga ponovo ako ga još želite."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je uklonila pristup u nuždi ovog kontakta. Odredite ga ponovo ako ga još želite.",
+ "Shared with groups": "Dijeljeno s grupama",
+ "Not shared with any group yet.": "Još nije dijeljeno ni s jednom grupom.",
+ "Revoke the share with {group}": "Opozovi dijeljenje s grupom {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Dijeljeno s grupom {group}: {received} članova je to primilo, {skipped} nije jer još nisu postavili šifriranje.",
+ "Search groups": "Pretraži grupe",
+ "Failed to share": "Dijeljenje nije uspjelo"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/bs.json b/l10n/bs.json
index 288050ebe..c5c9a451b 100644
--- a/l10n/bs.json
+++ b/l10n/bs.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa je nastavljena pa ovi kontakti za pristup u nuždi nisu mogli biti preneseni i njihov pristup u nuždi je uklonjen. Dodajte ih ponovo u odjeljku Pristup u nuždi ako ih još želite.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa je uklonila %n kontakt za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ga ponovo ako ga još želite.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa je uklonila %n kontakta za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ih ponovo ako ih još želite.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je uklonila pristup u nuždi ovog kontakta. Odredite ga ponovo ako ga još želite."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je uklonila pristup u nuždi ovog kontakta. Odredite ga ponovo ako ga još želite.",
+ "Shared with groups": "Dijeljeno s grupama",
+ "Not shared with any group yet.": "Još nije dijeljeno ni s jednom grupom.",
+ "Revoke the share with {group}": "Opozovi dijeljenje s grupom {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Dijeljeno s grupom {group}: {received} članova je to primilo, {skipped} nije jer još nisu postavili šifriranje.",
+ "Search groups": "Pretraži grupe",
+ "Failed to share": "Dijeljenje nije uspjelo"
},
"plurals": null
}
diff --git a/l10n/ca.js b/l10n/ca.js
index 729e795b4..269903da0 100644
--- a/l10n/ca.js
+++ b/l10n/ca.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "La rotació de claus s'ha reprès, per tant aquests contactes d'emergència no s'han pogut traspassar i se'ls ha retirat l'accés d'emergència. Torna'ls a afegir des d'Accés d'emergència si encara els vols.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "La rotació de claus ha retirat %n contacte d'emergència. Revisa Accés d'emergència i torna'l a afegir si encara el vols.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "La rotació de claus ha retirat %n contactes d'emergència. Revisa Accés d'emergència i torna'ls a afegir si encara els vols.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotació de claus ha retirat l'accés d'emergència d'aquest contacte. Torna'l a designar si encara el vols."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotació de claus ha retirat l'accés d'emergència d'aquest contacte. Torna'l a designar si encara el vols.",
+ "Shared with groups": "Compartit amb grups",
+ "Not shared with any group yet.": "Encara no s'ha compartit amb cap grup.",
+ "Revoke the share with {group}": "Revoca la compartició amb {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Compartit amb {group}: {received} membres l'han rebut, {skipped} no perquè encara no han configurat el xifratge.",
+ "Search groups": "Cerca grups",
+ "Failed to share": "No s'ha pogut compartir"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/ca.json b/l10n/ca.json
index e60e36388..065a5a969 100644
--- a/l10n/ca.json
+++ b/l10n/ca.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "La rotació de claus s'ha reprès, per tant aquests contactes d'emergència no s'han pogut traspassar i se'ls ha retirat l'accés d'emergència. Torna'ls a afegir des d'Accés d'emergència si encara els vols.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "La rotació de claus ha retirat %n contacte d'emergència. Revisa Accés d'emergència i torna'l a afegir si encara el vols.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "La rotació de claus ha retirat %n contactes d'emergència. Revisa Accés d'emergència i torna'ls a afegir si encara els vols.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotació de claus ha retirat l'accés d'emergència d'aquest contacte. Torna'l a designar si encara el vols."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotació de claus ha retirat l'accés d'emergència d'aquest contacte. Torna'l a designar si encara el vols.",
+ "Shared with groups": "Compartit amb grups",
+ "Not shared with any group yet.": "Encara no s'ha compartit amb cap grup.",
+ "Revoke the share with {group}": "Revoca la compartició amb {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Compartit amb {group}: {received} membres l'han rebut, {skipped} no perquè encara no han configurat el xifratge.",
+ "Search groups": "Cerca grups",
+ "Failed to share": "No s'ha pogut compartir"
},
"plurals": null
}
diff --git a/l10n/cs.js b/l10n/cs.js
index 3ee623003..4d4318694 100644
--- a/l10n/cs.js
+++ b/l10n/cs.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotace klíče byla obnovena, a proto tyto nouzové kontakty nebylo možné převést a jejich přístup pro naléhavé případy byl odebrán. Pokud je stále chcete, přidejte je znovu v části Přístup pro naléhavé případy.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotace klíče odebrala %n nouzový kontakt. Zkontrolujte Přístup pro naléhavé případy a přidejte jej znovu, pokud jej stále chcete.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotace klíče odebrala %n nouzových kontaktů. Zkontrolujte Přístup pro naléhavé případy a přidejte je znovu, pokud je stále chcete.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotace klíče odebrala tomuto kontaktu přístup pro naléhavé případy. Pokud jej stále chcete, určete jej znovu."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotace klíče odebrala tomuto kontaktu přístup pro naléhavé případy. Pokud jej stále chcete, určete jej znovu.",
+ "Shared with groups": "Sdíleno se skupinami",
+ "Not shared with any group yet.": "Zatím nesdíleno s žádnou skupinou.",
+ "Revoke the share with {group}": "Zrušit sdílení se skupinou {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Sdíleno se skupinou {group}: {received} členů to obdrželo, {skipped} ne, protože ještě nemají nastavené šifrování.",
+ "Search groups": "Hledat skupiny",
+ "Failed to share": "Sdílení se nezdařilo"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/cs.json b/l10n/cs.json
index 2102e8a3a..6499e5ff7 100644
--- a/l10n/cs.json
+++ b/l10n/cs.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotace klíče byla obnovena, a proto tyto nouzové kontakty nebylo možné převést a jejich přístup pro naléhavé případy byl odebrán. Pokud je stále chcete, přidejte je znovu v části Přístup pro naléhavé případy.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotace klíče odebrala %n nouzový kontakt. Zkontrolujte Přístup pro naléhavé případy a přidejte jej znovu, pokud jej stále chcete.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotace klíče odebrala %n nouzových kontaktů. Zkontrolujte Přístup pro naléhavé případy a přidejte je znovu, pokud je stále chcete.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotace klíče odebrala tomuto kontaktu přístup pro naléhavé případy. Pokud jej stále chcete, určete jej znovu."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotace klíče odebrala tomuto kontaktu přístup pro naléhavé případy. Pokud jej stále chcete, určete jej znovu.",
+ "Shared with groups": "Sdíleno se skupinami",
+ "Not shared with any group yet.": "Zatím nesdíleno s žádnou skupinou.",
+ "Revoke the share with {group}": "Zrušit sdílení se skupinou {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Sdíleno se skupinou {group}: {received} členů to obdrželo, {skipped} ne, protože ještě nemají nastavené šifrování.",
+ "Search groups": "Hledat skupiny",
+ "Failed to share": "Sdílení se nezdařilo"
},
"plurals": null
}
diff --git a/l10n/da.js b/l10n/da.js
index e01e89365..d1347357f 100644
--- a/l10n/da.js
+++ b/l10n/da.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Din nøglerotation blev genoptaget, så disse nødkontakter kunne ikke overføres, og deres nødadgang blev fjernet. Tilføj dem igen under Nødadgang, hvis du stadig ønsker dem.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Din nøglerotation fjernede %n nødkontakt. Tjek Nødadgang, og tilføj den igen, hvis du stadig ønsker den.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Din nøglerotation fjernede %n nødkontakter. Tjek Nødadgang, og tilføj dem igen, hvis du stadig ønsker dem.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nøglerotation fjernede denne kontakts nødadgang. Udpeg den igen, hvis du stadig ønsker den."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nøglerotation fjernede denne kontakts nødadgang. Udpeg den igen, hvis du stadig ønsker den.",
+ "Shared with groups": "Delt med grupper",
+ "Not shared with any group yet.": "Endnu ikke delt med nogen gruppe.",
+ "Revoke the share with {group}": "Tilbagekald deling med {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delt med {group}: {received} medlemmer modtog den, {skipped} gjorde ikke, fordi de endnu ikke har opsat kryptering.",
+ "Search groups": "Søg efter grupper",
+ "Failed to share": "Deling mislykkedes"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/da.json b/l10n/da.json
index 5c843470c..cf1d7e423 100644
--- a/l10n/da.json
+++ b/l10n/da.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Din nøglerotation blev genoptaget, så disse nødkontakter kunne ikke overføres, og deres nødadgang blev fjernet. Tilføj dem igen under Nødadgang, hvis du stadig ønsker dem.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Din nøglerotation fjernede %n nødkontakt. Tjek Nødadgang, og tilføj den igen, hvis du stadig ønsker den.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Din nøglerotation fjernede %n nødkontakter. Tjek Nødadgang, og tilføj dem igen, hvis du stadig ønsker dem.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nøglerotation fjernede denne kontakts nødadgang. Udpeg den igen, hvis du stadig ønsker den."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nøglerotation fjernede denne kontakts nødadgang. Udpeg den igen, hvis du stadig ønsker den.",
+ "Shared with groups": "Delt med grupper",
+ "Not shared with any group yet.": "Endnu ikke delt med nogen gruppe.",
+ "Revoke the share with {group}": "Tilbagekald deling med {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delt med {group}: {received} medlemmer modtog den, {skipped} gjorde ikke, fordi de endnu ikke har opsat kryptering.",
+ "Search groups": "Søg efter grupper",
+ "Failed to share": "Deling mislykkedes"
},
"plurals": null
}
diff --git a/l10n/de.js b/l10n/de.js
index 5e7448c14..180be4e0a 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ihre Schlüsselrotation wurde fortgesetzt, daher konnten diese Notfallkontakte nicht übernommen werden und ihr Notfallzugriff wurde entfernt. Fügen Sie sie unter Notfallzugriff erneut hinzu, wenn Sie sie noch möchten.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ihre Schlüsselrotation hat %n Notfallkontakt entfernt. Prüfen Sie den Notfallzugriff und fügen Sie ihn erneut hinzu, wenn Sie ihn noch möchten.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ihre Schlüsselrotation hat %n Notfallkontakte entfernt. Prüfen Sie den Notfallzugriff und fügen Sie sie erneut hinzu, wenn Sie sie noch möchten.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ihre Schlüsselrotation hat den Notfallzugriff dieses Kontakts entfernt. Benennen Sie ihn erneut, wenn Sie ihn noch möchten."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ihre Schlüsselrotation hat den Notfallzugriff dieses Kontakts entfernt. Benennen Sie ihn erneut, wenn Sie ihn noch möchten.",
+ "Shared with groups": "Mit Gruppen geteilt",
+ "Not shared with any group yet.": "Noch mit keiner Gruppe geteilt.",
+ "Revoke the share with {group}": "Freigabe für {group} widerrufen",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Mit {group} geteilt: {received} Mitglieder haben es erhalten, {skipped} nicht, weil sie noch keine Verschlüsselung eingerichtet haben.",
+ "Search groups": "Gruppen suchen",
+ "Failed to share": "Teilen fehlgeschlagen"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/de.json b/l10n/de.json
index 0fe008b04..c3fbf1130 100644
--- a/l10n/de.json
+++ b/l10n/de.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ihre Schlüsselrotation wurde fortgesetzt, daher konnten diese Notfallkontakte nicht übernommen werden und ihr Notfallzugriff wurde entfernt. Fügen Sie sie unter Notfallzugriff erneut hinzu, wenn Sie sie noch möchten.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ihre Schlüsselrotation hat %n Notfallkontakt entfernt. Prüfen Sie den Notfallzugriff und fügen Sie ihn erneut hinzu, wenn Sie ihn noch möchten.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ihre Schlüsselrotation hat %n Notfallkontakte entfernt. Prüfen Sie den Notfallzugriff und fügen Sie sie erneut hinzu, wenn Sie sie noch möchten.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ihre Schlüsselrotation hat den Notfallzugriff dieses Kontakts entfernt. Benennen Sie ihn erneut, wenn Sie ihn noch möchten."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ihre Schlüsselrotation hat den Notfallzugriff dieses Kontakts entfernt. Benennen Sie ihn erneut, wenn Sie ihn noch möchten.",
+ "Shared with groups": "Mit Gruppen geteilt",
+ "Not shared with any group yet.": "Noch mit keiner Gruppe geteilt.",
+ "Revoke the share with {group}": "Freigabe für {group} widerrufen",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Mit {group} geteilt: {received} Mitglieder haben es erhalten, {skipped} nicht, weil sie noch keine Verschlüsselung eingerichtet haben.",
+ "Search groups": "Gruppen suchen",
+ "Failed to share": "Teilen fehlgeschlagen"
},
"plurals": null
}
diff --git a/l10n/el.js b/l10n/el.js
index 5b19d8f67..5f92864a5 100644
--- a/l10n/el.js
+++ b/l10n/el.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Η εναλλαγή κλειδιού συνεχίστηκε, οπότε αυτές οι επαφές έκτακτης ανάγκης δεν μπόρεσαν να μεταφερθούν και η πρόσβασή τους έκτακτης ανάγκης αφαιρέθηκε. Προσθέστε τις ξανά από την Πρόσβαση έκτακτης ανάγκης, αν τις θέλετε ακόμα.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Η εναλλαγή κλειδιού αφαίρεσε %n επαφή έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε την ξανά, αν τη θέλετε ακόμα.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε %n επαφές έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε τις ξανά, αν τις θέλετε ακόμα.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα.",
+ "Shared with groups": "Κοινοποιήθηκε σε ομάδες",
+ "Not shared with any group yet.": "Δεν έχει κοινοποιηθεί ακόμη σε καμία ομάδα.",
+ "Revoke the share with {group}": "Ανάκληση της κοινοποίησης στην ομάδα {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Κοινοποιήθηκε στην ομάδα {group}: {received} μέλη το έλαβαν, {skipped} όχι, επειδή δεν έχουν ρυθμίσει ακόμη κρυπτογράφηση.",
+ "Search groups": "Αναζήτηση ομάδων",
+ "Failed to share": "Η κοινοποίηση απέτυχε"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/el.json b/l10n/el.json
index c92d68429..e71ec62f3 100644
--- a/l10n/el.json
+++ b/l10n/el.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Η εναλλαγή κλειδιού συνεχίστηκε, οπότε αυτές οι επαφές έκτακτης ανάγκης δεν μπόρεσαν να μεταφερθούν και η πρόσβασή τους έκτακτης ανάγκης αφαιρέθηκε. Προσθέστε τις ξανά από την Πρόσβαση έκτακτης ανάγκης, αν τις θέλετε ακόμα.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Η εναλλαγή κλειδιού αφαίρεσε %n επαφή έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε την ξανά, αν τη θέλετε ακόμα.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε %n επαφές έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε τις ξανά, αν τις θέλετε ακόμα.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα.",
+ "Shared with groups": "Κοινοποιήθηκε σε ομάδες",
+ "Not shared with any group yet.": "Δεν έχει κοινοποιηθεί ακόμη σε καμία ομάδα.",
+ "Revoke the share with {group}": "Ανάκληση της κοινοποίησης στην ομάδα {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Κοινοποιήθηκε στην ομάδα {group}: {received} μέλη το έλαβαν, {skipped} όχι, επειδή δεν έχουν ρυθμίσει ακόμη κρυπτογράφηση.",
+ "Search groups": "Αναζήτηση ομάδων",
+ "Failed to share": "Η κοινοποίηση απέτυχε"
},
"plurals": null
}
diff --git a/l10n/en.js b/l10n/en.js
index 0d86028c6..fd13b8490 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Your key rotation removed this contact's emergency access. Designate them again if you still want them."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Your key rotation removed this contact's emergency access. Designate them again if you still want them.",
+ "Shared with groups": "Shared with groups",
+ "Not shared with any group yet.": "Not shared with any group yet.",
+ "Revoke the share with {group}": "Revoke the share with {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.",
+ "Search groups": "Search groups",
+ "Failed to share": "Failed to share"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 80c5bbbf7..8881d9ea1 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Your key rotation removed this contact's emergency access. Designate them again if you still want them."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Your key rotation removed this contact's emergency access. Designate them again if you still want them.",
+ "Shared with groups": "Shared with groups",
+ "Not shared with any group yet.": "Not shared with any group yet.",
+ "Revoke the share with {group}": "Revoke the share with {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.",
+ "Search groups": "Search groups",
+ "Failed to share": "Failed to share"
},
"plurals": "",
"pluralForm": "nplurals=2; plural=(n != 1);"
diff --git a/l10n/es.js b/l10n/es.js
index ceb54d2bb..2b3a62973 100644
--- a/l10n/es.js
+++ b/l10n/es.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Tu rotación de clave se reanudó, así que estos contactos de emergencia no se pudieron trasladar y se les retiró el acceso de emergencia. Vuelve a añadirlos desde Acceso de emergencia si aún los quieres.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Tu rotación de clave retiró %n contacto de emergencia. Revisa Acceso de emergencia y vuelve a añadirlo si aún lo quieres.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Tu rotación de clave retiró %n contactos de emergencia. Revisa Acceso de emergencia y vuelve a añadirlos si aún los quieres.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Tu rotación de clave retiró el acceso de emergencia de este contacto. Vuelve a designarlo si aún lo quieres."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Tu rotación de clave retiró el acceso de emergencia de este contacto. Vuelve a designarlo si aún lo quieres.",
+ "Shared with groups": "Compartido con grupos",
+ "Not shared with any group yet.": "Aún no se ha compartido con ningún grupo.",
+ "Revoke the share with {group}": "Revocar el uso compartido con {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Compartido con {group}: {received} miembros lo recibieron, {skipped} no porque aún no han configurado el cifrado.",
+ "Search groups": "Buscar grupos",
+ "Failed to share": "No se pudo compartir"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/es.json b/l10n/es.json
index a89a7df7a..3d1776bcc 100644
--- a/l10n/es.json
+++ b/l10n/es.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Tu rotación de clave se reanudó, así que estos contactos de emergencia no se pudieron trasladar y se les retiró el acceso de emergencia. Vuelve a añadirlos desde Acceso de emergencia si aún los quieres.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Tu rotación de clave retiró %n contacto de emergencia. Revisa Acceso de emergencia y vuelve a añadirlo si aún lo quieres.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Tu rotación de clave retiró %n contactos de emergencia. Revisa Acceso de emergencia y vuelve a añadirlos si aún los quieres.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Tu rotación de clave retiró el acceso de emergencia de este contacto. Vuelve a designarlo si aún lo quieres."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Tu rotación de clave retiró el acceso de emergencia de este contacto. Vuelve a designarlo si aún lo quieres.",
+ "Shared with groups": "Compartido con grupos",
+ "Not shared with any group yet.": "Aún no se ha compartido con ningún grupo.",
+ "Revoke the share with {group}": "Revocar el uso compartido con {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Compartido con {group}: {received} miembros lo recibieron, {skipped} no porque aún no han configurado el cifrado.",
+ "Search groups": "Buscar grupos",
+ "Failed to share": "No se pudo compartir"
},
"plurals": null
}
diff --git a/l10n/et.js b/l10n/et.js
index c78b1de8c..7bd3fffc4 100644
--- a/l10n/et.js
+++ b/l10n/et.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Sinu võtme rotatsiooni jätkati, seega neid hädaolukorra kontakte ei saanud üle kanda ja nende hädaolukorra ligipääs eemaldati. Lisa nad uuesti jaotises Hädaolukorra ligipääs, kui soovid neid endiselt.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa see uuesti, kui soovid seda endiselt.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa need uuesti, kui soovid neid endiselt.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Sinu võtme rotatsioon eemaldas selle kontakti hädaolukorra ligipääsu. Määra ta uuesti, kui soovid teda endiselt."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Sinu võtme rotatsioon eemaldas selle kontakti hädaolukorra ligipääsu. Määra ta uuesti, kui soovid teda endiselt.",
+ "Shared with groups": "Jagatud gruppidega",
+ "Not shared with any group yet.": "Pole veel ühegi grupiga jagatud.",
+ "Revoke the share with {group}": "Tühista jagamine grupiga {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Jagatud grupiga {group}: {received} liiget said selle kätte, {skipped} mitte, sest neil pole veel krüpteerimist seadistatud.",
+ "Search groups": "Otsi gruppe",
+ "Failed to share": "Jagamine ebaõnnestus"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/et.json b/l10n/et.json
index afece1dfd..d5e459f57 100644
--- a/l10n/et.json
+++ b/l10n/et.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Sinu võtme rotatsiooni jätkati, seega neid hädaolukorra kontakte ei saanud üle kanda ja nende hädaolukorra ligipääs eemaldati. Lisa nad uuesti jaotises Hädaolukorra ligipääs, kui soovid neid endiselt.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa see uuesti, kui soovid seda endiselt.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa need uuesti, kui soovid neid endiselt.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Sinu võtme rotatsioon eemaldas selle kontakti hädaolukorra ligipääsu. Määra ta uuesti, kui soovid teda endiselt."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Sinu võtme rotatsioon eemaldas selle kontakti hädaolukorra ligipääsu. Määra ta uuesti, kui soovid teda endiselt.",
+ "Shared with groups": "Jagatud gruppidega",
+ "Not shared with any group yet.": "Pole veel ühegi grupiga jagatud.",
+ "Revoke the share with {group}": "Tühista jagamine grupiga {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Jagatud grupiga {group}: {received} liiget said selle kätte, {skipped} mitte, sest neil pole veel krüpteerimist seadistatud.",
+ "Search groups": "Otsi gruppe",
+ "Failed to share": "Jagamine ebaõnnestus"
},
"plurals": null
}
diff --git a/l10n/fi.js b/l10n/fi.js
index a222cec2a..b99fc5dd3 100644
--- a/l10n/fi.js
+++ b/l10n/fi.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Avaimen kiertoa jatkettiin, joten näitä hätäyhteyshenkilöitä ei voitu siirtää ja heidän hätäkäyttöoikeutensa poistettiin. Lisää heidät uudelleen Hätäkäyttöoikeus-osiosta, jos haluat heidät yhä.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Avaimen kierto poisti %n hätäyhteyshenkilön. Tarkista Hätäkäyttöoikeus ja lisää hänet uudelleen, jos haluat hänet yhä.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Avaimen kierto poisti %n hätäyhteyshenkilöä. Tarkista Hätäkäyttöoikeus ja lisää heidät uudelleen, jos haluat heidät yhä.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Avaimen kierto poisti tämän yhteyshenkilön hätäkäyttöoikeuden. Nimeä hänet uudelleen, jos haluat hänet yhä."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Avaimen kierto poisti tämän yhteyshenkilön hätäkäyttöoikeuden. Nimeä hänet uudelleen, jos haluat hänet yhä.",
+ "Shared with groups": "Jaettu ryhmien kanssa",
+ "Not shared with any group yet.": "Ei vielä jaettu minkään ryhmän kanssa.",
+ "Revoke the share with {group}": "Peru jako ryhmän {group} kanssa",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Jaettu ryhmän {group} kanssa: {received} jäsentä sai sen, {skipped} ei saanut, koska heillä ei ole vielä salausta käytössä.",
+ "Search groups": "Hae ryhmiä",
+ "Failed to share": "Jakaminen epäonnistui"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/fi.json b/l10n/fi.json
index 0c8fab5ad..af09b0703 100644
--- a/l10n/fi.json
+++ b/l10n/fi.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Avaimen kiertoa jatkettiin, joten näitä hätäyhteyshenkilöitä ei voitu siirtää ja heidän hätäkäyttöoikeutensa poistettiin. Lisää heidät uudelleen Hätäkäyttöoikeus-osiosta, jos haluat heidät yhä.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Avaimen kierto poisti %n hätäyhteyshenkilön. Tarkista Hätäkäyttöoikeus ja lisää hänet uudelleen, jos haluat hänet yhä.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Avaimen kierto poisti %n hätäyhteyshenkilöä. Tarkista Hätäkäyttöoikeus ja lisää heidät uudelleen, jos haluat heidät yhä.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Avaimen kierto poisti tämän yhteyshenkilön hätäkäyttöoikeuden. Nimeä hänet uudelleen, jos haluat hänet yhä."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Avaimen kierto poisti tämän yhteyshenkilön hätäkäyttöoikeuden. Nimeä hänet uudelleen, jos haluat hänet yhä.",
+ "Shared with groups": "Jaettu ryhmien kanssa",
+ "Not shared with any group yet.": "Ei vielä jaettu minkään ryhmän kanssa.",
+ "Revoke the share with {group}": "Peru jako ryhmän {group} kanssa",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Jaettu ryhmän {group} kanssa: {received} jäsentä sai sen, {skipped} ei saanut, koska heillä ei ole vielä salausta käytössä.",
+ "Search groups": "Hae ryhmiä",
+ "Failed to share": "Jakaminen epäonnistui"
},
"plurals": null
}
diff --git a/l10n/fr.js b/l10n/fr.js
index f998d891e..58f2d6f4b 100644
--- a/l10n/fr.js
+++ b/l10n/fr.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Votre rotation de clé a été reprise ; ces contacts d'urgence n'ont donc pas pu être transférés et leur accès d'urgence a été supprimé. Ajoutez-les à nouveau depuis Accès d'urgence si vous les souhaitez toujours.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Votre rotation de clé a supprimé %n contact d'urgence. Vérifiez Accès d'urgence et ajoutez-le à nouveau si vous le souhaitez toujours.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Votre rotation de clé a supprimé %n contacts d'urgence. Vérifiez Accès d'urgence et ajoutez-les à nouveau si vous les souhaitez toujours.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Votre rotation de clé a supprimé l'accès d'urgence de ce contact. Désignez-le à nouveau si vous le souhaitez toujours."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Votre rotation de clé a supprimé l'accès d'urgence de ce contact. Désignez-le à nouveau si vous le souhaitez toujours.",
+ "Shared with groups": "Partagé avec des groupes",
+ "Not shared with any group yet.": "Pas encore partagé avec un groupe.",
+ "Revoke the share with {group}": "Révoquer le partage avec {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partagé avec {group} : {received} membres l'ont reçu, {skipped} non, car ils n'ont pas encore configuré le chiffrement.",
+ "Search groups": "Rechercher des groupes",
+ "Failed to share": "Échec du partage"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/fr.json b/l10n/fr.json
index 3eb24aab2..cd1ace62f 100644
--- a/l10n/fr.json
+++ b/l10n/fr.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Votre rotation de clé a été reprise ; ces contacts d'urgence n'ont donc pas pu être transférés et leur accès d'urgence a été supprimé. Ajoutez-les à nouveau depuis Accès d'urgence si vous les souhaitez toujours.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Votre rotation de clé a supprimé %n contact d'urgence. Vérifiez Accès d'urgence et ajoutez-le à nouveau si vous le souhaitez toujours.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Votre rotation de clé a supprimé %n contacts d'urgence. Vérifiez Accès d'urgence et ajoutez-les à nouveau si vous les souhaitez toujours.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Votre rotation de clé a supprimé l'accès d'urgence de ce contact. Désignez-le à nouveau si vous le souhaitez toujours."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Votre rotation de clé a supprimé l'accès d'urgence de ce contact. Désignez-le à nouveau si vous le souhaitez toujours.",
+ "Shared with groups": "Partagé avec des groupes",
+ "Not shared with any group yet.": "Pas encore partagé avec un groupe.",
+ "Revoke the share with {group}": "Révoquer le partage avec {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partagé avec {group} : {received} membres l'ont reçu, {skipped} non, car ils n'ont pas encore configuré le chiffrement.",
+ "Search groups": "Rechercher des groupes",
+ "Failed to share": "Échec du partage"
},
"plurals": null
}
diff --git a/l10n/ga.js b/l10n/ga.js
index ee9c8dfa3..5c1fcc2f9 100644
--- a/l10n/ga.js
+++ b/l10n/ga.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Atosaíodh do rothlú eochrach, mar sin níorbh fhéidir na teagmhálaithe éigeandála seo a thabhairt anonn agus baineadh a rochtain éigeandála. Cuir leis arís iad ó Rochtain éigeandála más mian leat iad fós.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Bhain do rothlú eochrach %n teagmhálaí éigeandála. Seiceáil Rochtain éigeandála agus cuir leis arís é más mian leat é fós.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Bhain do rothlú eochrach %n teagmhálaí éigeandála. Seiceáil Rochtain éigeandála agus cuir leis arís iad más mian leat iad fós.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Bhain do rothlú eochrach rochtain éigeandála an teagmhálaí seo. Ainmnigh arís é más mian leat é fós."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Bhain do rothlú eochrach rochtain éigeandála an teagmhálaí seo. Ainmnigh arís é más mian leat é fós.",
+ "Shared with groups": "Comhroinnte le grúpaí",
+ "Not shared with any group yet.": "Níor comhroinneadh le grúpa ar bith fós.",
+ "Revoke the share with {group}": "Cealaigh an chomhroinnt le {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Comhroinnte le {group}: fuair {received} ball é, ní bhfuair {skipped} é mar níl criptiú socraithe acu fós.",
+ "Search groups": "Cuardaigh grúpaí",
+ "Failed to share": "Theip ar an gcomhroinnt"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/ga.json b/l10n/ga.json
index 9ac575399..bf0cba2da 100644
--- a/l10n/ga.json
+++ b/l10n/ga.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Atosaíodh do rothlú eochrach, mar sin níorbh fhéidir na teagmhálaithe éigeandála seo a thabhairt anonn agus baineadh a rochtain éigeandála. Cuir leis arís iad ó Rochtain éigeandála más mian leat iad fós.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Bhain do rothlú eochrach %n teagmhálaí éigeandála. Seiceáil Rochtain éigeandála agus cuir leis arís é más mian leat é fós.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Bhain do rothlú eochrach %n teagmhálaí éigeandála. Seiceáil Rochtain éigeandála agus cuir leis arís iad más mian leat iad fós.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Bhain do rothlú eochrach rochtain éigeandála an teagmhálaí seo. Ainmnigh arís é más mian leat é fós."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Bhain do rothlú eochrach rochtain éigeandála an teagmhálaí seo. Ainmnigh arís é más mian leat é fós.",
+ "Shared with groups": "Comhroinnte le grúpaí",
+ "Not shared with any group yet.": "Níor comhroinneadh le grúpa ar bith fós.",
+ "Revoke the share with {group}": "Cealaigh an chomhroinnt le {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Comhroinnte le {group}: fuair {received} ball é, ní bhfuair {skipped} é mar níl criptiú socraithe acu fós.",
+ "Search groups": "Cuardaigh grúpaí",
+ "Failed to share": "Theip ar an gcomhroinnt"
},
"plurals": null
}
diff --git a/l10n/hr.js b/l10n/hr.js
index 3df6cc0c4..3d21715ee 100644
--- a/l10n/hr.js
+++ b/l10n/hr.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa je nastavljena pa ovi kontakti za pristup u nuždi nisu mogli biti preneseni i njihov pristup u nuždi je uklonjen. Dodajte ih ponovno u odjeljku Pristup u nuždi ako ih još želite.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa uklonila je %n kontakt za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ga ponovno ako ga još želite.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa uklonila je %n kontakta za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ih ponovno ako ih još želite.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa uklonila je pristup u nuždi ovog kontakta. Odredite ga ponovno ako ga još želite."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa uklonila je pristup u nuždi ovog kontakta. Odredite ga ponovno ako ga još želite.",
+ "Shared with groups": "Dijeljeno s grupama",
+ "Not shared with any group yet.": "Još nije dijeljeno ni s jednom grupom.",
+ "Revoke the share with {group}": "Opozovi dijeljenje s grupom {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Dijeljeno s grupom {group}: {received} članova je to primilo, {skipped} nije jer još nisu postavili šifriranje.",
+ "Search groups": "Pretraži grupe",
+ "Failed to share": "Dijeljenje nije uspjelo"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/hr.json b/l10n/hr.json
index 95ac3d5da..153937818 100644
--- a/l10n/hr.json
+++ b/l10n/hr.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa je nastavljena pa ovi kontakti za pristup u nuždi nisu mogli biti preneseni i njihov pristup u nuždi je uklonjen. Dodajte ih ponovno u odjeljku Pristup u nuždi ako ih još želite.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa uklonila je %n kontakt za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ga ponovno ako ga još želite.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa uklonila je %n kontakta za pristup u nuždi. Provjerite Pristup u nuždi i dodajte ih ponovno ako ih još želite.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa uklonila je pristup u nuždi ovog kontakta. Odredite ga ponovno ako ga još želite."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa uklonila je pristup u nuždi ovog kontakta. Odredite ga ponovno ako ga još želite.",
+ "Shared with groups": "Dijeljeno s grupama",
+ "Not shared with any group yet.": "Još nije dijeljeno ni s jednom grupom.",
+ "Revoke the share with {group}": "Opozovi dijeljenje s grupom {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Dijeljeno s grupom {group}: {received} članova je to primilo, {skipped} nije jer još nisu postavili šifriranje.",
+ "Search groups": "Pretraži grupe",
+ "Failed to share": "Dijeljenje nije uspjelo"
},
"plurals": null
}
diff --git a/l10n/hu.js b/l10n/hu.js
index 24b1f8d3a..33e49a74b 100644
--- a/l10n/hu.js
+++ b/l10n/hu.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "A kulcsrotáció folytatódott, ezért ezeket a vészhelyzeti kapcsolattartókat nem lehetett átvinni, és vészhelyzeti hozzáférésüket eltávolítottuk. Ha továbbra is szeretné őket, adja hozzá újra őket a Vészhelyzeti hozzáférés oldalon.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "A kulcsrotáció eltávolított %n vészhelyzeti kapcsolattartót. Nézze meg a Vészhelyzeti hozzáférést, és adja hozzá újra, ha továbbra is szeretné.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "A kulcsrotáció eltávolított %n vészhelyzeti kapcsolattartót. Nézze meg a Vészhelyzeti hozzáférést, és adja hozzá újra őket, ha továbbra is szeretné őket.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A kulcsrotáció eltávolította ennek a kapcsolattartónak a vészhelyzeti hozzáférését. Jelölje ki újra, ha továbbra is szeretné."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A kulcsrotáció eltávolította ennek a kapcsolattartónak a vészhelyzeti hozzáférését. Jelölje ki újra, ha továbbra is szeretné.",
+ "Shared with groups": "Megosztva csoportokkal",
+ "Not shared with any group yet.": "Még nincs megosztva egy csoporttal sem.",
+ "Revoke the share with {group}": "Megosztás visszavonása ezzel: {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Megosztva ezzel: {group}. {received} tag megkapta, {skipped} nem, mert még nem állított be titkosítást.",
+ "Search groups": "Csoportok keresése",
+ "Failed to share": "A megosztás sikertelen"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/hu.json b/l10n/hu.json
index d5bdabab7..a8bf75f41 100644
--- a/l10n/hu.json
+++ b/l10n/hu.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "A kulcsrotáció folytatódott, ezért ezeket a vészhelyzeti kapcsolattartókat nem lehetett átvinni, és vészhelyzeti hozzáférésüket eltávolítottuk. Ha továbbra is szeretné őket, adja hozzá újra őket a Vészhelyzeti hozzáférés oldalon.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "A kulcsrotáció eltávolított %n vészhelyzeti kapcsolattartót. Nézze meg a Vészhelyzeti hozzáférést, és adja hozzá újra, ha továbbra is szeretné.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "A kulcsrotáció eltávolított %n vészhelyzeti kapcsolattartót. Nézze meg a Vészhelyzeti hozzáférést, és adja hozzá újra őket, ha továbbra is szeretné őket.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A kulcsrotáció eltávolította ennek a kapcsolattartónak a vészhelyzeti hozzáférését. Jelölje ki újra, ha továbbra is szeretné."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A kulcsrotáció eltávolította ennek a kapcsolattartónak a vészhelyzeti hozzáférését. Jelölje ki újra, ha továbbra is szeretné.",
+ "Shared with groups": "Megosztva csoportokkal",
+ "Not shared with any group yet.": "Még nincs megosztva egy csoporttal sem.",
+ "Revoke the share with {group}": "Megosztás visszavonása ezzel: {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Megosztva ezzel: {group}. {received} tag megkapta, {skipped} nem, mert még nem állított be titkosítást.",
+ "Search groups": "Csoportok keresése",
+ "Failed to share": "A megosztás sikertelen"
},
"plurals": null
}
diff --git a/l10n/is.js b/l10n/is.js
index 1f65887dd..6cafcaa93 100644
--- a/l10n/is.js
+++ b/l10n/is.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Lyklasnúningurinn var hafinn aftur, svo ekki var hægt að færa þessa neyðartengiliði yfir og neyðaraðgangur þeirra var fjarlægður. Bættu þeim aftur við í Neyðaraðgangi ef þú vilt þá enn.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Lyklasnúningurinn fjarlægði %n neyðartengilið. Skoðaðu Neyðaraðgang og bættu honum aftur við ef þú vilt hann enn.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Lyklasnúningurinn fjarlægði %n neyðartengiliði. Skoðaðu Neyðaraðgang og bættu þeim aftur við ef þú vilt þá enn.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Lyklasnúningurinn fjarlægði neyðaraðgang þessa tengiliðar. Tilnefndu hann aftur ef þú vilt hann enn."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Lyklasnúningurinn fjarlægði neyðaraðgang þessa tengiliðar. Tilnefndu hann aftur ef þú vilt hann enn.",
+ "Shared with groups": "Deilt með hópum",
+ "Not shared with any group yet.": "Ekki enn deilt með neinum hópi.",
+ "Revoke the share with {group}": "Afturkalla deilingu með {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Deilt með {group}: {received} meðlimir fengu það, {skipped} ekki þar sem þeir hafa ekki enn sett upp dulkóðun.",
+ "Search groups": "Leita að hópum",
+ "Failed to share": "Ekki tókst að deila"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/is.json b/l10n/is.json
index 3b585ac7d..fdf6c8942 100644
--- a/l10n/is.json
+++ b/l10n/is.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Lyklasnúningurinn var hafinn aftur, svo ekki var hægt að færa þessa neyðartengiliði yfir og neyðaraðgangur þeirra var fjarlægður. Bættu þeim aftur við í Neyðaraðgangi ef þú vilt þá enn.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Lyklasnúningurinn fjarlægði %n neyðartengilið. Skoðaðu Neyðaraðgang og bættu honum aftur við ef þú vilt hann enn.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Lyklasnúningurinn fjarlægði %n neyðartengiliði. Skoðaðu Neyðaraðgang og bættu þeim aftur við ef þú vilt þá enn.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Lyklasnúningurinn fjarlægði neyðaraðgang þessa tengiliðar. Tilnefndu hann aftur ef þú vilt hann enn."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Lyklasnúningurinn fjarlægði neyðaraðgang þessa tengiliðar. Tilnefndu hann aftur ef þú vilt hann enn.",
+ "Shared with groups": "Deilt með hópum",
+ "Not shared with any group yet.": "Ekki enn deilt með neinum hópi.",
+ "Revoke the share with {group}": "Afturkalla deilingu með {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Deilt með {group}: {received} meðlimir fengu það, {skipped} ekki þar sem þeir hafa ekki enn sett upp dulkóðun.",
+ "Search groups": "Leita að hópum",
+ "Failed to share": "Ekki tókst að deila"
},
"plurals": null
}
diff --git a/l10n/it.js b/l10n/it.js
index 0ec8d27a6..e87b12ed8 100644
--- a/l10n/it.js
+++ b/l10n/it.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "La rotazione della chiave è stata ripresa, quindi questi contatti di emergenza non hanno potuto essere trasferiti e il loro accesso di emergenza è stato rimosso. Aggiungili di nuovo da Accesso di emergenza se li vuoi ancora.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "La rotazione della chiave ha rimosso %n contatto di emergenza. Controlla Accesso di emergenza e aggiungilo di nuovo se lo vuoi ancora.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "La rotazione della chiave ha rimosso %n contatti di emergenza. Controlla Accesso di emergenza e aggiungili di nuovo se li vuoi ancora.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotazione della chiave ha rimosso l'accesso di emergenza di questo contatto. Designalo di nuovo se lo vuoi ancora."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotazione della chiave ha rimosso l'accesso di emergenza di questo contatto. Designalo di nuovo se lo vuoi ancora.",
+ "Shared with groups": "Condiviso con gruppi",
+ "Not shared with any group yet.": "Non ancora condiviso con alcun gruppo.",
+ "Revoke the share with {group}": "Revoca la condivisione con {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Condiviso con {group}: {received} membri lo hanno ricevuto, {skipped} no perché non hanno ancora configurato la crittografia.",
+ "Search groups": "Cerca gruppi",
+ "Failed to share": "Condivisione non riuscita"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/it.json b/l10n/it.json
index f83c34aee..50f83ce02 100644
--- a/l10n/it.json
+++ b/l10n/it.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "La rotazione della chiave è stata ripresa, quindi questi contatti di emergenza non hanno potuto essere trasferiti e il loro accesso di emergenza è stato rimosso. Aggiungili di nuovo da Accesso di emergenza se li vuoi ancora.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "La rotazione della chiave ha rimosso %n contatto di emergenza. Controlla Accesso di emergenza e aggiungilo di nuovo se lo vuoi ancora.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "La rotazione della chiave ha rimosso %n contatti di emergenza. Controlla Accesso di emergenza e aggiungili di nuovo se li vuoi ancora.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotazione della chiave ha rimosso l'accesso di emergenza di questo contatto. Designalo di nuovo se lo vuoi ancora."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "La rotazione della chiave ha rimosso l'accesso di emergenza di questo contatto. Designalo di nuovo se lo vuoi ancora.",
+ "Shared with groups": "Condiviso con gruppi",
+ "Not shared with any group yet.": "Non ancora condiviso con alcun gruppo.",
+ "Revoke the share with {group}": "Revoca la condivisione con {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Condiviso con {group}: {received} membri lo hanno ricevuto, {skipped} no perché non hanno ancora configurato la crittografia.",
+ "Search groups": "Cerca gruppi",
+ "Failed to share": "Condivisione non riuscita"
},
"plurals": null
}
diff --git a/l10n/lb.js b/l10n/lb.js
index 14afc9239..911399530 100644
--- a/l10n/lb.js
+++ b/l10n/lb.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Är Schlësselrotatioun gouf weidergefouert, dofir konnten dës Noutfallkontakter net iwwerholl ginn an hiren Noutfallzougrëff gouf ewechgeholl. Setzt se nees bäi ënner Noutfallzougrëff, wann Dir se nach wëllt.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Är Schlësselrotatioun huet %n Noutfallkontakt ewechgeholl. Kuckt den Noutfallzougrëff a setzt en nees bäi, wann Dir en nach wëllt.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Är Schlësselrotatioun huet %n Noutfallkontakter ewechgeholl. Kuckt den Noutfallzougrëff a setzt se nees bäi, wann Dir se nach wëllt.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Är Schlësselrotatioun huet den Noutfallzougrëff vun dësem Kontakt ewechgeholl. Bestëmmt en nees, wann Dir en nach wëllt."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Är Schlësselrotatioun huet den Noutfallzougrëff vun dësem Kontakt ewechgeholl. Bestëmmt en nees, wann Dir en nach wëllt.",
+ "Shared with groups": "Mat Gruppen gedeelt",
+ "Not shared with any group yet.": "Nach mat kenger Grupp gedeelt.",
+ "Revoke the share with {group}": "Deele mat {group} zréckzéien",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Mat {group} gedeelt: {received} Memberen hunn et kritt, {skipped} net, well se nach keng Verschlësselung ageriicht hunn.",
+ "Search groups": "Gruppe sichen",
+ "Failed to share": "Deelen ass feelgeschloen"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/lb.json b/l10n/lb.json
index 2866ed47f..d0f73ddee 100644
--- a/l10n/lb.json
+++ b/l10n/lb.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Är Schlësselrotatioun gouf weidergefouert, dofir konnten dës Noutfallkontakter net iwwerholl ginn an hiren Noutfallzougrëff gouf ewechgeholl. Setzt se nees bäi ënner Noutfallzougrëff, wann Dir se nach wëllt.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Är Schlësselrotatioun huet %n Noutfallkontakt ewechgeholl. Kuckt den Noutfallzougrëff a setzt en nees bäi, wann Dir en nach wëllt.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Är Schlësselrotatioun huet %n Noutfallkontakter ewechgeholl. Kuckt den Noutfallzougrëff a setzt se nees bäi, wann Dir se nach wëllt.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Är Schlësselrotatioun huet den Noutfallzougrëff vun dësem Kontakt ewechgeholl. Bestëmmt en nees, wann Dir en nach wëllt."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Är Schlësselrotatioun huet den Noutfallzougrëff vun dësem Kontakt ewechgeholl. Bestëmmt en nees, wann Dir en nach wëllt.",
+ "Shared with groups": "Mat Gruppen gedeelt",
+ "Not shared with any group yet.": "Nach mat kenger Grupp gedeelt.",
+ "Revoke the share with {group}": "Deele mat {group} zréckzéien",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Mat {group} gedeelt: {received} Memberen hunn et kritt, {skipped} net, well se nach keng Verschlësselung ageriicht hunn.",
+ "Search groups": "Gruppe sichen",
+ "Failed to share": "Deelen ass feelgeschloen"
},
"plurals": null
}
diff --git a/l10n/lt.js b/l10n/lt.js
index 7ffe59c07..6c72fbedb 100644
--- a/l10n/lt.js
+++ b/l10n/lt.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rakto rotacija buvo pratęsta, todėl šių skubios prieigos kontaktų nepavyko perkelti ir jų prieiga nenumatytais atvejais pašalinta. Jei jų vis dar norite, vėl pridėkite juos skiltyje „Prieiga nenumatytais atvejais“.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rakto rotacija pašalino %n skubios prieigos kontaktą. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl jį pridėkite, jei jo vis dar norite.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rakto rotacija pašalino %n skubios prieigos kontaktus. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl juos pridėkite, jei jų vis dar norite.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rakto rotacija pašalino šio kontakto prieigą nenumatytais atvejais. Jei jo vis dar norite, paskirkite jį iš naujo."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rakto rotacija pašalino šio kontakto prieigą nenumatytais atvejais. Jei jo vis dar norite, paskirkite jį iš naujo.",
+ "Shared with groups": "Bendrinama su grupėmis",
+ "Not shared with any group yet.": "Dar nebendrinama su jokia grupe.",
+ "Revoke the share with {group}": "Atšaukti bendrinimą su {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Bendrinama su {group}: {received} nariai tai gavo, {skipped} negavo, nes dar nenustatė šifravimo.",
+ "Search groups": "Ieškoti grupių",
+ "Failed to share": "Nepavyko bendrinti"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/lt.json b/l10n/lt.json
index ae7bd2804..a0fccef37 100644
--- a/l10n/lt.json
+++ b/l10n/lt.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rakto rotacija buvo pratęsta, todėl šių skubios prieigos kontaktų nepavyko perkelti ir jų prieiga nenumatytais atvejais pašalinta. Jei jų vis dar norite, vėl pridėkite juos skiltyje „Prieiga nenumatytais atvejais“.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rakto rotacija pašalino %n skubios prieigos kontaktą. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl jį pridėkite, jei jo vis dar norite.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rakto rotacija pašalino %n skubios prieigos kontaktus. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl juos pridėkite, jei jų vis dar norite.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rakto rotacija pašalino šio kontakto prieigą nenumatytais atvejais. Jei jo vis dar norite, paskirkite jį iš naujo."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rakto rotacija pašalino šio kontakto prieigą nenumatytais atvejais. Jei jo vis dar norite, paskirkite jį iš naujo.",
+ "Shared with groups": "Bendrinama su grupėmis",
+ "Not shared with any group yet.": "Dar nebendrinama su jokia grupe.",
+ "Revoke the share with {group}": "Atšaukti bendrinimą su {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Bendrinama su {group}: {received} nariai tai gavo, {skipped} negavo, nes dar nenustatė šifravimo.",
+ "Search groups": "Ieškoti grupių",
+ "Failed to share": "Nepavyko bendrinti"
},
"plurals": null
}
diff --git a/l10n/lv.js b/l10n/lv.js
index 3227fb0ff..35c1e34fb 100644
--- a/l10n/lv.js
+++ b/l10n/lv.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Atslēgas rotācija tika atsākta, tāpēc šīs ārkārtas kontaktpersonas nevarēja pārnest un to ārkārtas piekļuve tika noņemta. Pievienojiet tās atkārtoti sadaļā Ārkārtas piekļuve, ja tās joprojām vēlaties.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Atslēgas rotācija noņēma %n ārkārtas kontaktpersonu. Pārbaudiet Ārkārtas piekļuvi un pievienojiet to atkārtoti, ja joprojām to vēlaties.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Atslēgas rotācija noņēma %n ārkārtas kontaktpersonas. Pārbaudiet Ārkārtas piekļuvi un pievienojiet tās atkārtoti, ja joprojām tās vēlaties.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Atslēgas rotācija noņēma šīs kontaktpersonas ārkārtas piekļuvi. Norīkojiet to atkārtoti, ja joprojām to vēlaties."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Atslēgas rotācija noņēma šīs kontaktpersonas ārkārtas piekļuvi. Norīkojiet to atkārtoti, ja joprojām to vēlaties.",
+ "Shared with groups": "Kopīgots ar grupām",
+ "Not shared with any group yet.": "Vēl nav kopīgots ne ar vienu grupu.",
+ "Revoke the share with {group}": "Atsaukt kopīgošanu ar {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Kopīgots ar {group}: {received} dalībnieki to saņēma, {skipped} nesaņēma, jo viņiem vēl nav iestatīta šifrēšana.",
+ "Search groups": "Meklēt grupas",
+ "Failed to share": "Kopīgošana neizdevās"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/lv.json b/l10n/lv.json
index 4e3f3fa42..4c6405bc7 100644
--- a/l10n/lv.json
+++ b/l10n/lv.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Atslēgas rotācija tika atsākta, tāpēc šīs ārkārtas kontaktpersonas nevarēja pārnest un to ārkārtas piekļuve tika noņemta. Pievienojiet tās atkārtoti sadaļā Ārkārtas piekļuve, ja tās joprojām vēlaties.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Atslēgas rotācija noņēma %n ārkārtas kontaktpersonu. Pārbaudiet Ārkārtas piekļuvi un pievienojiet to atkārtoti, ja joprojām to vēlaties.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Atslēgas rotācija noņēma %n ārkārtas kontaktpersonas. Pārbaudiet Ārkārtas piekļuvi un pievienojiet tās atkārtoti, ja joprojām tās vēlaties.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Atslēgas rotācija noņēma šīs kontaktpersonas ārkārtas piekļuvi. Norīkojiet to atkārtoti, ja joprojām to vēlaties."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Atslēgas rotācija noņēma šīs kontaktpersonas ārkārtas piekļuvi. Norīkojiet to atkārtoti, ja joprojām to vēlaties.",
+ "Shared with groups": "Kopīgots ar grupām",
+ "Not shared with any group yet.": "Vēl nav kopīgots ne ar vienu grupu.",
+ "Revoke the share with {group}": "Atsaukt kopīgošanu ar {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Kopīgots ar {group}: {received} dalībnieki to saņēma, {skipped} nesaņēma, jo viņiem vēl nav iestatīta šifrēšana.",
+ "Search groups": "Meklēt grupas",
+ "Failed to share": "Kopīgošana neizdevās"
},
"plurals": null
}
diff --git a/l10n/mk.js b/l10n/mk.js
index 21574658b..7bdb8ff47 100644
--- a/l10n/mk.js
+++ b/l10n/mk.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацијата на клучот беше продолжена, па овие контакти за итни случаи не можеа да се пренесат и нивниот пристап во итни случаи беше отстранет. Додајте ги повторно од „Пристап во итни случаи“ ако сè уште ги сакате.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротацијата на клучот отстрани %n контакт за итни случаи. Проверете „Пристап во итни случаи“ и додајте го повторно ако сè уште го сакате.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротацијата на клучот отстрани %n контакти за итни случаи. Проверете „Пристап во итни случаи“ и додајте ги повторно ако сè уште ги сакате.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате.",
+ "Shared with groups": "Споделено со групи",
+ "Not shared with any group yet.": "Сè уште не е споделено со ниедна група.",
+ "Revoke the share with {group}": "Отповикај го споделувањето со {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Споделено со {group}: {received} членови го примија, {skipped} не, бидејќи сè уште немаат поставено шифрирање.",
+ "Search groups": "Пребарај групи",
+ "Failed to share": "Споделувањето не успеа"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/mk.json b/l10n/mk.json
index 23ac65470..d04f2c54f 100644
--- a/l10n/mk.json
+++ b/l10n/mk.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацијата на клучот беше продолжена, па овие контакти за итни случаи не можеа да се пренесат и нивниот пристап во итни случаи беше отстранет. Додајте ги повторно од „Пристап во итни случаи“ ако сè уште ги сакате.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротацијата на клучот отстрани %n контакт за итни случаи. Проверете „Пристап во итни случаи“ и додајте го повторно ако сè уште го сакате.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротацијата на клучот отстрани %n контакти за итни случаи. Проверете „Пристап во итни случаи“ и додајте ги повторно ако сè уште ги сакате.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате.",
+ "Shared with groups": "Споделено со групи",
+ "Not shared with any group yet.": "Сè уште не е споделено со ниедна група.",
+ "Revoke the share with {group}": "Отповикај го споделувањето со {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Споделено со {group}: {received} членови го примија, {skipped} не, бидејќи сè уште немаат поставено шифрирање.",
+ "Search groups": "Пребарај групи",
+ "Failed to share": "Споделувањето не успеа"
},
"plurals": null
}
diff --git a/l10n/mt.js b/l10n/mt.js
index 18400c936..110b928ea 100644
--- a/l10n/mt.js
+++ b/l10n/mt.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek tkompliet, għalhekk dawn il-kuntatti ta' emerġenza ma setgħux jiġu trasferiti u l-aċċess ta' emerġenza tagħhom tneħħa. Erġa' żidhom minn Aċċess ta' emerġenza jekk għadek tridhom.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet %n kuntatt ta' emerġenza. Iċċekkja Aċċess ta' emerġenza u erġa' żidu jekk għadek tridu.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet %n kuntatti ta' emerġenza. Iċċekkja Aċċess ta' emerġenza u erġa' żidhom jekk għadek tridhom.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet l-aċċess ta' emerġenza ta' dan il-kuntatt. Erġa' aħtru jekk għadek tridu."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet l-aċċess ta' emerġenza ta' dan il-kuntatt. Erġa' aħtru jekk għadek tridu.",
+ "Shared with groups": "Maqsum ma' gruppi",
+ "Not shared with any group yet.": "Għadu mhux maqsum ma' ebda grupp.",
+ "Revoke the share with {group}": "Irrevoka l-qsim ma' {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Maqsum ma' {group}: {received} membri rċevewh, {skipped} le għax għadhom ma waqqfux il-kriptaġġ.",
+ "Search groups": "Fittex gruppi",
+ "Failed to share": "Il-qsim ma rnexxiex"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/mt.json b/l10n/mt.json
index 0eec20332..d01596570 100644
--- a/l10n/mt.json
+++ b/l10n/mt.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek tkompliet, għalhekk dawn il-kuntatti ta' emerġenza ma setgħux jiġu trasferiti u l-aċċess ta' emerġenza tagħhom tneħħa. Erġa' żidhom minn Aċċess ta' emerġenza jekk għadek tridhom.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet %n kuntatt ta' emerġenza. Iċċekkja Aċċess ta' emerġenza u erġa' żidu jekk għadek tridu.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet %n kuntatti ta' emerġenza. Iċċekkja Aċċess ta' emerġenza u erġa' żidhom jekk għadek tridhom.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet l-aċċess ta' emerġenza ta' dan il-kuntatt. Erġa' aħtru jekk għadek tridu."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ir-rotazzjoni taċ-ċavetta tiegħek neħħiet l-aċċess ta' emerġenza ta' dan il-kuntatt. Erġa' aħtru jekk għadek tridu.",
+ "Shared with groups": "Maqsum ma' gruppi",
+ "Not shared with any group yet.": "Għadu mhux maqsum ma' ebda grupp.",
+ "Revoke the share with {group}": "Irrevoka l-qsim ma' {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Maqsum ma' {group}: {received} membri rċevewh, {skipped} le għax għadhom ma waqqfux il-kriptaġġ.",
+ "Search groups": "Fittex gruppi",
+ "Failed to share": "Il-qsim ma rnexxiex"
},
"plurals": null
}
diff --git a/l10n/nb.js b/l10n/nb.js
index ee04f8ff6..ac54ceb11 100644
--- a/l10n/nb.js
+++ b/l10n/nb.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Nøkkelrotasjonen ble gjenopptatt, så disse nødkontaktene kunne ikke overføres, og nødtilgangen deres ble fjernet. Legg dem til igjen under Nødtilgang hvis du fortsatt vil ha dem.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Nøkkelrotasjonen fjernet %n nødkontakt. Sjekk Nødtilgang og legg den til igjen hvis du fortsatt vil ha den.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Nøkkelrotasjonen fjernet %n nødkontakter. Sjekk Nødtilgang og legg dem til igjen hvis du fortsatt vil ha dem.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Nøkkelrotasjonen fjernet nødtilgangen til denne kontakten. Utpek den på nytt hvis du fortsatt vil ha den."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Nøkkelrotasjonen fjernet nødtilgangen til denne kontakten. Utpek den på nytt hvis du fortsatt vil ha den.",
+ "Shared with groups": "Delt med grupper",
+ "Not shared with any group yet.": "Ikke delt med noen gruppe ennå.",
+ "Revoke the share with {group}": "Trekk tilbake delingen med {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delt med {group}: {received} medlemmer mottok den, {skipped} gjorde det ikke fordi de ikke har satt opp kryptering ennå.",
+ "Search groups": "Søk etter grupper",
+ "Failed to share": "Deling mislyktes"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nb.json b/l10n/nb.json
index 5d3a21206..9b0d6b369 100644
--- a/l10n/nb.json
+++ b/l10n/nb.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Nøkkelrotasjonen ble gjenopptatt, så disse nødkontaktene kunne ikke overføres, og nødtilgangen deres ble fjernet. Legg dem til igjen under Nødtilgang hvis du fortsatt vil ha dem.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Nøkkelrotasjonen fjernet %n nødkontakt. Sjekk Nødtilgang og legg den til igjen hvis du fortsatt vil ha den.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Nøkkelrotasjonen fjernet %n nødkontakter. Sjekk Nødtilgang og legg dem til igjen hvis du fortsatt vil ha dem.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Nøkkelrotasjonen fjernet nødtilgangen til denne kontakten. Utpek den på nytt hvis du fortsatt vil ha den."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Nøkkelrotasjonen fjernet nødtilgangen til denne kontakten. Utpek den på nytt hvis du fortsatt vil ha den.",
+ "Shared with groups": "Delt med grupper",
+ "Not shared with any group yet.": "Ikke delt med noen gruppe ennå.",
+ "Revoke the share with {group}": "Trekk tilbake delingen med {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delt med {group}: {received} medlemmer mottok den, {skipped} gjorde det ikke fordi de ikke har satt opp kryptering ennå.",
+ "Search groups": "Søk etter grupper",
+ "Failed to share": "Deling mislyktes"
},
"plurals": null
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 21d00b618..c983cc1d7 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1187,7 +1187,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Je sleutelrotatie is hervat, dus deze noodcontacten konden niet worden meegenomen en hun noodtoegang is verwijderd. Voeg ze opnieuw toe via Noodtoegang als je ze nog wilt.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Je sleutelrotatie heeft %n noodcontact verwijderd. Kijk bij Noodtoegang en voeg het opnieuw toe als je het nog wilt.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Je sleutelrotatie heeft %n noodcontacten verwijderd. Kijk bij Noodtoegang en voeg ze opnieuw toe als je ze nog wilt.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Je sleutelrotatie heeft de noodtoegang van dit contact verwijderd. Wijs het opnieuw aan als je het nog wilt."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Je sleutelrotatie heeft de noodtoegang van dit contact verwijderd. Wijs het opnieuw aan als je het nog wilt.",
+ "Shared with groups": "Gedeeld met groepen",
+ "Not shared with any group yet.": "Nog met geen enkele groep gedeeld.",
+ "Revoke the share with {group}": "Deling met {group} intrekken",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Gedeeld met {group}: {received} leden hebben het ontvangen, {skipped} niet omdat ze nog geen versleuteling hebben ingesteld.",
+ "Search groups": "Groepen zoeken",
+ "Failed to share": "Delen mislukt"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 88c6d4e99..5999b549b 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1186,7 +1186,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Je sleutelrotatie is hervat, dus deze noodcontacten konden niet worden meegenomen en hun noodtoegang is verwijderd. Voeg ze opnieuw toe via Noodtoegang als je ze nog wilt.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Je sleutelrotatie heeft %n noodcontact verwijderd. Kijk bij Noodtoegang en voeg het opnieuw toe als je het nog wilt.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Je sleutelrotatie heeft %n noodcontacten verwijderd. Kijk bij Noodtoegang en voeg ze opnieuw toe als je ze nog wilt.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Je sleutelrotatie heeft de noodtoegang van dit contact verwijderd. Wijs het opnieuw aan als je het nog wilt."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Je sleutelrotatie heeft de noodtoegang van dit contact verwijderd. Wijs het opnieuw aan als je het nog wilt.",
+ "Shared with groups": "Gedeeld met groepen",
+ "Not shared with any group yet.": "Nog met geen enkele groep gedeeld.",
+ "Revoke the share with {group}": "Deling met {group} intrekken",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Gedeeld met {group}: {received} leden hebben het ontvangen, {skipped} niet omdat ze nog geen versleuteling hebben ingesteld.",
+ "Search groups": "Groepen zoeken",
+ "Failed to share": "Delen mislukt"
},
"plurals": null,
"pluralForm": "nplurals=2; plural=(n != 1);"
diff --git a/l10n/pl.js b/l10n/pl.js
index e58acc3f2..82eabf2f6 100644
--- a/l10n/pl.js
+++ b/l10n/pl.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacja klucza została wznowiona, więc tych kontaktów awaryjnych nie dało się przenieść, a ich dostęp awaryjny został usunięty. Dodaj je ponownie w sekcji Dostęp awaryjny, jeśli nadal ich chcesz.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacja klucza usunęła %n kontakt awaryjny. Sprawdź Dostęp awaryjny i dodaj go ponownie, jeśli nadal go chcesz.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacja klucza usunęła %n kontaktów awaryjnych. Sprawdź Dostęp awaryjny i dodaj je ponownie, jeśli nadal ich chcesz.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacja klucza usunęła dostęp awaryjny tego kontaktu. Wyznacz go ponownie, jeśli nadal go chcesz."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacja klucza usunęła dostęp awaryjny tego kontaktu. Wyznacz go ponownie, jeśli nadal go chcesz.",
+ "Shared with groups": "Udostępniono grupom",
+ "Not shared with any group yet.": "Jeszcze nie udostępniono żadnej grupie.",
+ "Revoke the share with {group}": "Cofnij udostępnienie dla {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Udostępniono grupie {group}: {received} członków otrzymało, {skipped} nie, ponieważ nie skonfigurowali jeszcze szyfrowania.",
+ "Search groups": "Szukaj grup",
+ "Failed to share": "Nie udało się udostępnić"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/pl.json b/l10n/pl.json
index b4b1c25e2..0147c0596 100644
--- a/l10n/pl.json
+++ b/l10n/pl.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacja klucza została wznowiona, więc tych kontaktów awaryjnych nie dało się przenieść, a ich dostęp awaryjny został usunięty. Dodaj je ponownie w sekcji Dostęp awaryjny, jeśli nadal ich chcesz.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacja klucza usunęła %n kontakt awaryjny. Sprawdź Dostęp awaryjny i dodaj go ponownie, jeśli nadal go chcesz.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacja klucza usunęła %n kontaktów awaryjnych. Sprawdź Dostęp awaryjny i dodaj je ponownie, jeśli nadal ich chcesz.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacja klucza usunęła dostęp awaryjny tego kontaktu. Wyznacz go ponownie, jeśli nadal go chcesz."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacja klucza usunęła dostęp awaryjny tego kontaktu. Wyznacz go ponownie, jeśli nadal go chcesz.",
+ "Shared with groups": "Udostępniono grupom",
+ "Not shared with any group yet.": "Jeszcze nie udostępniono żadnej grupie.",
+ "Revoke the share with {group}": "Cofnij udostępnienie dla {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Udostępniono grupie {group}: {received} członków otrzymało, {skipped} nie, ponieważ nie skonfigurowali jeszcze szyfrowania.",
+ "Search groups": "Szukaj grup",
+ "Failed to share": "Nie udało się udostępnić"
},
"plurals": null
}
diff --git a/l10n/pt.js b/l10n/pt.js
index f0d8cf023..8e8e4cbc5 100644
--- a/l10n/pt.js
+++ b/l10n/pt.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "A rotação da chave foi retomada, pelo que estes contactos de emergência não puderam ser transferidos e o seu acesso de emergência foi removido. Adicione-os novamente em Acesso de emergência se ainda os quiser.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "A rotação da chave removeu %n contacto de emergência. Verifique Acesso de emergência e adicione-o novamente se ainda o quiser.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "A rotação da chave removeu %n contactos de emergência. Verifique Acesso de emergência e adicione-os novamente se ainda os quiser.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A rotação da chave removeu o acesso de emergência deste contacto. Designe-o novamente se ainda o quiser."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A rotação da chave removeu o acesso de emergência deste contacto. Designe-o novamente se ainda o quiser.",
+ "Shared with groups": "Partilhado com grupos",
+ "Not shared with any group yet.": "Ainda não partilhado com nenhum grupo.",
+ "Revoke the share with {group}": "Revogar a partilha com {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partilhado com {group}: {received} membros receberam-no, {skipped} não porque ainda não configuraram a encriptação.",
+ "Search groups": "Pesquisar grupos",
+ "Failed to share": "Falha ao partilhar"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/pt.json b/l10n/pt.json
index 62b8983d7..4043768b1 100644
--- a/l10n/pt.json
+++ b/l10n/pt.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "A rotação da chave foi retomada, pelo que estes contactos de emergência não puderam ser transferidos e o seu acesso de emergência foi removido. Adicione-os novamente em Acesso de emergência se ainda os quiser.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "A rotação da chave removeu %n contacto de emergência. Verifique Acesso de emergência e adicione-o novamente se ainda o quiser.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "A rotação da chave removeu %n contactos de emergência. Verifique Acesso de emergência e adicione-os novamente se ainda os quiser.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A rotação da chave removeu o acesso de emergência deste contacto. Designe-o novamente se ainda o quiser."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "A rotação da chave removeu o acesso de emergência deste contacto. Designe-o novamente se ainda o quiser.",
+ "Shared with groups": "Partilhado com grupos",
+ "Not shared with any group yet.": "Ainda não partilhado com nenhum grupo.",
+ "Revoke the share with {group}": "Revogar a partilha com {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partilhado com {group}: {received} membros receberam-no, {skipped} não porque ainda não configuraram a encriptação.",
+ "Search groups": "Pesquisar grupos",
+ "Failed to share": "Falha ao partilhar"
},
"plurals": null
}
diff --git a/l10n/rm.js b/l10n/rm.js
index 2bfdbd8a1..1ba6f84c5 100644
--- a/l10n/rm.js
+++ b/l10n/rm.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Vossa rotaziun da la clav è vegnida cuntinuada, perquai n'hai quests contacts d'urgenza betg pudì vegnir transferids ed lur access d'urgenza è vegnì allontanà. Agiuntai els danovamain sut Access d'urgenza, sche Vus als vulais anc.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Vossa rotaziun da la clav ha allontanà %n contact d'urgenza. Controllai Access d'urgenza ed agiuntai el danovamain, sche Vus al vulais anc.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Vossa rotaziun da la clav ha allontanà %n contacts d'urgenza. Controllai Access d'urgenza ed agiuntai els danovamain, sche Vus als vulais anc.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Vossa rotaziun da la clav ha allontanà l'access d'urgenza da quest contact. Designai el danovamain, sche Vus al vulais anc."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Vossa rotaziun da la clav ha allontanà l'access d'urgenza da quest contact. Designai el danovamain, sche Vus al vulais anc.",
+ "Shared with groups": "Partì cun gruppas",
+ "Not shared with any group yet.": "Anc betg partì cun ina gruppa.",
+ "Revoke the share with {group}": "Revocar la partiziun cun {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partì cun {group}: {received} commembers han retschavì quai, {skipped} betg, perquai ch'els n'han anc betg configurà il criptadi.",
+ "Search groups": "Tschertgar gruppas",
+ "Failed to share": "La partiziun n'è betg reussida"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/rm.json b/l10n/rm.json
index 1117edb2c..cbadc16c0 100644
--- a/l10n/rm.json
+++ b/l10n/rm.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Vossa rotaziun da la clav è vegnida cuntinuada, perquai n'hai quests contacts d'urgenza betg pudì vegnir transferids ed lur access d'urgenza è vegnì allontanà. Agiuntai els danovamain sut Access d'urgenza, sche Vus als vulais anc.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Vossa rotaziun da la clav ha allontanà %n contact d'urgenza. Controllai Access d'urgenza ed agiuntai el danovamain, sche Vus al vulais anc.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Vossa rotaziun da la clav ha allontanà %n contacts d'urgenza. Controllai Access d'urgenza ed agiuntai els danovamain, sche Vus als vulais anc.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Vossa rotaziun da la clav ha allontanà l'access d'urgenza da quest contact. Designai el danovamain, sche Vus al vulais anc."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Vossa rotaziun da la clav ha allontanà l'access d'urgenza da quest contact. Designai el danovamain, sche Vus al vulais anc.",
+ "Shared with groups": "Partì cun gruppas",
+ "Not shared with any group yet.": "Anc betg partì cun ina gruppa.",
+ "Revoke the share with {group}": "Revocar la partiziun cun {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partì cun {group}: {received} commembers han retschavì quai, {skipped} betg, perquai ch'els n'han anc betg configurà il criptadi.",
+ "Search groups": "Tschertgar gruppas",
+ "Failed to share": "La partiziun n'è betg reussida"
},
"plurals": null
}
diff --git a/l10n/ro.js b/l10n/ro.js
index 0a535d6b6..198593cfc 100644
--- a/l10n/ro.js
+++ b/l10n/ro.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotația cheii a fost reluată, așa că aceste contacte de urgență nu au putut fi transferate, iar accesul lor de urgență a fost eliminat. Adăugați-le din nou din Acces de urgență dacă le mai doriți.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotația cheii a eliminat %n contact de urgență. Verificați Acces de urgență și adăugați-l din nou dacă îl mai doriți.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotația cheii a eliminat %n contacte de urgență. Verificați Acces de urgență și adăugați-le din nou dacă le mai doriți.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotația cheii a eliminat accesul de urgență al acestui contact. Desemnați-l din nou dacă îl mai doriți."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotația cheii a eliminat accesul de urgență al acestui contact. Desemnați-l din nou dacă îl mai doriți.",
+ "Shared with groups": "Partajat cu grupuri",
+ "Not shared with any group yet.": "Încă nu este partajat cu niciun grup.",
+ "Revoke the share with {group}": "Revocă partajarea cu {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partajat cu {group}: {received} membri l-au primit, {skipped} nu, deoarece nu au configurat încă criptarea.",
+ "Search groups": "Caută grupuri",
+ "Failed to share": "Partajarea a eșuat"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/ro.json b/l10n/ro.json
index d874ffa9a..47c44dd4b 100644
--- a/l10n/ro.json
+++ b/l10n/ro.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotația cheii a fost reluată, așa că aceste contacte de urgență nu au putut fi transferate, iar accesul lor de urgență a fost eliminat. Adăugați-le din nou din Acces de urgență dacă le mai doriți.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotația cheii a eliminat %n contact de urgență. Verificați Acces de urgență și adăugați-l din nou dacă îl mai doriți.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotația cheii a eliminat %n contacte de urgență. Verificați Acces de urgență și adăugați-le din nou dacă le mai doriți.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotația cheii a eliminat accesul de urgență al acestui contact. Desemnați-l din nou dacă îl mai doriți."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotația cheii a eliminat accesul de urgență al acestui contact. Desemnați-l din nou dacă îl mai doriți.",
+ "Shared with groups": "Partajat cu grupuri",
+ "Not shared with any group yet.": "Încă nu este partajat cu niciun grup.",
+ "Revoke the share with {group}": "Revocă partajarea cu {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partajat cu {group}: {received} membri l-au primit, {skipped} nu, deoarece nu au configurat încă criptarea.",
+ "Search groups": "Caută grupuri",
+ "Failed to share": "Partajarea a eșuat"
},
"plurals": null
}
diff --git a/l10n/ru.js b/l10n/ru.js
index 51346f251..4f3987db9 100644
--- a/l10n/ru.js
+++ b/l10n/ru.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротация ключа была возобновлена, поэтому эти экстренные контакты не удалось перенести и их экстренный доступ удалён. Добавьте их снова в разделе «Экстренный доступ», если они вам ещё нужны.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротация ключа удалила %n экстренный контакт. Проверьте «Экстренный доступ» и добавьте его снова, если он вам ещё нужен.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротация ключа удалила %n экстренных контактов. Проверьте «Экстренный доступ» и добавьте их снова, если они вам ещё нужны.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен.",
+ "Shared with groups": "Предоставлен доступ группам",
+ "Not shared with any group yet.": "Пока не предоставлен ни одной группе.",
+ "Revoke the share with {group}": "Отозвать доступ для {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Предоставлено группе {group}: {received} участников получили, {skipped} нет, так как у них ещё не настроено шифрование.",
+ "Search groups": "Искать группы",
+ "Failed to share": "Не удалось предоставить доступ"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/ru.json b/l10n/ru.json
index b62a19aa0..037bfc4ed 100644
--- a/l10n/ru.json
+++ b/l10n/ru.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротация ключа была возобновлена, поэтому эти экстренные контакты не удалось перенести и их экстренный доступ удалён. Добавьте их снова в разделе «Экстренный доступ», если они вам ещё нужны.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротация ключа удалила %n экстренный контакт. Проверьте «Экстренный доступ» и добавьте его снова, если он вам ещё нужен.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротация ключа удалила %n экстренных контактов. Проверьте «Экстренный доступ» и добавьте их снова, если они вам ещё нужны.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен.",
+ "Shared with groups": "Предоставлен доступ группам",
+ "Not shared with any group yet.": "Пока не предоставлен ни одной группе.",
+ "Revoke the share with {group}": "Отозвать доступ для {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Предоставлено группе {group}: {received} участников получили, {skipped} нет, так как у них ещё не настроено шифрование.",
+ "Search groups": "Искать группы",
+ "Failed to share": "Не удалось предоставить доступ"
},
"plurals": null
}
diff --git a/l10n/sk.js b/l10n/sk.js
index ce063adec..a4f54bcf8 100644
--- a/l10n/sk.js
+++ b/l10n/sk.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotácia kľúča bola obnovená, a preto tieto núdzové kontakty nebolo možné preniesť a ich prístup pre naliehavé prípady bol odstránený. Ak ich stále chcete, pridajte ich znova v časti Prístup pre naliehavé prípady.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotácia kľúča odstránila %n núdzový kontakt. Skontrolujte Prístup pre naliehavé prípady a pridajte ho znova, ak ho stále chcete.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotácia kľúča odstránila %n núdzových kontaktov. Skontrolujte Prístup pre naliehavé prípady a pridajte ich znova, ak ich stále chcete.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotácia kľúča odstránila tomuto kontaktu prístup pre naliehavé prípady. Ak ho stále chcete, určte ho znova."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotácia kľúča odstránila tomuto kontaktu prístup pre naliehavé prípady. Ak ho stále chcete, určte ho znova.",
+ "Shared with groups": "Zdieľané so skupinami",
+ "Not shared with any group yet.": "Zatiaľ nezdieľané so žiadnou skupinou.",
+ "Revoke the share with {group}": "Zrušiť zdieľanie so skupinou {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Zdieľané so skupinou {group}: {received} členov to dostalo, {skipped} nie, pretože ešte nemajú nastavené šifrovanie.",
+ "Search groups": "Hľadať skupiny",
+ "Failed to share": "Zdieľanie zlyhalo"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/sk.json b/l10n/sk.json
index 0bd362488..9b4a6eae3 100644
--- a/l10n/sk.json
+++ b/l10n/sk.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotácia kľúča bola obnovená, a preto tieto núdzové kontakty nebolo možné preniesť a ich prístup pre naliehavé prípady bol odstránený. Ak ich stále chcete, pridajte ich znova v časti Prístup pre naliehavé prípady.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotácia kľúča odstránila %n núdzový kontakt. Skontrolujte Prístup pre naliehavé prípady a pridajte ho znova, ak ho stále chcete.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotácia kľúča odstránila %n núdzových kontaktov. Skontrolujte Prístup pre naliehavé prípady a pridajte ich znova, ak ich stále chcete.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotácia kľúča odstránila tomuto kontaktu prístup pre naliehavé prípady. Ak ho stále chcete, určte ho znova."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotácia kľúča odstránila tomuto kontaktu prístup pre naliehavé prípady. Ak ho stále chcete, určte ho znova.",
+ "Shared with groups": "Zdieľané so skupinami",
+ "Not shared with any group yet.": "Zatiaľ nezdieľané so žiadnou skupinou.",
+ "Revoke the share with {group}": "Zrušiť zdieľanie so skupinou {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Zdieľané so skupinou {group}: {received} členov to dostalo, {skipped} nie, pretože ešte nemajú nastavené šifrovanie.",
+ "Search groups": "Hľadať skupiny",
+ "Failed to share": "Zdieľanie zlyhalo"
},
"plurals": null
}
diff --git a/l10n/sl.js b/l10n/sl.js
index bbabf22bf..3079d7854 100644
--- a/l10n/sl.js
+++ b/l10n/sl.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa se je nadaljevala, zato teh stikov za nujne primere ni bilo mogoče prenesti in njihov dostop v nujnih primerih je bil odstranjen. Če jih še želite, jih znova dodajte v razdelku Dostop v nujnih primerih.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa je odstranila %n stik za nujne primere. Preverite Dostop v nujnih primerih in ga znova dodajte, če ga še želite.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je odstranila dostop v nujnih primerih za ta stik. Če ga še želite, ga znova določite."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je odstranila dostop v nujnih primerih za ta stik. Če ga še želite, ga znova določite.",
+ "Shared with groups": "Deljeno s skupinami",
+ "Not shared with any group yet.": "Še ni deljeno z nobeno skupino.",
+ "Revoke the share with {group}": "Prekliči deljenje s skupino {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Deljeno s skupino {group}: {received} članov je to prejelo, {skipped} ne, ker še nimajo nastavljenega šifriranja.",
+ "Search groups": "Išči skupine",
+ "Failed to share": "Deljenje ni uspelo"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/sl.json b/l10n/sl.json
index 0db1d30a6..7cd6e1041 100644
--- a/l10n/sl.json
+++ b/l10n/sl.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rotacija ključa se je nadaljevala, zato teh stikov za nujne primere ni bilo mogoče prenesti in njihov dostop v nujnih primerih je bil odstranjen. Če jih še želite, jih znova dodajte v razdelku Dostop v nujnih primerih.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rotacija ključa je odstranila %n stik za nujne primere. Preverite Dostop v nujnih primerih in ga znova dodajte, če ga še želite.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je odstranila dostop v nujnih primerih za ta stik. Če ga še želite, ga znova določite."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rotacija ključa je odstranila dostop v nujnih primerih za ta stik. Če ga še želite, ga znova določite.",
+ "Shared with groups": "Deljeno s skupinami",
+ "Not shared with any group yet.": "Še ni deljeno z nobeno skupino.",
+ "Revoke the share with {group}": "Prekliči deljenje s skupino {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Deljeno s skupino {group}: {received} članov je to prejelo, {skipped} ne, ker še nimajo nastavljenega šifriranja.",
+ "Search groups": "Išči skupine",
+ "Failed to share": "Deljenje ni uspelo"
},
"plurals": null
}
diff --git a/l10n/sq.js b/l10n/sq.js
index d6942d8c4..cee74241b 100644
--- a/l10n/sq.js
+++ b/l10n/sq.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rrotullimi i kyçit u rifillua, prandaj këta kontakte emergjence nuk mund të barteshin dhe aksesi i tyre i emergjencës u hoq. Shtojini përsëri nga Aksesi i emergjencës nëse i doni ende.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rrotullimi i kyçit hoqi %n kontakt emergjence. Kontrolloni Aksesin e emergjencës dhe shtojeni përsëri nëse e doni ende.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rrotullimi i kyçit hoqi %n kontakte emergjence. Kontrolloni Aksesin e emergjencës dhe shtojini përsëri nëse i doni ende.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rrotullimi i kyçit hoqi aksesin e emergjencës së këtij kontakti. Caktojeni përsëri nëse e doni ende."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rrotullimi i kyçit hoqi aksesin e emergjencës së këtij kontakti. Caktojeni përsëri nëse e doni ende.",
+ "Shared with groups": "Ndarë me grupe",
+ "Not shared with any group yet.": "Ende nuk është ndarë me asnjë grup.",
+ "Revoke the share with {group}": "Revoko ndarjen me {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Ndarë me {group}: {received} anëtarë e morën, {skipped} jo, sepse ende nuk kanë konfiguruar enkriptimin.",
+ "Search groups": "Kërko grupe",
+ "Failed to share": "Ndarja dështoi"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/sq.json b/l10n/sq.json
index bc1383d98..8e51029a2 100644
--- a/l10n/sq.json
+++ b/l10n/sq.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Rrotullimi i kyçit u rifillua, prandaj këta kontakte emergjence nuk mund të barteshin dhe aksesi i tyre i emergjencës u hoq. Shtojini përsëri nga Aksesi i emergjencës nëse i doni ende.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Rrotullimi i kyçit hoqi %n kontakt emergjence. Kontrolloni Aksesin e emergjencës dhe shtojeni përsëri nëse e doni ende.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Rrotullimi i kyçit hoqi %n kontakte emergjence. Kontrolloni Aksesin e emergjencës dhe shtojini përsëri nëse i doni ende.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rrotullimi i kyçit hoqi aksesin e emergjencës së këtij kontakti. Caktojeni përsëri nëse e doni ende."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Rrotullimi i kyçit hoqi aksesin e emergjencës së këtij kontakti. Caktojeni përsëri nëse e doni ende.",
+ "Shared with groups": "Ndarë me grupe",
+ "Not shared with any group yet.": "Ende nuk është ndarë me asnjë grup.",
+ "Revoke the share with {group}": "Revoko ndarjen me {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Ndarë me {group}: {received} anëtarë e morën, {skipped} jo, sepse ende nuk kanë konfiguruar enkriptimin.",
+ "Search groups": "Kërko grupe",
+ "Failed to share": "Ndarja dështoi"
},
"plurals": null
}
diff --git a/l10n/sr.js b/l10n/sr.js
index c836fd5bf..17e9dbe9a 100644
--- a/l10n/sr.js
+++ b/l10n/sr.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротација кључа је настављена, па ови контакти за хитне случајеве нису могли бити пренети и њихов приступ у хитним случајевима је уклоњен. Додајте их поново у одељку Приступ у хитним случајевима ако их још желите.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротација кључа је уклонила %n контакт за хитне случајеве. Проверите Приступ у хитним случајевима и додајте га поново ако га још желите.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротација кључа је уклонила %n контакта за хитне случајеве. Проверите Приступ у хитним случајевима и додајте их поново ако их још желите.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите.",
+ "Shared with groups": "Дељено са групама",
+ "Not shared with any group yet.": "Још није дељено ни са једном групом.",
+ "Revoke the share with {group}": "Опозови дељење са групом {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Дељено са групом {group}: {received} чланова је то примило, {skipped} није јер још нису подесили шифровање.",
+ "Search groups": "Претражи групе",
+ "Failed to share": "Дељење није успело"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/sr.json b/l10n/sr.json
index ec9c0ac40..8f3891931 100644
--- a/l10n/sr.json
+++ b/l10n/sr.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротација кључа је настављена, па ови контакти за хитне случајеве нису могли бити пренети и њихов приступ у хитним случајевима је уклоњен. Додајте их поново у одељку Приступ у хитним случајевима ако их још желите.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротација кључа је уклонила %n контакт за хитне случајеве. Проверите Приступ у хитним случајевима и додајте га поново ако га још желите.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротација кључа је уклонила %n контакта за хитне случајеве. Проверите Приступ у хитним случајевима и додајте их поново ако их још желите.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите.",
+ "Shared with groups": "Дељено са групама",
+ "Not shared with any group yet.": "Још није дељено ни са једном групом.",
+ "Revoke the share with {group}": "Опозови дељење са групом {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Дељено са групом {group}: {received} чланова је то примило, {skipped} није јер још нису подесили шифровање.",
+ "Search groups": "Претражи групе",
+ "Failed to share": "Дељење није успело"
},
"plurals": null
}
diff --git a/l10n/sv.js b/l10n/sv.js
index e9c667a44..b37e2dce2 100644
--- a/l10n/sv.js
+++ b/l10n/sv.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Din nyckelrotation återupptogs, så de här nödkontakterna kunde inte föras över och deras nödåtkomst togs bort. Lägg till dem igen under Nödåtkomst om du fortfarande vill ha dem.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Din nyckelrotation tog bort %n nödkontakt. Kontrollera Nödåtkomst och lägg till den igen om du fortfarande vill ha den.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Din nyckelrotation tog bort %n nödkontakter. Kontrollera Nödåtkomst och lägg till dem igen om du fortfarande vill ha dem.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nyckelrotation tog bort den här kontaktens nödåtkomst. Utse den igen om du fortfarande vill ha den."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nyckelrotation tog bort den här kontaktens nödåtkomst. Utse den igen om du fortfarande vill ha den.",
+ "Shared with groups": "Delad med grupper",
+ "Not shared with any group yet.": "Inte delad med någon grupp än.",
+ "Revoke the share with {group}": "Återkalla delningen med {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delad med {group}: {received} medlemmar tog emot den, {skipped} gjorde det inte eftersom de inte har konfigurerat kryptering än.",
+ "Search groups": "Sök grupper",
+ "Failed to share": "Delningen misslyckades"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/sv.json b/l10n/sv.json
index 7ebfed1e7..fcee42422 100644
--- a/l10n/sv.json
+++ b/l10n/sv.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Din nyckelrotation återupptogs, så de här nödkontakterna kunde inte föras över och deras nödåtkomst togs bort. Lägg till dem igen under Nödåtkomst om du fortfarande vill ha dem.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Din nyckelrotation tog bort %n nödkontakt. Kontrollera Nödåtkomst och lägg till den igen om du fortfarande vill ha den.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Din nyckelrotation tog bort %n nödkontakter. Kontrollera Nödåtkomst och lägg till dem igen om du fortfarande vill ha dem.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nyckelrotation tog bort den här kontaktens nödåtkomst. Utse den igen om du fortfarande vill ha den."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Din nyckelrotation tog bort den här kontaktens nödåtkomst. Utse den igen om du fortfarande vill ha den.",
+ "Shared with groups": "Delad med grupper",
+ "Not shared with any group yet.": "Inte delad med någon grupp än.",
+ "Revoke the share with {group}": "Återkalla delningen med {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delad med {group}: {received} medlemmar tog emot den, {skipped} gjorde det inte eftersom de inte har konfigurerat kryptering än.",
+ "Search groups": "Sök grupper",
+ "Failed to share": "Delningen misslyckades"
},
"plurals": null
}
diff --git a/l10n/tr.js b/l10n/tr.js
index 8ba3802d3..00319ea29 100644
--- a/l10n/tr.js
+++ b/l10n/tr.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Anahtar döndürmeniz sürdürüldü, bu nedenle bu acil durum kişileri aktarılamadı ve acil durum erişimleri kaldırıldı. Hâlâ istiyorsanız onları Acil durum erişimi bölümünden yeniden ekleyin.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız yeniden ekleyin.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız onları yeniden ekleyin.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Anahtar döndürmeniz bu kişinin acil durum erişimini kaldırdı. Hâlâ istiyorsanız onu yeniden atayın."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Anahtar döndürmeniz bu kişinin acil durum erişimini kaldırdı. Hâlâ istiyorsanız onu yeniden atayın.",
+ "Shared with groups": "Gruplarla paylaşıldı",
+ "Not shared with any group yet.": "Henüz hiçbir grupla paylaşılmadı.",
+ "Revoke the share with {group}": "{group} ile paylaşımı geri al",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "{group} ile paylaşıldı: {received} üye aldı, {skipped} üye henüz şifreleme ayarlamadığı için almadı.",
+ "Search groups": "Grup ara",
+ "Failed to share": "Paylaşılamadı"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/tr.json b/l10n/tr.json
index c9aa7aa0e..1e4b39aaa 100644
--- a/l10n/tr.json
+++ b/l10n/tr.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Anahtar döndürmeniz sürdürüldü, bu nedenle bu acil durum kişileri aktarılamadı ve acil durum erişimleri kaldırıldı. Hâlâ istiyorsanız onları Acil durum erişimi bölümünden yeniden ekleyin.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız yeniden ekleyin.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız onları yeniden ekleyin.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Anahtar döndürmeniz bu kişinin acil durum erişimini kaldırdı. Hâlâ istiyorsanız onu yeniden atayın."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Anahtar döndürmeniz bu kişinin acil durum erişimini kaldırdı. Hâlâ istiyorsanız onu yeniden atayın.",
+ "Shared with groups": "Gruplarla paylaşıldı",
+ "Not shared with any group yet.": "Henüz hiçbir grupla paylaşılmadı.",
+ "Revoke the share with {group}": "{group} ile paylaşımı geri al",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "{group} ile paylaşıldı: {received} üye aldı, {skipped} üye henüz şifreleme ayarlamadığı için almadı.",
+ "Search groups": "Grup ara",
+ "Failed to share": "Paylaşılamadı"
},
"plurals": null
}
diff --git a/l10n/uk.js b/l10n/uk.js
index 0016efe52..4ac37d8ea 100644
--- a/l10n/uk.js
+++ b/l10n/uk.js
@@ -1182,7 +1182,13 @@ OC.L10N.register(
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацію ключа було відновлено, тому ці екстрені контакти не вдалося перенести і їхній надзвичайний доступ видалено. Додайте їх знову в розділі «Надзвичайний доступ», якщо вони вам ще потрібні.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротація ключа видалила %n екстрений контакт. Перевірте «Надзвичайний доступ» і додайте його знову, якщо він вам ще потрібен.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротація ключа видалила %n екстрених контактів. Перевірте «Надзвичайний доступ» і додайте їх знову, якщо вони вам ще потрібні.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен.",
+ "Shared with groups": "Надано доступ групам",
+ "Not shared with any group yet.": "Ще не надано жодній групі.",
+ "Revoke the share with {group}": "Відкликати доступ для {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Надано групі {group}: {received} учасників отримали, {skipped} ні, бо в них ще не налаштовано шифрування.",
+ "Search groups": "Шукати групи",
+ "Failed to share": "Не вдалося надати доступ"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/uk.json b/l10n/uk.json
index d59ba95c4..a3e2d6815 100644
--- a/l10n/uk.json
+++ b/l10n/uk.json
@@ -1181,7 +1181,13 @@
"Your key rotation was resumed, so these emergency contacts could not be carried across and their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ротацію ключа було відновлено, тому ці екстрені контакти не вдалося перенести і їхній надзвичайний доступ видалено. Додайте їх знову в розділі «Надзвичайний доступ», якщо вони вам ще потрібні.",
"Your key rotation removed %n emergency contact. Check Emergency Access and add it again if you still want it.": "Ротація ключа видалила %n екстрений контакт. Перевірте «Надзвичайний доступ» і додайте його знову, якщо він вам ще потрібен.",
"Your key rotation removed %n emergency contacts. Check Emergency Access and add them again if you still want them.": "Ротація ключа видалила %n екстрених контактів. Перевірте «Надзвичайний доступ» і додайте їх знову, якщо вони вам ще потрібні.",
- "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен."
+ "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен.",
+ "Shared with groups": "Надано доступ групам",
+ "Not shared with any group yet.": "Ще не надано жодній групі.",
+ "Revoke the share with {group}": "Відкликати доступ для {group}",
+ "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Надано групі {group}: {received} учасників отримали, {skipped} ні, бо в них ще не налаштовано шифрування.",
+ "Search groups": "Шукати групи",
+ "Failed to share": "Не вдалося надати доступ"
},
"plurals": null
}
diff --git a/lib/Controller/GroupShareController.php b/lib/Controller/GroupShareController.php
index 081d78557..b5f2bf813 100644
--- a/lib/Controller/GroupShareController.php
+++ b/lib/Controller/GroupShareController.php
@@ -123,6 +123,7 @@ public function create(string $secretId, string $groupId): JSONResponse {
data: [
'groupShare' => $result['groupShare']->jsonSerialize(),
'members' => $result['members'],
+ 'skipped' => $result['skipped'],
],
statusCode: Http::STATUS_CREATED
);
diff --git a/lib/Service/DirectShareRegistrar.php b/lib/Service/DirectShareRegistrar.php
index 453326e02..f01a6988e 100644
--- a/lib/Service/DirectShareRegistrar.php
+++ b/lib/Service/DirectShareRegistrar.php
@@ -31,6 +31,7 @@
namespace OCA\Keepiq\Service;
use DateTime;
+use OCA\Keepiq\Db\GroupShareMapper;
use OCA\Keepiq\Db\SecretMapper;
use OCA\Keepiq\Db\ShareTarget;
use OCA\Keepiq\Db\ShareTargetMapper;
@@ -57,6 +58,7 @@ class DirectShareRegistrar {
* @param RecipientSecretCopyFactory $copyFactory The recipient-copy factory
* @param NotificationService $notificationService The notification dispatcher
* @param ShareAuditTrail|null $auditTrail The share audit trail
+ * @param GroupShareMapper|null $groupShareMapper The group-share mapper (rows linked to a group share)
*
* @return void
*
@@ -68,6 +70,7 @@ public function __construct(
private RecipientSecretCopyFactory $copyFactory,
private NotificationService $notificationService,
?ShareAuditTrail $auditTrail = null,
+ private ?GroupShareMapper $groupShareMapper = null,
) {
$this->auditTrail = ($auditTrail ?? new ShareAuditTrail());
}//end __construct()
@@ -79,11 +82,12 @@ public function __construct(
* team-folder fan-out registration.
*
* @param string $userId The sharing owner
- * @param array> $shares Rows {sourceSecretId, targetUserId, encryptedKey, encryptedLogin?, encryptedAdditionalFields?}
+ * @param array> $shares Rows {sourceSecretId, targetUserId, encryptedKey, encryptedLogin?, encryptedAdditionalFields?, groupShareId?}
*
* @return array
*
* @spec openspec/specs/bulk-actions/spec.md#requirement-the-four-bulk-operations
+ * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
public function registerDirectShares(string $userId, array $shares): array {
$report = [];
@@ -166,6 +170,14 @@ private function registerDirectShare(string $userId, array $row): array {
];
}
+ if ($this->groupShareMatches(sourceSecretId: $sourceSecretId, row: $row) === false) {
+ return [
+ 'sourceSecretId' => $sourceSecretId,
+ 'targetUserId' => $targetUserId,
+ 'status' => 'invalid',
+ ];
+ }
+
return $this->createDirectShare(
userId: $userId,
sourceSecretId: $sourceSecretId,
@@ -245,6 +257,7 @@ private function createDirectShare(
$entity->setSourceSecretId($sourceSecretId);
$entity->setTargetUserId($targetUserId);
$entity->setSecretId($copy->getId());
+ $entity->setGroupShareId($this->optionalString(value: ($row['groupShareId'] ?? null)));
$entity->setCreatedBy($userId);
$entity->setCreatedAt(new DateTime());
$this->mapper->insert($entity);
@@ -264,6 +277,36 @@ private function createDirectShare(
];
}//end createDirectShare()
+ /**
+ * Whether a row's optional groupShareId names a group share of the SAME
+ * source secret. A row without one matches; an unknown group share, one
+ * of another secret, or no mapper to check with does not (fail closed).
+ * The owner guard in createDirectShare() then covers the secret itself.
+ *
+ * @param string $sourceSecretId The row's source secret
+ * @param array $row The row
+ *
+ * @return bool
+ */
+ private function groupShareMatches(string $sourceSecretId, array $row): bool {
+ $groupShareId = $this->optionalString(value: ($row['groupShareId'] ?? null));
+ if ($groupShareId === null) {
+ return true;
+ }
+
+ if ($this->groupShareMapper === null) {
+ return false;
+ }
+
+ try {
+ $groupShare = $this->groupShareMapper->findById($groupShareId);
+ } catch (DoesNotExistException) {
+ return false;
+ }
+
+ return $groupShare->getSecretId() === $sourceSecretId;
+ }//end groupShareMatches()
+
/**
* Normalise an optional blob value to a non-empty string or null.
*
diff --git a/lib/Service/GroupShareService.php b/lib/Service/GroupShareService.php
index 23b58ac6e..b1f9bab74 100644
--- a/lib/Service/GroupShareService.php
+++ b/lib/Service/GroupShareService.php
@@ -40,6 +40,7 @@
use OCA\Keepiq\Db\ShareTargetMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IGroupManager;
+use OCP\Share\IManager as IShareManager;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
@@ -64,6 +65,8 @@ class GroupShareService {
* @param IGroupManager $groupManager The Nextcloud group manager
* @param NotificationService $notificationService The notification dispatcher
* @param LoggerInterface $logger The logger
+ * @param IShareManager $shareManager Nextcloud's share settings (group sharing on, own groups only)
+ * @param ShareRevocationService $revocationService Revokes one member's share and deletes its copy
*
* @return void
*/
@@ -77,6 +80,8 @@ public function __construct(
private IGroupManager $groupManager,
private NotificationService $notificationService,
private LoggerInterface $logger,
+ private IShareManager $shareManager,
+ private ShareRevocationService $revocationService,
) {
}//end __construct()
@@ -91,11 +96,15 @@ public function __construct(
* @param string $groupId The Nextcloud group ID
* @param string $userId The initiator (must be owner or delegate)
*
- * @return array{groupShare:GroupShare,members:array}
+ * `skipped` counts the members (owner excluded) left out for want of an
+ * active EncryptionSuite, so the sharer can be told who did not get it.
+ *
+ * @return array{groupShare:GroupShare,members:array,skipped:int}
*
* @throws InvalidArgumentException On unauthorized / missing secret / empty group
*
* @spec openspec/changes/implement-user-sharing/tasks.md#4.2
+ * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
public function createGroupShare(string $secretId, string $groupId, string $userId): array {
if ($groupId === '') {
@@ -110,6 +119,8 @@ public function createGroupShare(string $secretId, string $groupId, string $user
throw new InvalidArgumentException(message: 'Group not found');
}
+ $this->assertGroupShareable(groupId: $groupId, userId: $userId);
+
// Idempotency — one GroupShare per (secret, group). Surface the
// existing one with a fresh member list rather than persisting a
// duplicate.
@@ -130,6 +141,7 @@ public function createGroupShare(string $secretId, string $groupId, string $user
}
$members = [];
+ $skipped = 0;
foreach ($group->getUsers() as $user) {
$candidateId = $user->getUID();
if ($candidateId === $secret->getOwnerId()) {
@@ -142,6 +154,7 @@ public function createGroupShare(string $secretId, string $groupId, string $user
ownerId: $candidateId
);
} catch (DoesNotExistException) {
+ $skipped++;
continue;
}
@@ -154,12 +167,42 @@ public function createGroupShare(string $secretId, string $groupId, string $user
return [
'groupShare' => $existing,
'members' => $members,
+ 'skipped' => $skipped,
];
}//end createGroupShare()
/**
- * Revoke a group share — cascade-deletes every ShareTarget that was
- * fanned out from it, then deletes the GroupShare row itself.
+ * Apply Nextcloud's own share settings to a group share: group sharing
+ * must be on, and when sharing is restricted to the sharer's own groups
+ * the sharer must be a member. A group outside that reach is reported as
+ * "Group not found", the same answer a missing group gets, so the reply
+ * does not confirm that a group the caller cannot see exists.
+ *
+ * @param string $groupId The target group
+ * @param string $userId The sharer
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the group is out of the sharer's reach
+ *
+ * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ */
+ private function assertGroupShareable(string $groupId, string $userId): void {
+ if ($this->shareManager->allowGroupSharing() === false) {
+ throw new InvalidArgumentException(message: 'Group sharing is disabled');
+ }
+
+ if ($this->shareManager->shareWithGroupMembersOnly() === true
+ && $this->groupManager->isInGroup($userId, $groupId) === false
+ ) {
+ throw new InvalidArgumentException(message: 'Group not found');
+ }
+ }//end assertGroupShareable()
+
+ /**
+ * Revoke a group share: revoke every member share fanned out from it
+ * through the share revocation path (which deletes the member's Secret
+ * copy, not only the ShareTarget row), then delete the GroupShare row.
*
* @param string $groupShareId The GroupShare row ID
* @param string $userId The Nextcloud user requesting the revoke
@@ -169,6 +212,7 @@ public function createGroupShare(string $secretId, string $groupId, string $user
* @throws InvalidArgumentException On unauthorized / not found
*
* @spec openspec/changes/implement-user-sharing/tasks.md#4.3
+ * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#scenario-the-owner-revokes-a-group-share
*/
public function revokeGroupShare(string $groupShareId, string $userId): void {
try {
@@ -180,6 +224,12 @@ public function revokeGroupShare(string $groupShareId, string $userId): void {
$secret = $this->loadSecret(secretId: $entity->getSecretId());
$this->assertOwnerOrDelegate(secret: $secret, userId: $userId);
+ foreach ($this->bulkGrantMapper->findByGroupShare(groupShareId: $groupShareId) as $target) {
+ $this->revocationService->revokeShare(shareId: $target->getId(), userId: $userId);
+ }
+
+ // Anything the per-share revoke could not reach (a row whose source
+ // moved) still goes with the group share.
$this->bulkGrantMapper->deleteByGroupShare(groupShareId: $groupShareId);
$this->mapper->delete($entity);
diff --git a/src/components/SecretDetailSidebar.vue b/src/components/SecretDetailSidebar.vue
index 253d40176..0f83cf57c 100644
--- a/src/components/SecretDetailSidebar.vue
+++ b/src/components/SecretDetailSidebar.vue
@@ -677,6 +677,11 @@
:secretId="secretId"
data-testid="secret-detail-share-list" />
+
+
- Group-share form. The owner enters a Nextcloud group id; the parent
- view supplies the plaintext snapshot and the resolved member list +
- certificates. This component owns the per-member encryption loop and
- emits a `shared` event with the array of recipient envelopes the
- parent can hand to `useShareStore.createBatchShares`.
+ Group-share form. The owner picks a Nextcloud group from a search (a
+ typo cannot target the wrong group) and confirms. The group-share store
+ creates the group share, encrypts the secret in this tab for every
+ member with an encryption suite, and registers the copies. The form
+ emits `shared` with the group and the received / skipped counts.
- @spec openspec/changes/implement-user-sharing/tasks.md#task-12.3
+ @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
-->
@@ -16,28 +16,16 @@
+ {{
+ t(
+ 'keepiq',
+ 'Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.',
+ { group: result.group, received: result.received, skipped: result.skipped },
+ )
+ }}
+
+
+
+ {{ store.error }}
+
+
+
+
+ {{ t('keepiq', 'Share with group') }}
+
+
+
+
+
+
+
diff --git a/src/store/modules/groupShare.js b/src/store/modules/groupShare.js
new file mode 100644
index 000000000..02f6cb865
--- /dev/null
+++ b/src/store/modules/groupShare.js
@@ -0,0 +1,222 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+import axios from '@nextcloud/axios'
+import { generateOcsUrl, generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+import { useSecretStore } from './secret.js'
+import { useShareStore } from './share.js'
+
+/** Upper bound on the groups one sharee search returns. */
+const MAX_GROUP_RESULTS = 25
+
+/**
+ * Pinia store for sharing a secret with a Nextcloud group (sharing-02).
+ *
+ * The server keeps the group share and hands back the members that have an
+ * active encryption suite. Encryption stays in this browser tab: the store
+ * decrypts the owner's copy, encrypts it once per member, and registers the
+ * copies through `register-batch` linked to the group share, so revoking the
+ * group share revokes every copy.
+ *
+ * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ */
+export const useGroupShareStore = defineStore('groupShare', {
+ state: () => ({
+ /** @type {Array
@@ -63,10 +71,7 @@
:secretId="secretId"
@cancel="formOpen = false"
@shared="onShared" />
-
+
{{ t('keepiq', 'Share with group') }}
diff --git a/src/store/modules/groupShare.js b/src/store/modules/groupShare.js
index 02f6cb865..2df84794d 100644
--- a/src/store/modules/groupShare.js
+++ b/src/store/modules/groupShare.js
@@ -47,7 +47,9 @@ export const useGroupShareStore = defineStore('groupShare', {
this.error = null
try {
const response = await axios.get(
- generateUrl(`/apps/keepiq/api/v1/secrets/${secretId}/group-shares`),
+ generateUrl(
+ `/apps/keepiq/api/v1/secrets/${secretId}/group-shares`,
+ ),
)
this.groupShares = Array.isArray(response.data) ? response.data : []
} finally {
@@ -112,7 +114,9 @@ export const useGroupShareStore = defineStore('groupShare', {
this.error = null
try {
const created = await axios.post(
- generateUrl(`/apps/keepiq/api/v1/secrets/${secretId}/group-shares`),
+ generateUrl(
+ `/apps/keepiq/api/v1/secrets/${secretId}/group-shares`,
+ ),
{ groupId },
)
const groupShare = created.data?.groupShare ?? null
@@ -137,21 +141,25 @@ export const useGroupShareStore = defineStore('groupShare', {
? response.data.items
: []
received = items.filter(
- (item) => item.status === 'created' || item.status === 'exists',
+ (item) =>
+ item.status === 'created' || item.status === 'exists',
).length
refused = members.length - received
}
if (groupShare !== null) {
this.groupShares = [
- ...this.groupShares.filter((row) => row.id !== groupShare.id),
+ ...this.groupShares.filter(
+ (row) => row.id !== groupShare.id,
+ ),
groupShare,
]
}
return { received, skipped: skippedByServer + refused }
} catch (e) {
- this.error = e?.response?.data?.message || e?.message || 'Failed to share'
+ this.error =
+ e?.response?.data?.message || e?.message || 'Failed to share'
throw e
} finally {
this.loading = false
@@ -214,7 +222,8 @@ export const useGroupShareStore = defineStore('groupShare', {
(row) => row.id !== groupShareId,
)
} catch (e) {
- this.error = e?.response?.data?.message || e?.message || 'Failed to revoke'
+ this.error =
+ e?.response?.data?.message || e?.message || 'Failed to revoke'
throw e
}
},
diff --git a/tests/components/GroupShareList.spec.js b/tests/components/GroupShareList.spec.js
index c922970fe..9ff3cedf5 100644
--- a/tests/components/GroupShareList.spec.js
+++ b/tests/components/GroupShareList.spec.js
@@ -50,11 +50,19 @@ describe('GroupShareList', () => {
await flushPromises()
expect(share).toHaveBeenCalledWith('s-1', 'finance')
- expect(wrapper.findComponent({ name: 'GroupShareForm' }).exists()).toBe(false)
+ expect(wrapper.findComponent({ name: 'GroupShareForm' }).exists()).toBe(
+ false,
+ )
// The test t() stub returns the key untranslated, so the counts are
// read from the values the message is rendered with.
- expect(wrapper.find('[data-testid="group-share-result"]').exists()).toBe(true)
- expect(wrapper.vm.result).toEqual({ group: 'Finance', received: 3, skipped: 1 })
+ expect(wrapper.find('[data-testid="group-share-result"]').exists()).toBe(
+ true,
+ )
+ expect(wrapper.vm.result).toEqual({
+ group: 'Finance',
+ received: 3,
+ skipped: 1,
+ })
})
it('revokes a group share', async () => {
@@ -67,6 +75,8 @@ describe('GroupShareList', () => {
expect(del).toHaveBeenCalledWith('/apps/keepiq/api/v1/group-shares/gs-1')
expect(wrapper.findAll('[data-testid="group-share-row"]')).toHaveLength(0)
- expect(wrapper.find('[data-testid="group-share-list-empty"]').exists()).toBe(true)
+ expect(wrapper.find('[data-testid="group-share-list-empty"]').exists()).toBe(
+ true,
+ )
})
})
diff --git a/tests/store/groupShare.spec.js b/tests/store/groupShare.spec.js
index 9f1066472..c72559b1d 100644
--- a/tests/store/groupShare.spec.js
+++ b/tests/store/groupShare.spec.js
@@ -36,35 +36,44 @@ describe('useGroupShareStore', () => {
})
it('shares with a group: creates the group share, encrypts per member, registers the copies linked to it', async () => {
- const post = vi.spyOn(axios, 'post').mockImplementation(async (url, body) => {
- if (url.endsWith('/group-shares')) {
+ const post = vi
+ .spyOn(axios, 'post')
+ .mockImplementation(async (url, body) => {
+ if (url.endsWith('/group-shares')) {
+ return {
+ data: {
+ groupShare: { id: 'gs-1', groupId: 'finance' },
+ members: [
+ { userId: 'bob', certificate: 'PEM-BOB' },
+ { userId: 'carol', certificate: 'PEM-CAROL' },
+ { userId: 'dave', certificate: 'PEM-DAVE' },
+ ],
+ skipped: 1,
+ },
+ }
+ }
return {
data: {
- groupShare: { id: 'gs-1', groupId: 'finance' },
- members: [
- { userId: 'bob', certificate: 'PEM-BOB' },
- { userId: 'carol', certificate: 'PEM-CAROL' },
- { userId: 'dave', certificate: 'PEM-DAVE' },
- ],
- skipped: 1,
+ items: body.shares.map((row) => ({
+ targetUserId: row.targetUserId,
+ status:
+ row.targetUserId === 'dave' ? 'no_suite' : 'created',
+ })),
},
}
- }
- return {
- data: {
- items: body.shares.map((row) => ({
- targetUserId: row.targetUserId,
- status: row.targetUserId === 'dave' ? 'no_suite' : 'created',
- })),
- },
- }
- })
+ })
useSecretStore().fetchSecret = vi
.fn()
- .mockResolvedValue({ key: 'hunter2', login: 'alice', additionalFields: {} })
+ .mockResolvedValue({
+ key: 'hunter2',
+ login: 'alice',
+ additionalFields: {},
+ })
const encrypt = vi
.spyOn(useShareStore(), 'encryptForRecipient')
- .mockImplementation(async (snapshot, cert) => ({ key: `enc(${snapshot.key},${cert})` }))
+ .mockImplementation(async (snapshot, cert) => ({
+ key: `enc(${snapshot.key},${cert})`,
+ }))
const store = useGroupShareStore()
const result = await store.shareWithGroup('s-1', 'finance')
@@ -107,7 +116,14 @@ describe('useGroupShareStore', () => {
data: {
ocs: {
data: {
- exact: { groups: [{ label: 'Finance', value: { shareWith: 'finance' } }] },
+ exact: {
+ groups: [
+ {
+ label: 'Finance',
+ value: { shareWith: 'finance' },
+ },
+ ],
+ },
groups: [
{ label: 'Finance', value: { shareWith: 'finance' } },
{ label: 'Board', value: { shareWith: 'board' } },
@@ -120,7 +136,10 @@ describe('useGroupShareStore', () => {
const groups = await store.searchGroups('fin')
- expect(get.mock.calls[0][1].params).toMatchObject({ search: 'fin', shareType: 1 })
+ expect(get.mock.calls[0][1].params).toMatchObject({
+ search: 'fin',
+ shareType: 1,
+ })
expect(groups).toEqual([
{ id: 'finance', label: 'Finance' },
{ id: 'board', label: 'Board' },
From e8191aeee83d6bb1b08e852d16af52f9a98fad47 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 22:11:07 +0200
Subject: [PATCH 010/245] docs(openspec): archive
sharing-group-share-entry-point, sharing-02 built
The design's D1 said the server encrypts per member; it hands back the
members' public certificates and the browser encrypts. Corrected, plus
the revoke and share-settings decisions. Scenarios carry an e2e exclude
naming the unit tests; the spec tags point at the new main spec.
---
lib/Service/DirectShareRegistrar.php | 2 +-
lib/Service/GroupShareService.php | 6 +--
.../design.md | 38 ++++++++++++++++++
.../proposal.md | 0
.../specs/sharing-group/spec.md | 29 ++++++++++++++
.../tasks.md | 16 ++++++++
.../sharing-group-share-entry-point/design.md | 30 --------------
.../specs/sharing-group/spec.md | 23 -----------
.../sharing-group-share-entry-point/tasks.md | 16 --------
openspec/parity/capabilities.json | 23 ++++-------
openspec/specs/sharing-group/spec.md | 39 +++++++++++++++++++
src/components/share/GroupShareForm.vue | 6 +--
src/components/share/GroupShareList.vue | 6 +--
src/store/modules/groupShare.js | 10 ++---
tests/components/GroupShareList.spec.js | 2 +-
tests/store/groupShare.spec.js | 14 +++----
16 files changed, 152 insertions(+), 108 deletions(-)
create mode 100644 openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/design.md
rename openspec/changes/{sharing-group-share-entry-point => archive/2026-09-29-sharing-group-share-entry-point}/proposal.md (100%)
create mode 100644 openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/specs/sharing-group/spec.md
create mode 100644 openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/tasks.md
delete mode 100644 openspec/changes/sharing-group-share-entry-point/design.md
delete mode 100644 openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md
delete mode 100644 openspec/changes/sharing-group-share-entry-point/tasks.md
create mode 100644 openspec/specs/sharing-group/spec.md
diff --git a/lib/Service/DirectShareRegistrar.php b/lib/Service/DirectShareRegistrar.php
index f01a6988e..8f4b9e673 100644
--- a/lib/Service/DirectShareRegistrar.php
+++ b/lib/Service/DirectShareRegistrar.php
@@ -87,7 +87,7 @@ public function __construct(
* @return array
*
* @spec openspec/specs/bulk-actions/spec.md#requirement-the-four-bulk-operations
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
public function registerDirectShares(string $userId, array $shares): array {
$report = [];
diff --git a/lib/Service/GroupShareService.php b/lib/Service/GroupShareService.php
index b1f9bab74..d39f70d6d 100644
--- a/lib/Service/GroupShareService.php
+++ b/lib/Service/GroupShareService.php
@@ -104,7 +104,7 @@ public function __construct(
* @throws InvalidArgumentException On unauthorized / missing secret / empty group
*
* @spec openspec/changes/implement-user-sharing/tasks.md#4.2
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
public function createGroupShare(string $secretId, string $groupId, string $userId): array {
if ($groupId === '') {
@@ -185,7 +185,7 @@ public function createGroupShare(string $secretId, string $groupId, string $user
*
* @throws InvalidArgumentException When the group is out of the sharer's reach
*
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
private function assertGroupShareable(string $groupId, string $userId): void {
if ($this->shareManager->allowGroupSharing() === false) {
@@ -212,7 +212,7 @@ private function assertGroupShareable(string $groupId, string $userId): void {
* @throws InvalidArgumentException On unauthorized / not found
*
* @spec openspec/changes/implement-user-sharing/tasks.md#4.3
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#scenario-the-owner-revokes-a-group-share
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
public function revokeGroupShare(string $groupShareId, string $userId): void {
try {
diff --git a/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/design.md b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/design.md
new file mode 100644
index 000000000..2cf532e30
--- /dev/null
+++ b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/design.md
@@ -0,0 +1,38 @@
+# Design: share a secret with a Nextcloud group from the secret sidebar
+
+## Context
+
+At development `156cd800`:
+
+- `lib/Controller/GroupShareController.php` `index`, `create` (`:103`), `destroy` (`:143`), `approveNewMember` (`:179`), `denyNewMember` (`:233`) are routed at `appinfo/routes.php:133-135` and onward.
+- `lib/Service/GroupShareService.php:100` `createGroupShare($secretId, $groupId, $userId)` encrypts per member server-side; `:224` `getGroupMembers`; `:250` `handleNewGroupMember`.
+- `src/components/share/GroupShareForm.vue` takes a free text group id and a `members` prop no caller supplies, and calls `useShareStore.encryptForRecipient`.
+- `src/components/SecretDetailSidebar.vue:680-687` mounts the sharing components for owners and recipients.
+- `grep -rn group-shares src browser-extension cli` finds no caller.
+
+## Goals / Non-Goals
+
+**Goals**
+- An owner reaches the finished group share backend from the UI.
+
+**Non-Goals**
+- Changing the group share crypto.
+- Group roles on a share (`sharing-team-folder-manager-role`).
+
+## Decisions
+
+### D1: The browser encrypts for members, the server keeps the link
+
+Corrected at build time (29 Sep). The first draft said the server encrypts per member; it does not and must not. `GroupShareService::createGroupShare()` returns the eligible members with their PUBLIC certificates, and the plaintext never leaves the browser. So `useGroupShareStore.shareWithGroup()` creates the group share, decrypts the owner's copy in the tab, encrypts it once per member, and registers the copies through `POST /api/v1/shares/register-batch` with a `groupShareId` on each row. `DirectShareRegistrar` accepts that id only for a group share of the same source secret. The old `members` prop and the per-member loop in `GroupShareForm.vue` go.
+
+### D1b: Revoke deletes the copies
+
+`revokeGroupShare()` used to delete the ShareTarget rows only, leaving each member's encrypted copy in place. It now revokes every linked share through `ShareRevocationService::revokeShare()`, which deletes the copy and its attachment grants, then deletes the leftovers and the group share row.
+
+### D1c: Nextcloud's share settings apply
+
+The server refuses a group share when Nextcloud has group sharing off, and when sharing is limited to the sharer's own groups and the sharer is not a member. The second refusal reads "Group not found", the same as a missing group, so it does not confirm the group exists. The picker uses Nextcloud's sharee search, which applies the same settings.
+
+### D2: Pick, do not type
+
+A group search select replaces the free text field so a typo cannot target the wrong group.
diff --git a/openspec/changes/sharing-group-share-entry-point/proposal.md b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/proposal.md
similarity index 100%
rename from openspec/changes/sharing-group-share-entry-point/proposal.md
rename to openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/proposal.md
diff --git a/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/specs/sharing-group/spec.md b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/specs/sharing-group/spec.md
new file mode 100644
index 000000000..312863411
--- /dev/null
+++ b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/specs/sharing-group/spec.md
@@ -0,0 +1,29 @@
+## ADDED Requirements
+
+### Requirement: Share with a group
+
+The system MUST let the owner of a secret share it with a Nextcloud group from the secret detail sidebar, within Nextcloud's share settings (group sharing on; own groups only when that restriction is set). The action MUST call `POST /api/v1/secrets/{secretId}/group-shares` and MUST show how many members received the share and how many were skipped for lack of an active encryption suite. Members who join the group later MUST follow the existing approval path of `GroupShareService::handleNewGroupMember()`.
+
+#### Scenario: An owner shares with a group
+
+@e2e exclude Needs two users with active vault suites and a shared group on the test instance; covered by vitest tests/store/groupShare.spec.js and tests/components/GroupShareList.spec.js, and PHPUnit GroupShareServiceTest and ShareServiceTest::testRegisterDirectSharesLinksAGroupShareOfTheSameSecret.
+
+- **GIVEN** an owner viewing a secret in the sidebar at /secrets and a group Finance with four members of whom three have an encryption suite
+- **WHEN** the owner picks Share with group, selects Finance and confirms
+- **THEN** the sidebar lists a Finance group share and says three members received it and one was skipped
+
+#### Scenario: A non-owner cannot share with a group
+
+@e2e exclude Needs a second user holding a shared copy; covered by vitest tests/components/SecretDetailSidebar.sharing.spec.js (a recipient gets no group share list).
+
+- **GIVEN** a recipient who holds a shared copy
+- **WHEN** the recipient opens the sidebar
+- **THEN** the Share with group action is not offered
+
+#### Scenario: The owner revokes a group share
+
+@e2e exclude Needs group members with vault suites; covered by vitest tests/components/GroupShareList.spec.js and PHPUnit GroupShareServiceTest::testRevokeGroupShareCascades.
+
+- **GIVEN** a secret shared with Finance
+- **WHEN** the owner revokes the Finance share in the sidebar
+- **THEN** the group share is gone and members of Finance lose their copies
diff --git a/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/tasks.md b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/tasks.md
new file mode 100644
index 000000000..de2c7d8b3
--- /dev/null
+++ b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/tasks.md
@@ -0,0 +1,16 @@
+# Tasks: share a secret with a Nextcloud group from the secret sidebar
+
+## 1. Wire the form
+
+- [x] 1.1 Create `src/store/modules/groupShare.js` with list, create and revoke actions. Verify: vitest with mocked axios asserting the three routes and payloads.
+- [x] 1.2 Rework `GroupShareForm.vue` to a group picker and the store action, and open it from the sidebar. Verify: vitest on the form (tests/components/GroupShareList.spec.js). The Playwright flow is excluded: it needs two users with vault suites; the spec scenarios carry the reason.
+- [x] 1.3 List and revoke group shares in the sidebar. Verify: vitest (tests/components/GroupShareList.spec.js revoke) and PHPUnit GroupShareServiceTest::testRevokeGroupShareCascades (each member copy revoked through ShareRevocationService).
+
+## 2. Backend check
+
+- [x] 2.1 Add a PHPUnit test that a user cannot create a group share for a secret they do not own and cannot target a group they cannot see. Verify: PHPUnit; hydra no-admin-idor gate.
+
+## 3. Close out
+
+- [x] 3.1 Set row `sharing-02` to built, clear its defect, archive the change. Verify: parity_verify --strict.
+
diff --git a/openspec/changes/sharing-group-share-entry-point/design.md b/openspec/changes/sharing-group-share-entry-point/design.md
deleted file mode 100644
index d2386cac3..000000000
--- a/openspec/changes/sharing-group-share-entry-point/design.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# Design: share a secret with a Nextcloud group from the secret sidebar
-
-## Context
-
-At development `156cd800`:
-
-- `lib/Controller/GroupShareController.php` `index`, `create` (`:103`), `destroy` (`:143`), `approveNewMember` (`:179`), `denyNewMember` (`:233`) are routed at `appinfo/routes.php:133-135` and onward.
-- `lib/Service/GroupShareService.php:100` `createGroupShare($secretId, $groupId, $userId)` encrypts per member server-side; `:224` `getGroupMembers`; `:250` `handleNewGroupMember`.
-- `src/components/share/GroupShareForm.vue` takes a free text group id and a `members` prop no caller supplies, and calls `useShareStore.encryptForRecipient`.
-- `src/components/SecretDetailSidebar.vue:680-687` mounts the sharing components for owners and recipients.
-- `grep -rn group-shares src browser-extension cli` finds no caller.
-
-## Goals / Non-Goals
-
-**Goals**
-- An owner reaches the finished group share backend from the UI.
-
-**Non-Goals**
-- Changing the group share crypto.
-- Group roles on a share (`sharing-team-folder-manager-role`).
-
-## Decisions
-
-### D1: The server encrypts for members
-
-The backend already builds each member copy, so the form drops the per-member client encryption loop and only sends the group id. This removes the `members` prop nobody supplied.
-
-### D2: Pick, do not type
-
-A group search select replaces the free text field so a typo cannot target the wrong group.
diff --git a/openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md b/openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md
deleted file mode 100644
index eb2b74038..000000000
--- a/openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md
+++ /dev/null
@@ -1,23 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Share with a group
-
-The system MUST let the owner of a secret share it with a Nextcloud group from the secret detail sidebar. The action MUST call `POST /api/v1/secrets/{secretId}/group-shares` and MUST show how many members received the share and how many were skipped for lack of an active encryption suite. Members who join the group later MUST follow the existing approval path of `GroupShareService::handleNewGroupMember()`.
-
-#### Scenario: An owner shares with a group
-
-- **GIVEN** an owner viewing a secret in the sidebar at /secrets and a group Finance with four members of whom three have an encryption suite
-- **WHEN** the owner picks Share with group, selects Finance and confirms
-- **THEN** the sidebar lists a Finance group share and says three members received it and one was skipped
-
-#### Scenario: A non-owner cannot share with a group
-
-- **GIVEN** a recipient who holds a shared copy
-- **WHEN** the recipient opens the sidebar
-- **THEN** the Share with group action is not offered
-
-#### Scenario: The owner revokes a group share
-
-- **GIVEN** a secret shared with Finance
-- **WHEN** the owner revokes the Finance share in the sidebar
-- **THEN** the group share is gone and members of Finance lose their copies
diff --git a/openspec/changes/sharing-group-share-entry-point/tasks.md b/openspec/changes/sharing-group-share-entry-point/tasks.md
deleted file mode 100644
index cd5ce1197..000000000
--- a/openspec/changes/sharing-group-share-entry-point/tasks.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Tasks: share a secret with a Nextcloud group from the secret sidebar
-
-## 1. Wire the form
-
-- [ ] 1.1 Create `src/store/modules/groupShare.js` with list, create and revoke actions. Verify: vitest with mocked axios asserting the three routes and payloads.
-- [ ] 1.2 Rework `GroupShareForm.vue` to a group picker and the store action, and open it from the sidebar. Verify: vitest on the form; Playwright flow share with a group as owner, see the result counts.
-- [ ] 1.3 List and revoke group shares in the sidebar. Verify: Playwright flow revoke, recipient loses access.
-
-## 2. Backend check
-
-- [ ] 2.1 Add a PHPUnit test that a user cannot create a group share for a secret they do not own and cannot target a group they cannot see. Verify: PHPUnit; hydra no-admin-idor gate.
-
-## 3. Close out
-
-- [ ] 3.1 Set row `sharing-02` to built, clear its defect, archive the change. Verify: parity_verify --strict.
-
diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json
index 9959d0388..c7c4130c7 100644
--- a/openspec/parity/capabilities.json
+++ b/openspec/parity/capabilities.json
@@ -2270,7 +2270,7 @@
"id": "sharing-02",
"area": "sharing",
"name": "Share a secret with a Nextcloud group.",
- "keepiq": "no",
+ "keepiq": "yes",
"bitwarden": "yes",
"onepassword": "yes",
"passbolt": "yes",
@@ -2278,19 +2278,12 @@
"hashicorp-vault": "yes",
"nextcloud-passwords": "partial",
"built": {
- "state": "building",
- "evidence": "lib/Controller/GroupShareController.php and lib/Service/GroupShareService.php implement group shares fully, but src/components/share/GroupShareForm.vue (registry key 'group-share-form') has no cnOpenModal caller anywhere in src/, and no store or component ever calls the /api/v1/secrets/{id}/group-shares endpoints (grep -rn 'group-shares' src/ browser-extension/ cli/: no hits). GroupShareForm.vue also calls useShareStore.encryptForRecipient with a `members` prop no caller ever supplies, not the groupShare backend at all.",
+ "state": "built",
+ "evidence": "src/components/SecretDetailSidebar.vue mounts src/components/share/GroupShareList.vue for owners -> GroupShareForm.vue -> src/store/modules/groupShare.js shareWithGroup: POST /api/v1/secrets/{id}/group-shares (appinfo/routes.php:134, lib/Service/GroupShareService.php createGroupShare honours Nextcloud share settings) then POST /api/v1/shares/register-batch with groupShareId (lib/Service/DirectShareRegistrar.php groupShareMatches); revoke via DELETE /api/v1/group-shares/{id} revokes each member copy through ShareRevocationService",
"owner": "ConductionNL/keepiq",
- "note": "A user cannot share a secret with a Nextcloud group today. The backend and a form component exist but nothing in the app opens the form or calls the group-share API.",
- "defects": [
- {
- "at": "src/components/share/GroupShareForm.vue:1",
- "what": "Group-share form component is registered but never opened by any page or dialog, and its own submit logic calls the wrong store (useShareStore per-user encryption) rather than the group-shares endpoint.",
- "issue": null,
- "needsLiveCheck": false
- }
- ],
- "change": "openspec/changes/sharing-group-share-entry-point"
+ "reachedOn": "Secret sidebar (/secrets, open a secret you own) -> Sharing -> Share with group",
+ "note": "Built 2026-09-29. Tests: GroupShareServiceTest (visibility, skipped count, revoke cascade), ShareServiceTest::testRegisterDirectSharesLinksAGroupShareOfTheSameSecret, GroupShareControllerTest create, vitest groupShare store and GroupShareList.",
+ "change": "openspec/changes/archive/2026-09-29-sharing-group-share-entry-point"
},
"rowSource": "own",
"provider": "keepiq",
@@ -4477,9 +4470,9 @@
"nextcloud-passwords": "no",
"built": {
"state": "built",
- "evidence": "src/views/CertificateInventoryView.vue:292 'Renew\u2026' -> src/store/modules/certificate.js:86 POST /api/v1/certificates/{id}/renewal-checklist -> lib/Service/CertificateLifecycleService.php:228 (externally issued checklist); suite rows: CertificateInventoryView.vue:308 -> certificate.js:108 POST /api/v1/certificates/suites/{id}/reissue -> CertificateLifecycleService.php:282",
+ "evidence": "src/views/CertificateInventoryView.vue:292 'Renew…' -> src/store/modules/certificate.js:86 POST /api/v1/certificates/{id}/renewal-checklist -> lib/Service/CertificateLifecycleService.php:228 (externally issued checklist); suite rows: CertificateInventoryView.vue:308 -> certificate.js:108 POST /api/v1/certificates/suites/{id}/reissue -> CertificateLifecycleService.php:282",
"owner": "ConductionNL/keepiq",
- "reachedOn": "Certificates page -> Renew\u2026 on a stored certificate, Re-issue on a suite certificate",
+ "reachedOn": "Certificates page -> Renew… on a stored certificate, Re-issue on a suite certificate",
"note": "Stored certificates get a fixed checklist for externally issued certificates; certificates Keepiq issued itself (suite certificates) can be re-issued with one click. There is no branch for a stored certificate that Keepiq's CA issued, because Keepiq does not issue those."
},
"rowSource": "own",
diff --git a/openspec/specs/sharing-group/spec.md b/openspec/specs/sharing-group/spec.md
new file mode 100644
index 000000000..0f161405c
--- /dev/null
+++ b/openspec/specs/sharing-group/spec.md
@@ -0,0 +1,39 @@
+# Sharing with a group Specification
+
+**Status**: done
+
+**OpenSpec changes:**
+- [sharing-group-share-entry-point](../../changes/archive/2026-09-29-sharing-group-share-entry-point/) _(archived 2026-09-29)_
+
+## Purpose
+An owner shares a secret with a Nextcloud group from the secret sidebar. The server keeps the group share and names the members with an encryption suite; the browser encrypts a copy for each of them. Parity row sharing-02.
+
+## Requirements
+
+### Requirement: Share with a group
+
+The system MUST let the owner of a secret share it with a Nextcloud group from the secret detail sidebar, within Nextcloud's share settings (group sharing on; own groups only when that restriction is set). The action MUST call `POST /api/v1/secrets/{secretId}/group-shares` and MUST show how many members received the share and how many were skipped for lack of an active encryption suite. Members who join the group later MUST follow the existing approval path of `GroupShareService::handleNewGroupMember()`.
+
+#### Scenario: An owner shares with a group
+
+@e2e exclude Needs two users with active vault suites and a shared group on the test instance; covered by vitest tests/store/groupShare.spec.js and tests/components/GroupShareList.spec.js, and PHPUnit GroupShareServiceTest and ShareServiceTest::testRegisterDirectSharesLinksAGroupShareOfTheSameSecret.
+
+- **GIVEN** an owner viewing a secret in the sidebar at /secrets and a group Finance with four members of whom three have an encryption suite
+- **WHEN** the owner picks Share with group, selects Finance and confirms
+- **THEN** the sidebar lists a Finance group share and says three members received it and one was skipped
+
+#### Scenario: A non-owner cannot share with a group
+
+@e2e exclude Needs a second user holding a shared copy; covered by vitest tests/components/SecretDetailSidebar.sharing.spec.js (a recipient gets no group share list).
+
+- **GIVEN** a recipient who holds a shared copy
+- **WHEN** the recipient opens the sidebar
+- **THEN** the Share with group action is not offered
+
+#### Scenario: The owner revokes a group share
+
+@e2e exclude Needs group members with vault suites; covered by vitest tests/components/GroupShareList.spec.js and PHPUnit GroupShareServiceTest::testRevokeGroupShareCascades.
+
+- **GIVEN** a secret shared with Finance
+- **WHEN** the owner revokes the Finance share in the sidebar
+- **THEN** the group share is gone and members of Finance lose their copies
diff --git a/src/components/share/GroupShareForm.vue b/src/components/share/GroupShareForm.vue
index e4480c549..441b1338e 100644
--- a/src/components/share/GroupShareForm.vue
+++ b/src/components/share/GroupShareForm.vue
@@ -8,7 +8,7 @@
member with an encryption suite, and registers the copies. The form
emits `shared` with the group and the received / skipped counts.
- @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
-->
@@ -89,7 +89,7 @@ export default {
*
* @param {string} term The search term.
* @return {Promise}
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
async onSearch(term) {
this.searching = true
@@ -106,7 +106,7 @@ export default {
* Share the secret with the picked group.
*
* @return {Promise}
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#scenario-an-owner-shares-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
async onSubmit() {
this.error = null
diff --git a/src/components/share/GroupShareList.vue b/src/components/share/GroupShareList.vue
index 5c9fe0ddb..95b6923cf 100644
--- a/src/components/share/GroupShareList.vue
+++ b/src/components/share/GroupShareList.vue
@@ -7,7 +7,7 @@
the GroupShareForm. After a share it says how many members received the
secret and how many did not.
- @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
-->
@@ -132,7 +132,7 @@ export default {
*
* @param {{group: {id: string, label: string}, received: number, skipped: number}} payload The share outcome.
* @return {void}
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#scenario-an-owner-shares-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
onShared(payload) {
this.formOpen = false
@@ -148,7 +148,7 @@ export default {
*
* @param {string} id The group share id.
* @return {void}
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#scenario-the-owner-revokes-a-group-share
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
onRevoke(id) {
this.result = null
diff --git a/src/store/modules/groupShare.js b/src/store/modules/groupShare.js
index 2df84794d..75573fa12 100644
--- a/src/store/modules/groupShare.js
+++ b/src/store/modules/groupShare.js
@@ -21,7 +21,7 @@ const MAX_GROUP_RESULTS = 25
* copies through `register-batch` linked to the group share, so revoking the
* group share revokes every copy.
*
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
export const useGroupShareStore = defineStore('groupShare', {
state: () => ({
@@ -40,7 +40,7 @@ export const useGroupShareStore = defineStore('groupShare', {
*
* @param {string} secretId The source secret id.
* @return {Promise}
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
async fetchGroupShares(secretId) {
this.loading = true
@@ -64,7 +64,7 @@ export const useGroupShareStore = defineStore('groupShare', {
*
* @param {string} search The search term.
* @return {Promise>}
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
async searchGroups(search) {
const response = await axios.get(
@@ -107,7 +107,7 @@ export const useGroupShareStore = defineStore('groupShare', {
* @param {string} groupId The Nextcloud group id.
* @return {Promise<{received: number, skipped: number}>} How many members
* got a copy, and how many did not (no encryption suite, or refused).
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#scenario-an-owner-shares-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
async shareWithGroup(secretId, groupId) {
this.loading = true
@@ -210,7 +210,7 @@ export const useGroupShareStore = defineStore('groupShare', {
*
* @param {string} groupShareId The group share id.
* @return {Promise}
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#scenario-the-owner-revokes-a-group-share
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
async revokeGroupShare(groupShareId) {
this.error = null
diff --git a/tests/components/GroupShareList.spec.js b/tests/components/GroupShareList.spec.js
index 9ff3cedf5..1deb81a3c 100644
--- a/tests/components/GroupShareList.spec.js
+++ b/tests/components/GroupShareList.spec.js
@@ -6,7 +6,7 @@
* group, picks a group, and reads how many members received the secret;
* a group share can be revoked.
*
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
import axios from '@nextcloud/axios'
diff --git a/tests/store/groupShare.spec.js b/tests/store/groupShare.spec.js
index c72559b1d..322b58c0b 100644
--- a/tests/store/groupShare.spec.js
+++ b/tests/store/groupShare.spec.js
@@ -5,7 +5,7 @@
* useGroupShareStore: the three group-share routes and the per-member
* fan-out through register-batch (sharing-02).
*
- * @spec openspec/changes/sharing-group-share-entry-point/specs/sharing-group/spec.md#requirement-share-with-a-group
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
import axios from '@nextcloud/axios'
@@ -62,13 +62,11 @@ describe('useGroupShareStore', () => {
},
}
})
- useSecretStore().fetchSecret = vi
- .fn()
- .mockResolvedValue({
- key: 'hunter2',
- login: 'alice',
- additionalFields: {},
- })
+ useSecretStore().fetchSecret = vi.fn().mockResolvedValue({
+ key: 'hunter2',
+ login: 'alice',
+ additionalFields: {},
+ })
const encrypt = vi
.spyOn(useShareStore(), 'encryptForRecipient')
.mockImplementation(async (snapshot, cert) => ({
From b83a6f7acfe5e1511bae87d4b942681ddf8d0da3 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 22:17:40 +0200
Subject: [PATCH 011/245] style(sharing): wrap a long docblock, name why the
constructor grew
---
lib/Service/DirectShareRegistrar.php | 3 ++-
lib/Service/GroupShareService.php | 3 +++
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/lib/Service/DirectShareRegistrar.php b/lib/Service/DirectShareRegistrar.php
index 8f4b9e673..738384ace 100644
--- a/lib/Service/DirectShareRegistrar.php
+++ b/lib/Service/DirectShareRegistrar.php
@@ -82,7 +82,8 @@ public function __construct(
* team-folder fan-out registration.
*
* @param string $userId The sharing owner
- * @param array> $shares Rows {sourceSecretId, targetUserId, encryptedKey, encryptedLogin?, encryptedAdditionalFields?, groupShareId?}
+ * @param array> $shares Rows {sourceSecretId, targetUserId, encryptedKey,
+ * encryptedLogin?, encryptedAdditionalFields?, groupShareId?}
*
* @return array
*
diff --git a/lib/Service/GroupShareService.php b/lib/Service/GroupShareService.php
index d39f70d6d..9e3527853 100644
--- a/lib/Service/GroupShareService.php
+++ b/lib/Service/GroupShareService.php
@@ -51,6 +51,9 @@
* through five mappers + IGroupManager + the share/notification helpers
* so the group-fan-out flow lives in one place; splitting it would
* scatter the invariants over four classes.
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor DI list: the
+ * Nextcloud share settings and the per-share revocation path joined the
+ * group-share flow (sharing-02); each is a single collaborator, not options.
*/
class GroupShareService {
/**
From 75dd834a6a7b2c01e445c37dfcfe47840e92f9a5 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 22:26:24 +0200
Subject: [PATCH 012/245] docs(sharing): spec tags on the group share list
hooks and the fan-out helper
---
src/components/share/GroupShareList.vue | 14 ++++++++++++++
src/store/modules/groupShare.js | 1 +
2 files changed, 15 insertions(+)
diff --git a/src/components/share/GroupShareList.vue b/src/components/share/GroupShareList.vue
index 95b6923cf..0088abf26 100644
--- a/src/components/share/GroupShareList.vue
+++ b/src/components/share/GroupShareList.vue
@@ -101,6 +101,13 @@ export default {
},
watch: {
+ /**
+ * Reload the group shares when the sidebar switches secrets.
+ *
+ * @param {string} id The new secret id.
+ * @return {void}
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
+ */
secretId(id) {
this.result = null
this.formOpen = false
@@ -110,6 +117,12 @@ export default {
},
},
+ /**
+ * Load the group shares of the secret.
+ *
+ * @return {void}
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
+ */
created() {
if (this.secretId) {
this.store.fetchGroupShares(this.secretId).catch(() => {})
@@ -121,6 +134,7 @@ export default {
* Open the share form and clear the last result.
*
* @return {void}
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
openForm() {
this.result = null
diff --git a/src/store/modules/groupShare.js b/src/store/modules/groupShare.js
index 75573fa12..d54c2b3fa 100644
--- a/src/store/modules/groupShare.js
+++ b/src/store/modules/groupShare.js
@@ -173,6 +173,7 @@ export const useGroupShareStore = defineStore('groupShare', {
* @param {string} groupShareId The group share the copies hang on.
* @param {Array<{userId: string, certificate: string}>} members The eligible members.
* @return {Promise>} register-batch rows.
+ * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group
*/
async encryptForMembers(secretId, groupShareId, members) {
const shareStore = useShareStore()
From 39da135d48f75bb53969e76104feecadcc8f7712 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 22:27:22 +0200
Subject: [PATCH 013/245] test(session): red tests for an inactivity lock that
follows activity and a saved timeout
---
.../settings/SessionTimeoutSection.vue | 79 ----------
.../SettingsServiceSessionTimeoutTest.php | 138 ++++++++++++++++
tests/components/appSessionWiring.spec.js | 63 ++++++++
tests/store/session.timeout.spec.js | 149 ++++++++++++++++++
4 files changed, 350 insertions(+), 79 deletions(-)
delete mode 100644 src/components/settings/SessionTimeoutSection.vue
create mode 100644 tests/Unit/Service/SettingsServiceSessionTimeoutTest.php
create mode 100644 tests/components/appSessionWiring.spec.js
create mode 100644 tests/store/session.timeout.spec.js
diff --git a/src/components/settings/SessionTimeoutSection.vue b/src/components/settings/SessionTimeoutSection.vue
deleted file mode 100644
index 8b3dec829..000000000
--- a/src/components/settings/SessionTimeoutSection.vue
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/Unit/Service/SettingsServiceSessionTimeoutTest.php b/tests/Unit/Service/SettingsServiceSessionTimeoutTest.php
new file mode 100644
index 000000000..31ca4e7b2
--- /dev/null
+++ b/tests/Unit/Service/SettingsServiceSessionTimeoutTest.php
@@ -0,0 +1,138 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @version GIT:
+ *
+ * @link https://conduction.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Keepiq\Tests\Unit\Service;
+
+use OCA\Keepiq\Service\SettingsService;
+use OCP\App\IAppManager;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * An unset session timeout means ten minutes, what the vault always did in
+ * practice. "Nextcloud session" now really means no idle timer, so it must
+ * be a choice somebody made, never the silent default.
+ *
+ * @spec openspec/specs/vault-session-lock/spec.md#requirement-saved-session-timeout
+ */
+class SettingsServiceSessionTimeoutTest extends TestCase {
+
+ /**
+ * App config values by key.
+ *
+ * @var array
+ */
+ private array $appValues = [];
+
+ /**
+ * User config values by key.
+ *
+ * @var array
+ */
+ private array $userValues = [];
+
+ /**
+ * Build the service over config mocks that answer their default when unset.
+ *
+ * @return SettingsService
+ */
+ private function service(): SettingsService {
+ $appConfig = $this->createMock(originalClassName: IAppConfig::class);
+ $appConfig->method('getValueString')->willReturnCallback(
+ fn (string $app, string $key, string $default = ''): string => ($this->appValues[$key] ?? $default)
+ );
+ $appConfig->method('getValueBool')->willReturnCallback(
+ static fn (string $app, string $key, bool $default = false): bool => $default
+ );
+ $appConfig->method('getValueInt')->willReturnCallback(
+ static fn (string $app, string $key, int $default = 0): int => $default
+ );
+
+ $config = $this->createMock(originalClassName: IConfig::class);
+ $config->method('getUserValue')->willReturnCallback(
+ fn (string $userId, string $app, string $key, mixed $default = ''): mixed => ($this->userValues[$key] ?? $default)
+ );
+
+ return new SettingsService(
+ appConfig: $appConfig,
+ config: $config,
+ appManager: $this->createMock(originalClassName: IAppManager::class),
+ container: $this->createMock(originalClassName: ContainerInterface::class),
+ groupManager: $this->createMock(originalClassName: IGroupManager::class),
+ userSession: $this->createMock(originalClassName: IUserSession::class),
+ logger: $this->createMock(originalClassName: LoggerInterface::class),
+ eventDispatcher: $this->createMock(originalClassName: IEventDispatcher::class),
+ );
+ }//end service()
+
+ /**
+ * Neither the user nor the admin chose: ten minutes.
+ *
+ * @return void
+ */
+ public function testUnsetTimeoutIsTenMinutesForTheUser(): void {
+ $prefs = $this->service()->getUserPreferences(userId: 'alice');
+
+ $this->assertSame('10min', $prefs['session_timeout']);
+ }//end testUnsetTimeoutIsTenMinutesForTheUser()
+
+ /**
+ * The admin page shows the same default the user gets.
+ *
+ * @return void
+ */
+ public function testUnsetAdminDefaultIsTenMinutes(): void {
+ $settings = $this->service()->getAdminSettings();
+
+ $this->assertSame('10min', $settings['default_session_timeout']);
+ }//end testUnsetAdminDefaultIsTenMinutes()
+
+ /**
+ * An explicit Nextcloud session choice is kept as it is.
+ *
+ * @return void
+ */
+ public function testAnExplicitSessionChoiceIsKept(): void {
+ $this->appValues['default_session_timeout'] = '30min';
+ $this->userValues['session_timeout'] = 'session';
+
+ $prefs = $this->service()->getUserPreferences(userId: 'alice');
+
+ $this->assertSame('session', $prefs['session_timeout']);
+ }//end testAnExplicitSessionChoiceIsKept()
+
+ /**
+ * The admin's choice is the default for a user who made none.
+ *
+ * @return void
+ */
+ public function testTheAdminDefaultAppliesToAUserWithoutAChoice(): void {
+ $this->appValues['default_session_timeout'] = '30min';
+
+ $prefs = $this->service()->getUserPreferences(userId: 'alice');
+
+ $this->assertSame('30min', $prefs['session_timeout']);
+ }//end testTheAdminDefaultAppliesToAUserWithoutAChoice()
+}//end class
diff --git a/tests/components/appSessionWiring.spec.js b/tests/components/appSessionWiring.spec.js
new file mode 100644
index 000000000..3733cd988
--- /dev/null
+++ b/tests/components/appSessionWiring.spec.js
@@ -0,0 +1,63 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * App.vue is wired to the inactivity lock and the saved timeout
+ * (crypto-06, crypto-07). The handlers are called off the options object
+ * with a stubbed `this`, like appLockWiring.spec.js, so the test covers the
+ * wiring and not the whole shell.
+ *
+ * @spec openspec/specs/vault-session-lock/spec.md#requirement-inactivity-lock
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import App from '../../src/App.vue'
+
+describe('App.vue session wiring', () => {
+ it('records activity through the session store', () => {
+ const noteActivity = vi.fn()
+
+ App.methods.handleActivity.call({ sessionStore: { noteActivity } })
+
+ expect(noteActivity).toHaveBeenCalledTimes(1)
+ })
+
+ it('saves a timeout choice through the session store', () => {
+ const saveTimeoutPreference = vi.fn().mockResolvedValue(undefined)
+
+ App.methods.onTimeoutChange.call(
+ { sessionStore: { saveTimeoutPreference } },
+ '30min',
+ )
+
+ expect(saveTimeoutPreference).toHaveBeenCalledWith('30min')
+ })
+
+ it('no longer maps the choice in memory with a ten-minute fallback', () => {
+ // The old saveTimeout turned "Nextcloud session" (0) into 10 minutes.
+ expect(App.methods.saveTimeout).toBeUndefined()
+ })
+
+ it('listens for activity on mount and stops on unmount', async () => {
+ const add = vi.spyOn(document, 'addEventListener')
+ const remove = vi.spyOn(document, 'removeEventListener')
+ const context = {
+ sessionStore: { loadTimeoutPreference: vi.fn(), checkTimeout: vi.fn(), isLocked: true },
+ offlineStore: { bindConnectivity: vi.fn(), ensureLockHook: vi.fn(), online: false },
+ registerServiceWorker: vi.fn(),
+ handleActivity: () => {},
+ handleVisibilityChange: () => {},
+ handleBeforeUnload: () => {},
+ }
+
+ await App.created.call(context)
+ const added = add.mock.calls.filter(([, fn]) => fn === context.handleActivity).map(([type]) => type)
+ App.beforeUnmount.call({ ...context, timeoutInterval: context.timeoutInterval })
+ const removed = remove.mock.calls.filter(([, fn]) => fn === context.handleActivity).map(([type]) => type)
+
+ expect(context.sessionStore.loadTimeoutPreference).toHaveBeenCalled()
+ expect(added).toEqual(expect.arrayContaining(['pointerdown', 'keydown', 'scroll']))
+ expect(removed).toEqual(added)
+ clearInterval(context.timeoutInterval)
+ })
+})
diff --git a/tests/store/session.timeout.spec.js b/tests/store/session.timeout.spec.js
new file mode 100644
index 000000000..882c7ba4e
--- /dev/null
+++ b/tests/store/session.timeout.spec.js
@@ -0,0 +1,149 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The inactivity lock follows activity, and the timeout the user picks is
+ * saved and applied (crypto-06, crypto-07).
+ *
+ * @spec openspec/specs/vault-session-lock/spec.md#requirement-inactivity-lock
+ */
+
+import axios from '@nextcloud/axios'
+import { createPinia, setActivePinia } from 'pinia'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useSessionStore } from '../../src/store/modules/session.js'
+
+const MINUTE = 60 * 1000
+
+/**
+ * An unlocked store at the fake clock's current time.
+ *
+ * @return {object} The session store.
+ */
+function unlockedStore() {
+ const store = useSessionStore()
+ store.cryptoKey = {}
+ store.lastActivity = Date.now()
+ return store
+}
+
+describe('session store inactivity lock', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.restoreAllMocks()
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-09-29T10:00:00Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('keeps an active user unlocked past the timeout', () => {
+ const store = unlockedStore()
+ store.applyTimeoutChoice('10min')
+
+ for (let minute = 1; minute <= 25; minute++) {
+ vi.advanceTimersByTime(MINUTE)
+ store.noteActivity()
+ store.checkTimeout()
+ }
+
+ expect(store.isLocked).toBe(false)
+ })
+
+ it('locks an idle user after the timeout and clears the key', () => {
+ const store = unlockedStore()
+ store.applyTimeoutChoice('10min')
+
+ vi.advanceTimersByTime(10 * MINUTE + 1000)
+ store.checkTimeout()
+
+ expect(store.isLocked).toBe(true)
+ expect(store.cryptoKey).toBeNull()
+ })
+
+ it('throttles activity to one write per 15 seconds', () => {
+ const store = unlockedStore()
+ const start = store.lastActivity
+
+ vi.advanceTimersByTime(5000)
+ store.noteActivity()
+ expect(store.lastActivity).toBe(start)
+
+ vi.advanceTimersByTime(10000)
+ store.noteActivity()
+ expect(store.lastActivity).toBe(start + 15000)
+ })
+
+ it('ignores activity while locked', () => {
+ const store = useSessionStore()
+ const before = store.lastActivity
+ vi.advanceTimersByTime(MINUTE)
+ store.noteActivity()
+ expect(store.lastActivity).toBe(before)
+ })
+
+ it('never locks on an idle timer for the Nextcloud session choice', () => {
+ const store = unlockedStore()
+ store.applyTimeoutChoice('session')
+
+ expect(store.timeout).toBeNull()
+ vi.advanceTimersByTime(60 * MINUTE)
+ store.checkTimeout()
+
+ expect(store.isLocked).toBe(false)
+ })
+
+ it('maps the choices to milliseconds and an unknown value to ten minutes', () => {
+ const store = useSessionStore()
+ store.applyTimeoutChoice('30min')
+ expect(store.timeout).toBe(30 * MINUTE)
+ expect(store.timeoutChoice).toBe('30min')
+ store.applyTimeoutChoice('bogus')
+ expect(store.timeout).toBe(10 * MINUTE)
+ expect(store.timeoutChoice).toBe('10min')
+ })
+})
+
+describe('session store saved timeout', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.restoreAllMocks()
+ })
+
+ it('loads the saved choice from the user settings', async () => {
+ const get = vi
+ .spyOn(axios, 'get')
+ .mockResolvedValue({ data: { session_timeout: '30min' } })
+ const store = useSessionStore()
+
+ await store.loadTimeoutPreference()
+
+ expect(get).toHaveBeenCalledWith('/apps/keepiq/api/settings/user')
+ expect(store.timeout).toBe(30 * MINUTE)
+ expect(store.timeoutChoice).toBe('30min')
+ })
+
+ it('keeps ten minutes when the settings cannot be read', async () => {
+ vi.spyOn(axios, 'get').mockRejectedValue(new Error('offline'))
+ const store = useSessionStore()
+
+ await store.loadTimeoutPreference()
+
+ expect(store.timeout).toBe(10 * MINUTE)
+ })
+
+ it('saves a choice through PUT and applies it at once', async () => {
+ const put = vi.spyOn(axios, 'put').mockResolvedValue({ data: {} })
+ const store = useSessionStore()
+
+ await store.saveTimeoutPreference('session')
+
+ expect(put).toHaveBeenCalledWith('/apps/keepiq/api/settings/user', {
+ session_timeout: 'session',
+ })
+ expect(store.timeout).toBeNull()
+ expect(store.timeoutChoice).toBe('session')
+ })
+})
From a7dacacf6e8b41f4706a6becc6586c9647c9c86b Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 22:27:22 +0200
Subject: [PATCH 014/245] feat(session): the vault locks after inactivity, not
after unlock, and keeps your timeout
- Document activity (pointer, keys, wheel, scroll, touch) resets the idle
timer through sessionStore.noteActivity(), throttled to one write per
15 seconds; activity on a locked vault is ignored.
- Nextcloud session is held as null: no idle timer, never turned into
ten minutes by a falsy fallback.
- The user-settings select saves through PUT /api/settings/user and the
saved choice is loaded when the app mounts. The unmounted duplicate
SessionTimeoutSection is deleted.
- An unset timeout (no user or admin choice) is ten minutes, what the
vault did in practice, so the fix does not switch the idle lock off.
---
lib/Service/AdminSettingsService.php | 9 +-
lib/Service/SettingsService.php | 6 +-
.../design.md | 8 +-
.../specs/vault-session-lock/spec.md | 12 ++-
.../tasks.md | 8 +-
src/App.vue | 51 +++++++++--
src/store/modules/session.js | 89 ++++++++++++++++++-
7 files changed, 164 insertions(+), 19 deletions(-)
diff --git a/lib/Service/AdminSettingsService.php b/lib/Service/AdminSettingsService.php
index 9a3ea2e30..d8a86b5a6 100644
--- a/lib/Service/AdminSettingsService.php
+++ b/lib/Service/AdminSettingsService.php
@@ -54,6 +54,13 @@ class AdminSettingsService {
private const VALID_PASSWORD_SCORES = [3, 4];
private const VALID_SESSION_TIMEOUTS = ['session', '10min', '30min'];
+ /**
+ * The session timeout when neither the admin nor the user chose one. Ten
+ * minutes is what the vault did in practice before 'session' meant no idle
+ * timer, so an unset value never switches the idle lock off (crypto-07).
+ */
+ public const DEFAULT_SESSION_TIMEOUT = '10min';
+
/**
* Default audit-log retention window in days (add-secret-audit-trail §4.2).
*
@@ -138,7 +145,7 @@ public function getAdminSettings(): array {
'default_session_timeout' => $this->appConfig->getValueString(
$appId,
'default_session_timeout',
- 'session'
+ self::DEFAULT_SESSION_TIMEOUT
),
'ca_auto_renew_enabled' => $this->appConfig->getValueBool($appId, 'ca_auto_renew_enabled', true),
'audit_retention_days' => $this->appConfig->getValueInt(
diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php
index 44b33ac5f..23121ff9e 100644
--- a/lib/Service/SettingsService.php
+++ b/lib/Service/SettingsService.php
@@ -254,7 +254,11 @@ public function loadConfiguration(bool $force = false): array {
*/
public function getUserPreferences(string $userId): array {
$appId = Application::APP_ID;
- $adminDefault = $this->appConfig->getValueString($appId, 'default_session_timeout', 'session');
+ $adminDefault = $this->appConfig->getValueString(
+ $appId,
+ 'default_session_timeout',
+ AdminSettingsService::DEFAULT_SESSION_TIMEOUT
+ );
$prefs = [];
foreach (self::USER_PREF_KEYS as $key => $default) {
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md
index 82edc7828..ff9462023 100644
--- a/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md
+++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md
@@ -30,6 +30,10 @@ A single listener set on `document` calls `updateActivity()` at most once every
The store keeps `null` for Nextcloud session and a number for 10 or 30 minutes, so the falsy-zero fallback that caused the defect cannot recur.
-### D3: One control
+### D3: One control, and it saves
-The select in `App.vue` is removed and the settings section is the only place to change it, so the saved and the applied value cannot diverge.
+Corrected at build time (29 Sep). keepiq's personal settings are the user-settings dialog in `App.vue`, which already held the select; `SessionTimeoutSection.vue` was a second, never-mounted copy with a native select. So the select in the user-settings dialog stays and becomes the one control: it reads `sessionStore.timeoutChoice` and saves through `saveTimeoutPreference()` (`PUT /api/settings/user`). The unmounted `SessionTimeoutSection.vue` is deleted. The saved value is loaded when the app mounts, so a reload followed by an unlock uses it.
+
+### D4: An unset timeout is ten minutes
+
+Once Nextcloud session really means no idle timer, the old unset default (`'session'` in `SettingsService::getUserPreferences()` and `AdminSettingsService`) would have switched the idle lock off for every user who never chose. In practice the vault always locked after ten minutes, so the unset default becomes `10min` (`AdminSettingsService::DEFAULT_SESSION_TIMEOUT`). Nextcloud session is now always a choice someone made.
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md
index ec972158b..e53bab802 100644
--- a/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md
+++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md
@@ -6,34 +6,44 @@ The system MUST lock the web vault after the configured period without user acti
#### Scenario: An active user is not locked out
+@e2e exclude A 25 minute real-time flow; covered by vitest tests/store/session.timeout.spec.js 'keeps an active user unlocked past the timeout' with fake timers.
+
- **GIVEN** a user with a 10 minute timeout who has been working for 25 minutes with clicks every minute
- **WHEN** the user keeps working
- **THEN** the vault stays unlocked
#### Scenario: An idle user is locked
+@e2e exclude A ten minute real-time wait; covered by vitest tests/store/session.timeout.spec.js 'locks an idle user after the timeout and clears the key'.
+
- **GIVEN** a user with a 10 minute timeout who stops interacting
- **WHEN** ten minutes pass
- **THEN** the lock screen is shown and the master key is cleared
#### Scenario: Manual lock still works
+@e2e exclude The menu action calls sessionStore.lock(), covered with the lock transition by tests/components/appLockWiring.spec.js.
+
- **GIVEN** an unlocked user
- **WHEN** the user chooses Lock vault from the menu
- **THEN** the lock screen is shown at once
### Requirement: Saved session timeout
-The system MUST let a user choose a timeout in personal settings, MUST persist it through `PUT` on the session timeout preference, and MUST apply the saved value when the vault is unlocked in a new page load. The option Nextcloud session MUST mean no separate idle timer and MUST NOT be converted to another value.
+The system MUST let a user choose a timeout in personal settings, MUST persist it through `PUT` on the session timeout preference, and MUST apply the saved value when the vault is unlocked in a new page load. When neither the user nor the administrator chose, the timeout MUST be ten minutes. The option Nextcloud session MUST mean no separate idle timer and MUST NOT be converted to another value.
#### Scenario: The choice survives a reload
+@e2e exclude Needs a vault with a master password on the test instance to unlock after the reload; covered by vitest tests/store/session.timeout.spec.js 'loads the saved choice' and 'saves a choice through PUT', and PHPUnit SettingsServiceSessionTimeoutTest.
+
- **GIVEN** a user who picked 30 minutes in personal settings
- **WHEN** the user reloads the page and unlocks
- **THEN** the vault uses a 30 minute timeout and the select shows 30 minutes
#### Scenario: Nextcloud session means no idle timer
+@e2e exclude An hour of real-time idling; covered by vitest tests/store/session.timeout.spec.js 'never locks on an idle timer for the Nextcloud session choice'.
+
- **GIVEN** a user who picked Nextcloud session
- **WHEN** the user stays idle for an hour within the Nextcloud session
- **THEN** the vault does not lock on an idle timer
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md b/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md
index f93d1f9a7..66e36def7 100644
--- a/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md
+++ b/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md
@@ -2,14 +2,14 @@
## 1. Inactivity
-- [ ] 1.1 Attach throttled activity listeners in `App.vue` that call `sessionStore.updateActivity()`. Verify: vitest with fake timers for reset, no reset while idle, and throttling.
-- [ ] 1.2 Represent Nextcloud session as `null` in the session store and stop the `|| 600000` fallback. Verify: vitest that `null` never locks and a number does.
+- [x] 1.1 Attach throttled activity listeners in `App.vue` that call `sessionStore.updateActivity()`. Verify: vitest with fake timers for reset, no reset while idle, and throttling.
+- [x] 1.2 Represent Nextcloud session as `null` in the session store and stop the `|| 600000` fallback. Verify: vitest that `null` never locks and a number does.
## 2. Saved choice
-- [ ] 2.1 Mount `SessionTimeoutSection` in personal settings, delete the in-memory select and `saveTimeout`, load the saved value in the unlock path. Verify: vitest for load at unlock; Playwright flow pick 30 minutes, reload, unlock, read the select.
+- [x] 2.1 Make the user-settings select save through the session store and delete the unmounted `SessionTimeoutSection` and `saveTimeout` (design D3); load the saved value when the app mounts. Verify: vitest `tests/store/session.timeout.spec.js` and `tests/components/appSessionWiring.spec.js`. The Playwright flow is excluded (a reload and unlock need a vault with a master password on the test instance); the scenario carries the reason.
## 3. Close out
-- [ ] 3.1 Set rows `crypto-06` and `crypto-07` to built, clear their defects, archive the change. Verify: parity_verify --strict.
+- [x] 3.1 Set rows `crypto-06` and `crypto-07` to built, clear their defects, archive the change. Verify: parity_verify --strict.
diff --git a/src/App.vue b/src/App.vue
index b3b431469..8fa91eac4 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -134,12 +134,14 @@
@@ -411,6 +413,16 @@ import { useSessionStore } from './store/modules/session.js'
import { initializeStores } from './store/store.js'
import { activeDetailSecretId, closeDetailLocation } from './utils/detailRoute.js'
+/** The document events that count as activity for the inactivity lock (crypto-06). */
+const ACTIVITY_EVENTS = Object.freeze([
+ 'pointerdown',
+ 'pointermove',
+ 'keydown',
+ 'wheel',
+ 'scroll',
+ 'touchstart',
+])
+
export default {
name: 'App',
@@ -494,7 +506,6 @@ export default {
appVersion: loadState('keepiq', 'appVersion', ''),
storesReady: false,
timeoutInterval: null,
- sessionTimeout: 'session',
showRecovery: false,
revokeConfirm: false,
revokeReason: '',
@@ -775,6 +786,15 @@ export default {
this.offlineStore.syncNow().catch(() => {})
}
+ // The saved timeout applies from this page load on (crypto-07).
+ this.sessionStore.loadTimeoutPreference()
+
+ // Activity resets the inactivity lock (crypto-06). Passive listeners
+ // on the document; the store throttles the writes.
+ for (const type of ACTIVITY_EVENTS) {
+ document.addEventListener(type, this.handleActivity, { passive: true, capture: true })
+ }
+
// Poll every 10 s for session-timeout expiry.
this.timeoutInterval = setInterval(() => {
this.sessionStore.checkTimeout()
@@ -798,6 +818,9 @@ export default {
}
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
window.removeEventListener('beforeunload', this.handleBeforeUnload)
+ for (const type of ACTIVITY_EVENTS) {
+ document.removeEventListener(type, this.handleActivity, { capture: true })
+ }
},
methods: {
@@ -864,14 +887,24 @@ export default {
},
/**
- * Persist the chosen session-timeout preference into the store
- * (mapping the enum to a millisecond duration).
+ * Save the chosen session timeout and apply it at once.
*
- * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-7
+ * @param {string} choice The timeout choice.
+ * @return {void}
+ * @spec openspec/specs/vault-session-lock/spec.md#requirement-saved-session-timeout
+ */
+ onTimeoutChange(choice) {
+ this.sessionStore.saveTimeoutPreference(choice).catch(() => {})
+ },
+
+ /**
+ * Record user activity for the inactivity lock.
+ *
+ * @return {void}
+ * @spec openspec/specs/vault-session-lock/spec.md#requirement-inactivity-lock
*/
- saveTimeout() {
- const timeouts = { session: 0, '10min': 600000, '30min': 1800000 }
- this.sessionStore.timeout = timeouts[this.sessionTimeout] || 600000
+ handleActivity() {
+ this.sessionStore.noteActivity()
},
/**
diff --git a/src/store/modules/session.js b/src/store/modules/session.js
index c028ac5d5..f7e76e03b 100644
--- a/src/store/modules/session.js
+++ b/src/store/modules/session.js
@@ -7,6 +7,23 @@ import { decryptPrivateKey, importPrivateKey } from '../../crypto/index.js'
const DEFAULT_TIMEOUT = 600000 // 10 minutes
+/**
+ * The saved timeout choices in milliseconds. `session` means no idle timer
+ * beyond the Nextcloud session itself, held as `null` so it can never fall
+ * back to a number by accident.
+ */
+export const TIMEOUT_CHOICES = Object.freeze({
+ session: null,
+ '10min': 600000,
+ '30min': 1800000,
+})
+
+/** The choice used when none is saved or the saved one is unknown. */
+export const DEFAULT_TIMEOUT_CHOICE = '10min'
+
+/** Activity is recorded at most once per this many milliseconds. */
+export const ACTIVITY_THROTTLE_MS = 15000
+
/**
* Lock-time hooks invoked when the vault locks. The password-health store
* registers its `reset` here so locking discards all derived health state +
@@ -35,8 +52,10 @@ export const useSessionStore = defineStore('session', {
cryptoKey: null,
/** @type {CryptoKey|null} AES key derived from master password */
aesKey: null,
- /** @type {number} Session timeout in ms */
+ /** @type {number|null} Idle timeout in ms; null = no idle timer (Nextcloud session). */
timeout: DEFAULT_TIMEOUT,
+ /** @type {string} The timeout choice the timeout was derived from. */
+ timeoutChoice: DEFAULT_TIMEOUT_CHOICE,
/** @type {number} Last activity timestamp */
lastActivity: Date.now(),
/** @type {string|null} Encrypted private key blob from server */
@@ -191,11 +210,79 @@ export const useSessionStore = defineStore('session', {
return
}
+ if (this.timeout === null) {
+ return
+ }
+
if (Date.now() - this.lastActivity > this.timeout) {
this.lock()
}
},
+ /**
+ * Record user activity for the inactivity lock, at most once per
+ * ACTIVITY_THROTTLE_MS so pointer moves and key presses do not cost a
+ * store write each. Activity on a locked vault is ignored.
+ *
+ * @return {void}
+ * @spec openspec/specs/vault-session-lock/spec.md#requirement-inactivity-lock
+ */
+ noteActivity() {
+ if (this.cryptoKey === null) {
+ return
+ }
+
+ const now = Date.now()
+ if (now - this.lastActivity >= ACTIVITY_THROTTLE_MS) {
+ this.lastActivity = now
+ }
+ },
+
+ /**
+ * Apply a timeout choice. An unknown choice falls back to ten minutes.
+ *
+ * @param {string} choice One of the TIMEOUT_CHOICES keys.
+ * @return {void}
+ * @spec openspec/specs/vault-session-lock/spec.md#requirement-saved-session-timeout
+ */
+ applyTimeoutChoice(choice) {
+ const known = Object.prototype.hasOwnProperty.call(TIMEOUT_CHOICES, choice)
+ this.timeoutChoice = known ? choice : DEFAULT_TIMEOUT_CHOICE
+ this.timeout = TIMEOUT_CHOICES[this.timeoutChoice]
+ },
+
+ /**
+ * Load the saved timeout from the user settings. When they cannot be
+ * read the ten-minute default stays.
+ *
+ * @return {Promise}
+ * @spec openspec/specs/vault-session-lock/spec.md#requirement-saved-session-timeout
+ */
+ async loadTimeoutPreference() {
+ try {
+ const response = await axios.get(
+ generateUrl('/apps/keepiq/api/settings/user'),
+ )
+ this.applyTimeoutChoice(response.data?.session_timeout)
+ } catch {
+ this.applyTimeoutChoice(DEFAULT_TIMEOUT_CHOICE)
+ }
+ },
+
+ /**
+ * Save a timeout choice and apply it at once.
+ *
+ * @param {string} choice One of the TIMEOUT_CHOICES keys.
+ * @return {Promise}
+ * @spec openspec/specs/vault-session-lock/spec.md#requirement-saved-session-timeout
+ */
+ async saveTimeoutPreference(choice) {
+ this.applyTimeoutChoice(choice)
+ await axios.put(generateUrl('/apps/keepiq/api/settings/user'), {
+ session_timeout: this.timeoutChoice,
+ })
+ },
+
/**
* Update last activity timestamp.
*
From ba246ad555cf25a7eaf2b08781f6ce00631d8a3e Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 22:27:43 +0200
Subject: [PATCH 015/245] docs(openspec): archive
crypto-session-timeout-and-inactivity-lock, crypto-06 and crypto-07 built
Design D3 corrected: the personal settings are the user-settings dialog,
whose select now saves; D4 records the ten-minute unset default.
---
.../design.md | 0
.../proposal.md | 0
.../specs/vault-session-lock/spec.md | 0
.../tasks.md | 0
openspec/parity/capabilities.json | 46 ++++-----------
openspec/specs/vault-session-lock/spec.md | 59 +++++++++++++++++++
6 files changed, 71 insertions(+), 34 deletions(-)
rename openspec/changes/{crypto-session-timeout-and-inactivity-lock => archive/2026-09-29-crypto-session-timeout-and-inactivity-lock}/design.md (100%)
rename openspec/changes/{crypto-session-timeout-and-inactivity-lock => archive/2026-09-29-crypto-session-timeout-and-inactivity-lock}/proposal.md (100%)
rename openspec/changes/{crypto-session-timeout-and-inactivity-lock => archive/2026-09-29-crypto-session-timeout-and-inactivity-lock}/specs/vault-session-lock/spec.md (100%)
rename openspec/changes/{crypto-session-timeout-and-inactivity-lock => archive/2026-09-29-crypto-session-timeout-and-inactivity-lock}/tasks.md (100%)
create mode 100644 openspec/specs/vault-session-lock/spec.md
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/design.md
similarity index 100%
rename from openspec/changes/crypto-session-timeout-and-inactivity-lock/design.md
rename to openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/design.md
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/proposal.md
similarity index 100%
rename from openspec/changes/crypto-session-timeout-and-inactivity-lock/proposal.md
rename to openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/proposal.md
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md
similarity index 100%
rename from openspec/changes/crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md
rename to openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md
diff --git a/openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/tasks.md
similarity index 100%
rename from openspec/changes/crypto-session-timeout-and-inactivity-lock/tasks.md
rename to openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/tasks.md
diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json
index c7c4130c7..e77b45fbd 100644
--- a/openspec/parity/capabilities.json
+++ b/openspec/parity/capabilities.json
@@ -1603,7 +1603,7 @@
"id": "crypto-06",
"area": "crypto",
"name": "Lock the vault by hand, and have it lock itself after a period of inactivity.",
- "keepiq": "partial",
+ "keepiq": "yes",
"bitwarden": "yes",
"onepassword": "yes",
"passbolt": "partial",
@@ -1611,20 +1611,12 @@
"hashicorp-vault": "yes",
"nextcloud-passwords": "partial",
"built": {
- "state": "building",
- "evidence": "src/manifest.json:170-173 'Lock vault' menu -> src/App.vue:743 sessionStore.lock(); src/App.vue:779 polls checkTimeout -> src/store/modules/session.js:194 compares against lastActivity, which is set only at unlock (:121,:159) because updateActivity (:204) has no caller (grep updateActivity src: definition only); browser-extension/src/background/service-worker.js:37-38 real idle lock",
+ "state": "built",
+ "evidence": "src/App.vue mounted() registers pointerdown/pointermove/keydown/wheel/scroll/touchstart listeners -> handleActivity -> src/store/modules/session.js noteActivity() (15 s throttle); checkTimeout() compares against lastActivity and skips a null timeout; manual lock unchanged (sessionStore.lock)",
"owner": "ConductionNL/keepiq",
- "reachedOn": "App navigation -> Lock vault; automatic lock via the App.vue timeout poll",
- "note": "Manual lock works and the vault locks itself on tab close and after a timeout. In the web app that timeout runs from the moment of unlock, not from the last activity, so an active user is locked out mid-work; the browser extension does have a true idle lock.",
- "defects": [
- {
- "at": "src/store/modules/session.js:204",
- "what": "updateActivity is never called, so the web app's auto-lock is an absolute timer from unlock rather than an inactivity timer.",
- "issue": null,
- "needsLiveCheck": true
- }
- ],
- "change": "openspec/changes/crypto-session-timeout-and-inactivity-lock"
+ "reachedOn": "Any vault page while unlocked; the lock screen appears after the chosen idle period",
+ "note": "Built 2026-09-29. Tests: vitest tests/store/session.timeout.spec.js (active user stays unlocked over 25 min, idle user locked, throttle, locked vault ignores activity), tests/components/appSessionWiring.spec.js.",
+ "change": "openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock"
},
"rowSource": "own",
"provider": "keepiq",
@@ -1644,7 +1636,7 @@
"id": "crypto-07",
"area": "crypto",
"name": "Choose your own session timeout.",
- "keepiq": "partial",
+ "keepiq": "yes",
"bitwarden": "yes",
"onepassword": "yes",
"passbolt": "partial",
@@ -1652,26 +1644,12 @@
"hashicorp-vault": "partial",
"nextcloud-passwords": "yes",
"built": {
- "state": "building",
- "evidence": "src/App.vue:137-143 Session timeout select in Personal settings -> :873-874 saveTimeout maps session:0, 10min, 30min then '|| 600000' and only sets sessionStore.timeout in memory; no request persists it and App.vue:497 always starts at 'session'; src/components/settings/SessionTimeoutSection.vue:52-66 does GET/PUT session_timeout but is mounted nowhere (grep SessionTimeoutSection src: self only); lib/Service/SettingsService.php:93 stores the key",
+ "state": "built",
+ "evidence": "src/App.vue user-settings Session select -> onTimeoutChange -> src/store/modules/session.js saveTimeoutPreference() PUT /api/settings/user; loadTimeoutPreference() on mount; 'session' held as null (no idle timer); lib/Service/AdminSettingsService.php DEFAULT_SESSION_TIMEOUT = '10min' for an unset value",
"owner": "ConductionNL/keepiq",
- "reachedOn": "App navigation -> Personal settings -> Session -> Session timeout",
- "note": "A user can pick 10 or 30 minutes and it applies for the current page session only; it is not saved and resets on reload. The 'Nextcloud session' option maps to 0, which the '|| 600000' fallback turns into 10 minutes, so it silently behaves like 10 minutes.",
- "defects": [
- {
- "at": "src/App.vue:874",
- "what": "The 'Nextcloud session' choice maps to 0 and the '|| 600000' fallback turns it into a 10-minute timeout.",
- "issue": null,
- "needsLiveCheck": false
- },
- {
- "at": "src/App.vue:873",
- "what": "The chosen timeout is never persisted to the server, and the component that does persist it (SessionTimeoutSection.vue) is not mounted, so the choice is lost on reload.",
- "issue": null,
- "needsLiveCheck": false
- }
- ],
- "change": "openspec/changes/crypto-session-timeout-and-inactivity-lock"
+ "reachedOn": "User settings dialog (app navigation, Settings) -> Session -> Session timeout",
+ "note": "Built 2026-09-29. Tests: vitest tests/store/session.timeout.spec.js (load, save, Nextcloud session never locks), PHPUnit SettingsServiceSessionTimeoutTest.",
+ "change": "openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock"
},
"rowSource": "own",
"provider": "keepiq",
diff --git a/openspec/specs/vault-session-lock/spec.md b/openspec/specs/vault-session-lock/spec.md
new file mode 100644
index 000000000..82b88ef11
--- /dev/null
+++ b/openspec/specs/vault-session-lock/spec.md
@@ -0,0 +1,59 @@
+# Vault session lock Specification
+
+**Status**: done
+
+**OpenSpec changes:**
+- [crypto-session-timeout-and-inactivity-lock](../../changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/) _(archived 2026-09-29)_
+
+## Purpose
+The web vault locks itself after a period without user activity, and the user picks that period once in the user settings. Parity rows crypto-06 and crypto-07.
+
+## Requirements
+
+### Requirement: Inactivity lock
+
+The system MUST lock the web vault after the configured period without user activity. Pointer movement, key presses, scrolling and touch MUST reset the period. Activity in another browser tab MUST NOT keep a locked tab unlocked, and a lock MUST still clear the master key from memory.
+
+#### Scenario: An active user is not locked out
+
+@e2e exclude A 25 minute real-time flow; covered by vitest tests/store/session.timeout.spec.js 'keeps an active user unlocked past the timeout' with fake timers.
+
+- **GIVEN** a user with a 10 minute timeout who has been working for 25 minutes with clicks every minute
+- **WHEN** the user keeps working
+- **THEN** the vault stays unlocked
+
+#### Scenario: An idle user is locked
+
+@e2e exclude A ten minute real-time wait; covered by vitest tests/store/session.timeout.spec.js 'locks an idle user after the timeout and clears the key'.
+
+- **GIVEN** a user with a 10 minute timeout who stops interacting
+- **WHEN** ten minutes pass
+- **THEN** the lock screen is shown and the master key is cleared
+
+#### Scenario: Manual lock still works
+
+@e2e exclude The menu action calls sessionStore.lock(), covered with the lock transition by tests/components/appLockWiring.spec.js.
+
+- **GIVEN** an unlocked user
+- **WHEN** the user chooses Lock vault from the menu
+- **THEN** the lock screen is shown at once
+
+### Requirement: Saved session timeout
+
+The system MUST let a user choose a timeout in personal settings, MUST persist it through `PUT` on the session timeout preference, and MUST apply the saved value when the vault is unlocked in a new page load. When neither the user nor the administrator chose, the timeout MUST be ten minutes. The option Nextcloud session MUST mean no separate idle timer and MUST NOT be converted to another value.
+
+#### Scenario: The choice survives a reload
+
+@e2e exclude Needs a vault with a master password on the test instance to unlock after the reload; covered by vitest tests/store/session.timeout.spec.js 'loads the saved choice' and 'saves a choice through PUT', and PHPUnit SettingsServiceSessionTimeoutTest.
+
+- **GIVEN** a user who picked 30 minutes in personal settings
+- **WHEN** the user reloads the page and unlocks
+- **THEN** the vault uses a 30 minute timeout and the select shows 30 minutes
+
+#### Scenario: Nextcloud session means no idle timer
+
+@e2e exclude An hour of real-time idling; covered by vitest tests/store/session.timeout.spec.js 'never locks on an idle timer for the Nextcloud session choice'.
+
+- **GIVEN** a user who picked Nextcloud session
+- **WHEN** the user stays idle for an hour within the Nextcloud session
+- **THEN** the vault does not lock on an idle timer
From 5c922a4116d554e95b01a3d2775c8083e13a19b7 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 29 Sep 2026 22:29:07 +0200
Subject: [PATCH 016/245] test(import): red tests for the CSV column mapping
step and per-cell reveal
---
.../ImportWizardDialog.mapping.spec.js | 101 ++++++++++++++++++
tests/store/importMapping.spec.js | 86 +++++++++++++++
2 files changed, 187 insertions(+)
create mode 100644 tests/dialogs/ImportWizardDialog.mapping.spec.js
create mode 100644 tests/store/importMapping.spec.js
diff --git a/tests/dialogs/ImportWizardDialog.mapping.spec.js b/tests/dialogs/ImportWizardDialog.mapping.spec.js
new file mode 100644
index 000000000..e36fb2600
--- /dev/null
+++ b/tests/dialogs/ImportWizardDialog.mapping.spec.js
@@ -0,0 +1,101 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The import wizard's column mapping step and the per-cell reveal
+ * (portability-03).
+ *
+ * @spec openspec/specs/portability-import-mapping/spec.md#requirement-adjustable-csv-mapping
+ */
+
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it } from 'vitest'
+import ImportWizardDialog from '../../src/dialogs/ImportWizardDialog.vue'
+import { useImportStore } from '../../src/store/modules/import.js'
+import { useSessionStore } from '../../src/store/modules/session.js'
+
+const ncStubs = {
+ NcDialog: {
+ props: ['name', 'open', 'size'],
+ template: '