diff --git a/apps/encryption/src/components/SettingsPersonalChangePrivateKey.vue b/apps/encryption/src/components/SettingsPersonalChangePrivateKey.vue
index 6631b17d12ffc..5d06dac5fb442 100644
--- a/apps/encryption/src/components/SettingsPersonalChangePrivateKey.vue
+++ b/apps/encryption/src/components/SettingsPersonalChangePrivateKey.vue
@@ -48,8 +48,8 @@ async function onSubmit() {
formElement.value?.reset()
emit('updated')
} catch (error) {
- if (isAxiosError(error) && error.response && error.response.data?.data?.message) {
- showError(error.response.data.data.message)
+ if (isAxiosError(error) && error.response?.data?.message) {
+ showError(error.response.data.message)
}
hasError.value = true
} finally {
@@ -67,13 +67,17 @@ async function onSubmit() {
{{ t('encryption', 'If you do not remember your old password you can ask your administrator to recover your files.') }}
-
-
+
+
- {{ t('encryption', 'Update') }}
+ {{ t('encryption', 'Update') /* TRANSLATORS: Button label, when clicked the password will be updated */ }}
diff --git a/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts b/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts
new file mode 100644
index 0000000000000..97eaa51dda792
--- /dev/null
+++ b/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts
@@ -0,0 +1,143 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { expect, test } from '../../support/fixtures/encryption-personal-settings-page.ts'
+import {
+ desyncPrivateKeyPassword,
+ disableEncryption,
+ enablePerAccountKeyEncryption,
+ enableRecoveryKey,
+ setPasswordRecoveryForUser,
+} from '../../support/utils/encryption.ts'
+import { getToast } from '../../support/utils/toasts.ts'
+import { loginAs } from '../../support/utils/users.ts'
+
+const RECOVERY_KEY_PASSWORD = 'recovery-key-password'
+
+/**
+ * The personal settings of the default encryption module.
+ *
+ * Server-side encryption is instance-wide state, so the file is named
+ * `admin-settings-*`: that is the pattern `playwright.config.ts` runs in its own
+ * serial project.
+ */
+test.describe('encryption: personal settings', () => {
+ // The accounts of all tests are created while encryption is enabled, so that
+ // they have encryption keys of their own.
+ test.beforeAll(enablePerAccountKeyEncryption)
+ test.afterAll(disableEncryption)
+
+ test.describe('outdated private key password', () => {
+ /** The password the private key of the current account is encrypted with. */
+ let privateKeyPassword: string
+
+ test.beforeEach(async ({ page, user, encryptionSettings }) => {
+ privateKeyPassword = await desyncPrivateKeyPassword(user)
+ await loginAs(page, user)
+ await encryptionSettings.open()
+ })
+
+ test('offers to update the private key password', async ({ encryptionSettings }) => {
+ await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toBeVisible()
+ await expect(encryptionSettings.oldLoginPasswordField()).toBeVisible()
+ await expect(encryptionSettings.currentLoginPasswordField()).toBeVisible()
+ await expect(encryptionSettings.updateButton()).toBeVisible()
+ // Without password recovery there is no point in mentioning the administrator
+ await expect(encryptionSettings.recoveryHint()).toHaveCount(0)
+ })
+
+ test('points to the administrator if password recovery is enabled', async ({ user, encryptionSettings }) => {
+ await setPasswordRecoveryForUser(user.userId, true)
+ await encryptionSettings.open()
+
+ await expect(encryptionSettings.recoveryHint())
+ .toHaveText(/ask your administrator to recover your files/)
+ })
+
+ test('rejects a wrong current log-in password', async ({ page, encryptionSettings }) => {
+ const response = await encryptionSettings.updatePrivateKeyPassword(privateKeyPassword, 'not-the-log-in-password')
+
+ // Complaining about the current password means the old one was submitted, too
+ expect(response.status()).toBe(400)
+ await expect(getToast(page, 'The current log-in password was not correct, please try again.')).toBeVisible()
+
+ // The form keeps its values so that only the wrong password has to be fixed
+ await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toBeVisible()
+ await expect(encryptionSettings.oldLoginPasswordField()).toHaveValue(privateKeyPassword)
+ await expect(encryptionSettings.currentLoginPasswordField()).toHaveValue('not-the-log-in-password')
+ })
+
+ test('rejects a wrong old log-in password', async ({ page, user, encryptionSettings }) => {
+ const response = await encryptionSettings.updatePrivateKeyPassword('not-the-old-password', user.password)
+
+ // Complaining about the old password means the current one was accepted
+ expect(response.status()).toBe(400)
+ await expect(getToast(page, 'The old password was not correct, please try again.')).toBeVisible()
+ await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toBeVisible()
+ })
+
+ test('updates the private key password', async ({ page, user, encryptionSettings }) => {
+ const response = await encryptionSettings.updatePrivateKeyPassword(privateKeyPassword, user.password)
+
+ expect(response.status()).toBe(200)
+ // The settings reload the status, which is now successful
+ await expect(getToast(page, 'Encryption app is enabled and ready')).toBeVisible()
+ await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toHaveCount(0)
+
+ // The private key is really encrypted with the log-in password now: a new
+ // session can unlock it, so there is nothing left to configure
+ await page.context().clearCookies()
+ await loginAs(page, user)
+ await encryptionSettings.open()
+ await expect(encryptionSettings.section()).toHaveCount(0)
+ })
+ })
+
+ test.describe('uninitialized keys', () => {
+ test.beforeEach(async ({ page, user, encryptionSettings }) => {
+ // Log in while encryption is switched off: the account keys are never
+ // unlocked for that session, the state accounts are in when encryption is
+ // enabled while they are logged in.
+ await runOcc(['encryption:disable'])
+ await loginAs(page, user)
+ await runOcc(['encryption:enable'])
+
+ await encryptionSettings.open()
+ })
+
+ test('asks to log in again', async ({ encryptionSettings }) => {
+ await expect(encryptionSettings.keysNotInitializedWarning())
+ .toHaveText(/please log-out and log-in again/)
+ await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toHaveCount(0)
+ })
+ })
+
+ test.describe('password recovery', () => {
+ test.beforeEach(async ({ page, user, adminRequest, encryptionSettings }) => {
+ await loginAs(page, user)
+ await enableRecoveryKey(adminRequest, RECOVERY_KEY_PASSWORD)
+ await encryptionSettings.open()
+ })
+
+ // Leave the instance without a recovery key, otherwise the encryption section
+ // keeps being rendered for accounts that have nothing else to configure
+ test.afterAll(async () => {
+ await runOcc(['config:app:delete', 'encryption', 'recoveryAdminEnabled'])
+ })
+
+ test('can be enabled and disabled', async ({ encryptionSettings }) => {
+ await expect(encryptionSettings.passwordRecoverySwitch()).not.toBeChecked()
+
+ await encryptionSettings.setPasswordRecovery(true)
+ await encryptionSettings.open()
+ await expect(encryptionSettings.passwordRecoverySwitch()).toBeChecked()
+
+ await encryptionSettings.setPasswordRecovery(false)
+ await encryptionSettings.open()
+ await expect(encryptionSettings.passwordRecoverySwitch()).not.toBeChecked()
+ })
+ })
+})
diff --git a/tests/playwright/support/fixtures/admin-request.ts b/tests/playwright/support/fixtures/admin-request.ts
new file mode 100644
index 0000000000000..cc38125dfee21
--- /dev/null
+++ b/tests/playwright/support/fixtures/admin-request.ts
@@ -0,0 +1,30 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { APIRequestContext } from '@playwright/test'
+
+import { test as baseTest } from '@playwright/test'
+
+type AdminRequestFixtures = {
+ /**
+ * A request context authenticated as the administrator via basic auth, with no
+ * browser session cookies — needed because cookies of the page under test would
+ * otherwise win over basic auth and the request would run as that account.
+ */
+ adminRequest: APIRequestContext
+}
+
+export const test = baseTest.extend({
+ adminRequest: async ({ playwright, baseURL }, use) => {
+ const context = await playwright.request.newContext({
+ baseURL,
+ // send: 'always' — Nextcloud does not issue a Basic auth challenge for
+ // app routes, so the credentials must be sent preemptively
+ httpCredentials: { username: 'admin', password: 'admin', send: 'always' },
+ })
+ await use(context)
+ await context.dispose()
+ },
+})
diff --git a/tests/playwright/support/fixtures/encryption-personal-settings-page.ts b/tests/playwright/support/fixtures/encryption-personal-settings-page.ts
new file mode 100644
index 0000000000000..e7fb909022315
--- /dev/null
+++ b/tests/playwright/support/fixtures/encryption-personal-settings-page.ts
@@ -0,0 +1,29 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { mergeTests } from '@playwright/test'
+import { EncryptionPersonalSettingsPage } from '../sections/EncryptionPersonalSettingsPage.ts'
+import { test as adminRequestTest } from './admin-request.ts'
+import { test as randomUserTest } from './random-user.ts'
+
+type EncryptionFixtures = {
+ encryptionSettings: EncryptionPersonalSettingsPage
+}
+
+/**
+ * A random `user`, an `adminRequest` context and the personal encryption settings
+ * page object.
+ *
+ * The page is deliberately *not* logged in: the encryption app sets up and unlocks
+ * the account keys during log-in, so every test has to control when the session is
+ * created. Use `loginAs()` from `utils/users.ts` once the instance is prepared.
+ */
+export const test = mergeTests(randomUserTest, adminRequestTest).extend({
+ encryptionSettings: async ({ page }, use) => {
+ await use(new EncryptionPersonalSettingsPage(page))
+ },
+})
+
+export { expect } from '../matchers.ts'
diff --git a/tests/playwright/support/sections/EncryptionPersonalSettingsPage.ts b/tests/playwright/support/sections/EncryptionPersonalSettingsPage.ts
new file mode 100644
index 0000000000000..ad8e1c119a575
--- /dev/null
+++ b/tests/playwright/support/sections/EncryptionPersonalSettingsPage.ts
@@ -0,0 +1,97 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Locator, Page, Response } from '@playwright/test'
+
+/**
+ * The "Basic encryption module" section of the personal security settings,
+ * provided by the encryption app.
+ */
+export class EncryptionPersonalSettingsPage {
+ constructor(private readonly page: Page) {}
+
+ /**
+ * The section container. It is only rendered when the encryption app has
+ * something for the account to do, so `toHaveCount(0)` asserts "nothing to do".
+ */
+ section(): Locator {
+ // The container has no accessible name, so the app's mount point is used
+ return this.page.locator('#encryption-settings-section')
+ }
+
+ /** Warning shown when the account keys were never unlocked for this session. */
+ keysNotInitializedWarning(): Locator {
+ return this.section().getByRole('note').filter({ hasText: 'keys are not initialized' })
+ }
+
+ /** The "Update private key password" form, rendered as a labelled group. */
+ updatePrivateKeyPasswordForm(): Locator {
+ return this.page.getByRole('group', { name: 'Update private key password' })
+ }
+
+ oldLoginPasswordField(): Locator {
+ return this.updatePrivateKeyPasswordForm().getByLabel('Old log-in password')
+ }
+
+ currentLoginPasswordField(): Locator {
+ return this.updatePrivateKeyPasswordForm().getByLabel('Current log-in password')
+ }
+
+ updateButton(): Locator {
+ return this.updatePrivateKeyPasswordForm().getByRole('button', { name: 'Update' })
+ }
+
+ /** Hint to ask an administrator, only shown if password recovery is enabled. */
+ recoveryHint(): Locator {
+ return this.updatePrivateKeyPasswordForm().getByRole('note')
+ }
+
+ passwordRecoverySwitch(): Locator {
+ return this.section().getByRole('checkbox', { name: 'Enable password recovery' })
+ }
+
+ /**
+ * Open the personal security settings.
+ *
+ * Waits for a section of the page that is always there, so that assertions on
+ * the absence of the encryption section cannot pass before the page rendered.
+ */
+ async open(): Promise {
+ await this.page.goto('settings/user/security')
+ await this.page.getByRole('heading', { name: 'Devices & sessions' }).waitFor()
+ }
+
+ /**
+ * Fill in and submit the "Update private key password" form.
+ *
+ * @param oldPassword - Value for "Old log-in password"
+ * @param currentPassword - Value for "Current log-in password"
+ * @return The response of the update request
+ */
+ async updatePrivateKeyPassword(oldPassword: string, currentPassword: string): Promise {
+ await this.oldLoginPasswordField().fill(oldPassword)
+ await this.currentLoginPasswordField().fill(currentPassword)
+
+ // Registered before the click, otherwise the response can be missed
+ const updated = this.page.waitForResponse((response) => response.url().includes('/apps/encryption/ajax/updatePrivateKeyPassword'))
+ await this.updateButton().click()
+ return await updated
+ }
+
+ /**
+ * Toggle password recovery and wait for the change to be stored.
+ *
+ * The switch is debounced, so the request starts about a second after the
+ * click; the real input is visually hidden and needs a forced action.
+ *
+ * @param enabled - Whether password recovery should be enabled
+ */
+ async setPasswordRecovery(enabled: boolean): Promise {
+ const saved = this.page.waitForResponse((response) => response.url().includes('/apps/encryption/ajax/userSetRecovery')
+ && response.request().method() === 'POST')
+ await this.passwordRecoverySwitch().setChecked(enabled, { force: true })
+ await saved
+ }
+}
diff --git a/tests/playwright/support/utils/encryption.ts b/tests/playwright/support/utils/encryption.ts
new file mode 100644
index 0000000000000..9fb6cef798ac6
--- /dev/null
+++ b/tests/playwright/support/utils/encryption.ts
@@ -0,0 +1,99 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/e2e-test-server'
+import type { APIRequestContext } from '@playwright/test'
+
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { expect } from '@playwright/test'
+
+/** Log-in password set by {@link desyncPrivateKeyPassword}. */
+export const UPDATED_LOGIN_PASSWORD = 'updated-log-in-password'
+
+/**
+ * Turn on server-side encryption with per-account keys.
+ *
+ * Only without the master key does an account have a private key password of its
+ * own, which is what the personal encryption settings manage. Home storages are
+ * left unencrypted: the settings do not depend on it, and no test data has to be
+ * decrypted again when encryption is switched off in {@link disableEncryption}.
+ */
+export async function enablePerAccountKeyEncryption(): Promise {
+ await runOcc(['app:enable', 'encryption'])
+ await runOcc(['config:app:set', 'encryption', 'useMasterKey', '--value', '0', '--type', 'boolean'])
+ await runOcc(['config:app:set', 'encryption', 'encryptHomeStorage', '--value', '0', '--type', 'boolean'])
+ await runOcc(['encryption:enable'])
+}
+
+/**
+ * Switch server-side encryption off again and restore the default configuration.
+ */
+export async function disableEncryption(): Promise {
+ await runOcc(['encryption:disable'])
+ await runOcc(['config:app:delete', 'encryption', 'recoveryAdminEnabled'])
+ await runOcc(['config:app:delete', 'encryption', 'encryptHomeStorage'])
+ await runOcc(['config:app:delete', 'encryption', 'useMasterKey'])
+ await runOcc(['app:disable', 'encryption'])
+}
+
+/**
+ * Make an account's private key password differ from its log-in password — the
+ * state the "Update private key password" form exists for. This happens in real
+ * life when the password is changed in an external user backend.
+ *
+ * The account keys must already exist, meaning the account must have been created
+ * while encryption was enabled. The encryption app only re-encrypts the private
+ * key while encryption is enabled, so the log-in password is changed with
+ * encryption switched off and `user.password` is updated to the new one.
+ *
+ * The session must be created *after* this call, otherwise the encryption app
+ * already unlocked the private key for it.
+ *
+ * @param user - Account to desync — its `password` is updated in place
+ * @return The password the private key is still encrypted with
+ */
+export async function desyncPrivateKeyPassword(user: User): Promise {
+ const privateKeyPassword = user.password
+
+ await runOcc(['encryption:disable'])
+ await runOcc(['user:resetpassword', user.userId, '--password-from-env'], {
+ env: [`OC_PASS=${UPDATED_LOGIN_PASSWORD}`],
+ })
+ await runOcc(['encryption:enable'])
+
+ user.password = UPDATED_LOGIN_PASSWORD
+ return privateKeyPassword
+}
+
+/**
+ * Enable the recovery key, which is what makes the "Enable password recovery"
+ * setting available to accounts. The key is created on the first call, later
+ * calls have to pass the same password again.
+ *
+ * @param request - Request context authenticated as an administrator
+ * @param recoveryPassword - Password protecting the recovery key
+ */
+export async function enableRecoveryKey(request: APIRequestContext, recoveryPassword: string): Promise {
+ const response = await request.post('./apps/encryption/ajax/adminRecovery', {
+ // Marks the request as an API request, so no CSRF token is required
+ headers: { 'OCS-APIRequest': 'true' },
+ data: {
+ recoveryPassword,
+ confirmPassword: recoveryPassword,
+ adminEnableRecovery: true,
+ },
+ })
+ expect(response.status(), await response.text()).toBe(200)
+}
+
+/**
+ * Set the account's own "Enable password recovery" preference.
+ *
+ * @param userId - The account to change the preference for
+ * @param enabled - Whether password recovery should be enabled
+ */
+export async function setPasswordRecoveryForUser(userId: string, enabled: boolean): Promise {
+ await runOcc(['user:setting', userId, 'encryption', 'recoveryEnabled', enabled ? '1' : '0'])
+}
diff --git a/tests/playwright/support/utils/toasts.ts b/tests/playwright/support/utils/toasts.ts
new file mode 100644
index 0000000000000..38632b490b045
--- /dev/null
+++ b/tests/playwright/support/utils/toasts.ts
@@ -0,0 +1,21 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Locator, Page } from '@playwright/test'
+
+/**
+ * A toast message shown by `@nextcloud/dialogs`.
+ *
+ * Toasts are appended to the document body, so they cannot be scoped to a page
+ * object's container, and they carry no accessible name — the toastify class is
+ * the only stable handle.
+ *
+ * @param page - The page showing the toast
+ * @param message - Text the toast must contain
+ * @return Locator matching all toasts containing `message`
+ */
+export function getToast(page: Page, message: string | RegExp): Locator {
+ return page.locator('.toastify').filter({ hasText: message })
+}
diff --git a/tests/playwright/support/utils/users.ts b/tests/playwright/support/utils/users.ts
index 5da4707fa9b7f..07a57d29d901f 100644
--- a/tests/playwright/support/utils/users.ts
+++ b/tests/playwright/support/utils/users.ts
@@ -3,7 +3,30 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { APIRequestContext } from '@playwright/test'
+import type { User } from '@nextcloud/e2e-test-server'
+import type { APIRequestContext, Page } from '@playwright/test'
+
+import { login } from '@nextcloud/e2e-test-server/playwright'
+
+/**
+ * Log the browser context of `page` in as `user`.
+ *
+ * Unlike the `random-user-session` fixture this is called from the test itself,
+ * for cases where instance state has to be prepared *before* the session is
+ * created — the encryption app for example unlocks the account keys during log-in.
+ *
+ * @param page - Page whose browser context should hold the session
+ * @param user - The account to log in as
+ */
+export async function loginAs(page: Page, user: User): Promise {
+ try {
+ await login(page.request, user)
+ } catch (error) {
+ console.info('Failed to authenticate, retrying', error)
+ await new Promise((resolve) => setTimeout(resolve, 800))
+ await login(page.request, user)
+ }
+}
/**
* Grant subadmin rights to `subadminId` over `groupId` via the OCS Provisioning API.