feat(frontend): add v3 compositions for pages, settings, tables and identity

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent 82df10f80b
commit 42c81c4687
71 changed files with 2066 additions and 1403 deletions
+25 -9
View File
@@ -3,7 +3,7 @@ import { emailVerificationTokens, users } from '../data';
import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -41,7 +41,9 @@ test('Failed account update remains dirty and can be retried', async ({ page })
await displayName.fill('Retryable Account');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Temporary account update failure', { exact: true })).toBeVisible();
await expect(
unsavedChangesBar(page).getByText('Temporary account update failure', { exact: true })
).toBeVisible();
await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeVisible();
expect(failedUpdates).toBe(1);
@@ -57,7 +59,9 @@ test('Update account details fails with already taken email', async ({ page }) =
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Email is already in use', { exact: true })).toBeVisible();
await expect(
unsavedChangesBar(page).getByText('Email is already in use', { exact: true })
).toBeVisible();
});
test('Update account details fails with already taken username', async ({ page }) => {
@@ -67,7 +71,9 @@ test('Update account details fails with already taken username', async ({ page }
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
await expect(
unsavedChangesBar(page).getByText('Username is already in use', { exact: true })
).toBeVisible();
});
test('Update account details fails with already taken username in different casing', async ({
@@ -79,7 +85,9 @@ test('Update account details fails with already taken username in different casi
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
await expect(
unsavedChangesBar(page).getByText('Username is already in use', { exact: true })
).toBeVisible();
});
test('Change Locale', async ({ page }) => {
@@ -117,24 +125,32 @@ test('Add passkey to an account', async ({ page }) => {
await page.getByLabel('Name', { exact: true }).fill('Test Passkey');
await page.getByLabel('Name Passkey').getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Test Passkey')).toBeVisible();
await expect(page.getByText('Test Passkey', { exact: true })).toBeVisible();
});
test('Rename passkey', async ({ page }) => {
await page.goto('/settings/account');
await page.getByLabel('Rename').first().click();
await page
.getByRole('button', { name: /^Actions for / })
.first()
.click();
await page.getByRole('menuitem', { name: 'Rename' }).click();
await page.getByLabel('Name', { exact: true }).fill('Renamed Passkey');
await page.getByLabel('Name Passkey').getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Renamed Passkey')).toBeVisible();
await expect(page.getByText('Renamed Passkey', { exact: true })).toBeVisible();
});
test('Delete passkey from account', async ({ page }) => {
await page.goto('/settings/account');
await page.getByLabel('Delete').first().click();
await page
.getByRole('button', { name: /^Actions for / })
.first()
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByLabel('Delete Passkey').getByRole('button', { name: 'Delete' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully');
+10 -5
View File
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { cleanupBackend } from '../utils/cleanup.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util';
test.beforeEach(async ({ page }) => {
await cleanupBackend();
@@ -116,13 +116,16 @@ test('Invalid hidden configuration prevents every section from being saved', asy
const emailTab = page.getByRole('tab', { name: 'Email' });
const smtpPort = page.getByLabel('SMTP Port');
await expect(page.getByText('Please fix the highlighted errors before saving')).toBeVisible();
await expect(
unsavedChangesBar(page).getByText('Couldn’t save. Check the highlighted fields.')
).toBeVisible();
await expect(emailTab).toHaveAttribute('aria-selected', 'true');
await expect(smtpPort).toHaveAttribute('aria-invalid', 'true');
await expect(smtpPort).toBeFocused();
expect(updateRequests).toBe(0);
await smtpPort.fill('587');
await expect(smtpPort).toHaveAttribute('aria-invalid', 'false');
await saveUnsavedChanges(page);
await page.reload();
@@ -300,7 +303,7 @@ test('Save LDAP configuration while LDAP remains disabled', async ({ page }) =>
const softDeleteUsers = page.getByRole('switch', { name: 'Keep disabled users from LDAP' });
const originalValue = await softDeleteUsers.isChecked();
await softDeleteUsers.click();
await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible();
await expect(unsavedChangesBar(page)).toContainText(/\d+ unsaved changes?/);
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(softDeleteUsers).toBeChecked({ checked: originalValue });
@@ -326,7 +329,7 @@ test.describe('Update application images', () => {
await page
.getByRole('button', { name: 'Reset to default Background Image', exact: true })
.click();
await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible();
await expect(unsavedChangesBar(page)).toContainText(/\d+ unsaved changes?/);
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(
@@ -415,6 +418,8 @@ test.describe('Update application images', () => {
await emailLogoInput.setInputFiles('resources/images/cloud-logo.svg');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('File must be of type PNG or JPEG', { exact: true })).toBeVisible();
await expect(
unsavedChangesBar(page).getByText('File must be of type PNG or JPEG', { exact: true })
).toBeVisible();
});
});
+5 -1
View File
@@ -14,7 +14,11 @@ test('approves the QR link after requester review and fresh reauthentication', a
const expectedLoginCodeText = `${waiting.request.userCode.substring(0, 4)} - ${waiting.request.userCode.substring(4, 8)}`;
try {
await (await passkeyUtil.init(page)).addPasskey();
await expect(waiting.page.getByTestId('device-login-code')).toHaveText(expectedLoginCodeText);
await expect(waiting.page.getByTestId('device-login-code')).toHaveText(
new RegExp(
`${waiting.request.userCode.substring(0, 4)}\\s*–\\s*${waiting.request.userCode.substring(4, 8)}`
)
);
await expect(waiting.page.getByLabel('Device login QR code')).toBeVisible();
await page.goto(waiting.request.verificationUriComplete);
+4 -3
View File
@@ -1,5 +1,6 @@
import test, { expect } from '@playwright/test';
import { cleanupBackend } from '../utils/cleanup.util';
import { unsavedChangesBar } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -27,7 +28,7 @@ test('unsaved changes block navigation until they are discarded', async ({ page
const displayName = page.getByLabel('Display Name');
const originalDisplayName = await displayName.inputValue();
await displayName.fill('Pending navigation');
await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible();
await expect(unsavedChangesBar(page)).toContainText(/\d+ unsaved changes?/);
const myApps = page.getByRole('link', { name: 'My Apps' }).first();
await myApps.click();
@@ -35,9 +36,9 @@ test('unsaved changes block navigation until they are discarded', async ({ page
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(displayName).toHaveValue(originalDisplayName);
await expect(page.getByText('You have unsaved changes', { exact: true })).toHaveCount(0);
await expect(unsavedChangesBar(page)).toHaveCount(0);
await myApps.click();
await expect(page).toHaveURL(/\/settings\/apps$/);
await expect(page.getByText('You have unsaved changes', { exact: true })).toHaveCount(0);
await expect(unsavedChangesBar(page)).toHaveCount(0);
});
+8 -6
View File
@@ -3,7 +3,7 @@ import * as jose from 'jose';
import { oidcClients, userGroups } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import * as oidcUtil from '../utils/oidc.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -556,7 +556,7 @@ test('Update OIDC client federated credentials with public keys', async ({ page
const privateKeyUpdate = waitForClientUpdate();
await saveButton.click();
expect((await privateKeyUpdate).status()).toBe(400);
await expect(page.getByText(/private key material/)).toBeVisible();
await expect(unsavedChangesBar(page).getByText(/private key material/)).toBeVisible();
await publicKeys.last().getByRole('button').click();
await expect(publicKeys).toHaveCount(3);
@@ -568,7 +568,7 @@ test('Update OIDC client federated credentials with public keys', async ({ page
const missingKeyIdUpdate = waitForClientUpdate();
await saveButton.click();
expect((await missingKeyIdUpdate).status()).toBe(400);
await expect(page.getByText(/missing the "kid" property/)).toBeVisible();
await expect(unsavedChangesBar(page).getByText(/missing the "kid" property/)).toBeVisible();
await publicKeys.last().getByRole('button').click();
await expect(publicKeys).toHaveCount(3);
@@ -703,7 +703,7 @@ test('Delete OIDC client', async ({ page }) => {
await page
.getByRole('row', { name: oidcClient.name })
.getByRole('button', { name: 'Toggle menu' })
.getByRole('button', { name: /^Actions for / })
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
@@ -760,9 +760,11 @@ test('Filter OIDC clients by PAR requirement', async ({ page, request }) => {
await expect(page.getByRole('row', { name: parClient.name })).toBeVisible();
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).not.toBeVisible();
// Deselect "Yes" and select "No" to invert the filter
await page.getByTestId('facet-par-option-true').click();
// Reset the filter to "Any", then select "No" to invert it
await page.getByTestId('facet-par-trigger').click();
await page.getByTestId('facet-par-option-any').click();
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).toBeVisible();
await page.getByTestId('facet-par-trigger').click();
await page.getByTestId('facet-par-option-false').click();
// PAR client should be hidden, others visible
+19 -19
View File
@@ -96,7 +96,7 @@ test('Authorize new client', async ({ page }) => {
const urlParams = createUrlParams(oidcClient);
await page.goto(`/authorize?${urlParams.toString()}`);
await expectScopes(page, ['Email', 'Profile']);
await expectScopes(page, ['Email address', 'Name, username and picture']);
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
@@ -111,7 +111,7 @@ test('Authorize client requesting offline_access scope', async ({ page }) => {
// offline_access is a valid OIDC scope: the flow must reach the consent screen rather than
// being rejected with invalid_scope (offline_access itself has no displayable scope item)
await expectScopes(page, ['Email', 'Profile']);
await expectScopes(page, ['Email address', 'Name, username and picture']);
const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
@@ -125,10 +125,10 @@ test('Authorize new client with a deselected scope', async ({ page }) => {
const urlParams = createUrlParams(oidcClient);
await page.goto(`/authorize?${urlParams.toString()}`);
await expectScopes(page, ['Email', 'Profile']);
await page.getByRole('checkbox', { name: 'Email' }).click();
await expect(page.getByRole('checkbox', { name: 'Email' })).not.toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Profile' })).toBeChecked();
await expectScopes(page, ['Email address', 'Name, username and picture']);
await page.getByRole('checkbox', { name: 'Email address' }).click();
await expect(page.getByRole('checkbox', { name: 'Email address' })).not.toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Name, username and picture' })).toBeChecked();
const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
@@ -199,7 +199,7 @@ test('Authorize new client while not signed in', async ({ page }) => {
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Sign in' }).click();
await expectScopes(page, ['Email', 'Profile']);
await expectScopes(page, ['Email address', 'Name, username and picture']);
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
@@ -216,7 +216,7 @@ test('Authorize new client shows Pocket ID error when user group not allowed', a
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Sign in' }).click();
await expectScopes(page, ['Email', 'Profile']);
await expectScopes(page, ['Email address', 'Name, username and picture']);
await page.getByRole('button', { name: 'Sign in' }).click();
@@ -701,7 +701,7 @@ test('Authorize new client with device authorization flow', async ({ page }) =>
await page.goto(`/device?user_code=${userCode}`);
await page.getByRole('button', { name: 'Authorize' }).click();
await expectScopes(page, ['Email', 'Profile']);
await expectScopes(page, ['Email address', 'Name, username and picture']);
await page.getByRole('button', { name: 'Authorize' }).click();
@@ -724,10 +724,10 @@ test('Authorize new client with device authorization flow and a deselected scope
await page.goto(`/device?user_code=${device.user_code}`);
await page.getByRole('button', { name: 'Authorize' }).click();
await expectScopes(page, ['Email', 'Profile']);
await page.getByRole('checkbox', { name: 'Email' }).click();
await expect(page.getByRole('checkbox', { name: 'Email' })).not.toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Profile' })).toBeChecked();
await expectScopes(page, ['Email address', 'Name, username and picture']);
await page.getByRole('checkbox', { name: 'Email address' }).click();
await expect(page.getByRole('checkbox', { name: 'Email address' })).not.toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Name, username and picture' })).toBeChecked();
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(
@@ -763,7 +763,7 @@ test('Authorize new client with device authorization flow while not signed in',
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authorize' }).click();
await expectScopes(page, ['Email', 'Profile']);
await expectScopes(page, ['Email address', 'Name, username and picture']);
await page.getByRole('button', { name: 'Authorize' }).click();
@@ -834,7 +834,7 @@ for (const scenario of [
} else {
await page.getByRole('button', { name: 'Authorize', exact: true }).click();
if (scenario.consentRequired) {
await expectScopes(page, ['Email', 'Profile']);
await expectScopes(page, ['Email address', 'Name, username and picture']);
await page.getByRole('button', { name: 'Authorize', exact: true }).click();
}
await expect(
@@ -962,7 +962,7 @@ test('Authorize new client with device authorization with user group not allowed
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Authorize' }).click();
await expectScopes(page, ['Email', 'Profile']);
await expectScopes(page, ['Email address', 'Name, username and picture']);
await page.getByRole('button', { name: 'Authorize' }).click();
@@ -1241,7 +1241,7 @@ test.describe('OIDC prompt parameter', () => {
await page.goto(`/authorize?${urlParams.toString()}`);
// Should show consent UI even though client was already authorized
await expectScopes(page, ['Profile', 'Email']);
await expectScopes(page, ['Name, username and picture', 'Email address']);
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
@@ -1532,7 +1532,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
await page.goto(`/authorize?${urlParams.toString()}`);
// Consent screen with the requested scope (resolved from the PAR) must be shown
await expectScopes(page, ['Groups']);
await expectScopes(page, ['Group membership']);
// Confirming proceeds with the authorization
await expectCallbackRedirect(page, client.callbackUrl, () =>
@@ -1685,7 +1685,7 @@ test.describe('OIDC skip consent', () => {
await page.goto(`/authorize?${urlParams.toString()}`);
// prompt=consent overrides the client's skip-consent setting, so the scopes must still be shown
await expectScopes(page, ['Email', 'Profile']);
await expectScopes(page, ['Email address', 'Name, username and picture']);
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
+5 -1
View File
@@ -23,7 +23,11 @@ test('Sign in with login code without a passkey shows passkey setup', async ({ p
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authenticate' }).click();
await page.waitForURL('/settings/account');
await page.getByLabel('Delete').first().click();
await page
.getByRole('button', { name: /^Actions for / })
.first()
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByLabel('Delete Passkey').getByRole('button', { name: 'Delete' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully');
+3 -3
View File
@@ -1,7 +1,7 @@
import test, { expect, type Page } from '@playwright/test';
import { cleanupBackend, cleanupScimServiceProvider } from 'utils/cleanup.util';
import { oidcClients, userGroups, users } from '../data';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util';
async function configureOidcClient(page: Page) {
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
@@ -36,14 +36,14 @@ test.describe('SCIM Configuration', () => {
await page.getByLabel('SCIM Endpoint').fill('http://scim.provider/api');
await page.getByLabel('SCIM Token').fill('supersecrettoken');
await expect(page.getByText('You have unsaved changes', { exact: true })).toHaveCount(0);
await expect(unsavedChangesBar(page)).toHaveCount(0);
await page.getByRole('button', { name: 'Enable' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText('SCIM enabled successfully.');
await page.getByLabel('SCIM Endpoint').fill('http://edited.scim.provider/api');
await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible();
await expect(unsavedChangesBar(page)).toContainText(/\d+ unsaved changes?/);
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://scim.provider/api');
+13 -11
View File
@@ -2,7 +2,7 @@ import test, { expect } from '@playwright/test';
import { userGroups, users } from '../data';
import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -177,7 +177,9 @@ test('Update user fails with already taken email', async ({ page }) => {
await page.getByLabel('Email').fill(users.tim.email);
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Email is already in use', { exact: true })).toBeVisible();
await expect(
unsavedChangesBar(page).getByText('Email is already in use', { exact: true })
).toBeVisible();
});
test('Update user fails with already taken username', async ({ page }) => {
@@ -194,7 +196,9 @@ test('Update user fails with already taken username', async ({ page }) => {
await page.getByLabel('Username').fill(users.tim.username);
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
await expect(
unsavedChangesBar(page).getByText('Username is already in use', { exact: true })
).toBeVisible();
});
test('Update user fails with already taken username in different casing', async ({ page }) => {
@@ -211,7 +215,9 @@ test('Update user fails with already taken username in different casing', async
await page.getByLabel('Username').fill(users.tim.username.toUpperCase());
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
await expect(
unsavedChangesBar(page).getByText('Username is already in use', { exact: true })
).toBeVisible();
});
test('Update user custom claims', async ({ page }) => {
@@ -275,7 +281,7 @@ test('Admin can view another user passkeys', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.getByRole('tab', { name: 'Passkeys' }).click();
await expect(page.getByText('Passkey 2')).toBeVisible();
await expect(page.getByText('Passkey 2', { exact: true })).toBeVisible();
await expect(page.getByText(/Added on/)).toBeVisible();
});
@@ -283,15 +289,11 @@ test('Admin can delete another user passkey and audit log is created', async ({
await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.getByRole('tab', { name: 'Passkeys' }).click();
await page
.locator('[data-slot="item"]')
.filter({ hasText: 'Passkey 2' })
.getByLabel('Delete')
.click();
await page.getByRole('button', { name: 'Delete Passkey 2' }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully');
await expect(page.getByText('Passkey 2')).not.toBeVisible();
await expect(page.getByText('Passkey 2', { exact: true })).not.toBeVisible();
const auditLogResponse = await page.request.get('/api/audit-logs/all');
expect(auditLogResponse.ok()).toBeTruthy();
+2 -2
View File
@@ -52,9 +52,9 @@ test.describe('Signup Token Creation', () => {
await page.getByRole('button', { name: 'Create options' }).click();
await page.getByRole('menuitem', { name: 'View Active Signup Tokens' }).click();
await page.getByLabel('Manage Signup Tokens').getByRole('button', { name: 'View' }).click();
await page.getByLabel('Manage Signup Tokens').getByRole('button', { name: 'Display' }).click();
await page.getByRole('menuitemcheckbox', { name: 'User Groups' }).click();
await page.getByRole('button', { name: 'User Groups', exact: true }).click();
const row = page.getByRole('row').last();
await expect(row.getByRole('cell', { name: '0 of 8' })).toBeVisible();
+5 -1
View File
@@ -1,6 +1,10 @@
import { expect, type Page } from '@playwright/test';
export function unsavedChangesBar(page: Page) {
return page.getByRole('region', { name: 'Unsaved changes' });
}
export async function saveUnsavedChanges(page: Page) {
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Changes saved successfully', { exact: true })).toBeVisible();
await expect(unsavedChangesBar(page).getByText('Changes saved', { exact: true })).toBeVisible();
}