feat(ux): replace individual save buttons with single "unsaved changes bar" (#1757)

This commit is contained in:
Elias Schneider
2026-09-19 15:23:06 +00:00
committed by GitHub
parent 818333fb38
commit 63050add4e
58 changed files with 1916 additions and 1184 deletions
+38 -10
View File
@@ -3,6 +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';
test.beforeEach(async () => await cleanupBackend());
@@ -15,11 +16,38 @@ test('Update account details', async ({ page }) => {
await page.getByLabel('Display Name').fill('Timothy Apple');
await page.getByLabel('Email').fill('timothy.apple@test.com');
await page.getByLabel('Username').fill('timothy');
await page.getByRole('button', { name: 'Save' }).click();
await saveUnsavedChanges(page);
});
await expect(page.locator('[data-type="success"]')).toHaveText(
'Account details updated successfully'
);
test('Failed account update remains dirty and can be retried', async ({ page }) => {
await page.goto('/settings/account');
let failedUpdates = 0;
await page.route('**/api/users/me', async (route) => {
if (route.request().method() !== 'PUT' || failedUpdates > 0) {
await route.fallback();
return;
}
failedUpdates++;
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Temporary account update failure' })
});
});
const displayName = page.getByLabel('Display Name');
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(page.getByRole('button', { name: 'Save', exact: true })).toBeVisible();
expect(failedUpdates).toBe(1);
await saveUnsavedChanges(page);
await page.reload();
await expect(page.getByLabel('Display Name')).toHaveValue('Retryable Account');
});
test('Update account details fails with already taken email', async ({ page }) => {
@@ -27,9 +55,9 @@ test('Update account details fails with already taken email', async ({ page }) =
await page.getByLabel('Email').fill(users.craig.email);
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Email is already in use');
await expect(page.getByText('Email is already in use', { exact: true })).toBeVisible();
});
test('Update account details fails with already taken username', async ({ page }) => {
@@ -37,9 +65,9 @@ test('Update account details fails with already taken username', async ({ page }
await page.getByLabel('Username').fill(users.craig.username);
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
});
test('Update account details fails with already taken username in different casing', async ({
@@ -49,9 +77,9 @@ test('Update account details fails with already taken username in different casi
await page.getByLabel('Username').fill(users.craig.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
});
test('Change Locale', async ({ page }) => {
+4 -11
View File
@@ -3,6 +3,7 @@ import * as jose from 'jose';
import { apis, oidcClients } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import * as oidcUtil from '../utils/oidc.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -69,9 +70,7 @@ test('Edit the name of an API', async ({ page }) => {
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
await page.getByLabel('Name', { exact: true }).fill('Orders API renamed');
await page.getByRole('button', { name: 'Save' }).nth(0).click();
await expect(page.locator('[data-type="success"]')).toHaveText('API updated successfully');
await saveUnsavedChanges(page);
await page.reload();
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Orders API renamed');
@@ -84,11 +83,7 @@ test('Add a permission to an API', async ({ page }) => {
await page.getByRole('button', { name: 'Add another' }).click();
await page.getByPlaceholder('Permission', { exact: true }).last().fill('ship:orders');
await page.getByPlaceholder('Name', { exact: true }).last().fill('Ship orders');
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Permissions updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
// The two seeded permissions plus the newly added one
@@ -259,9 +254,7 @@ test('Allow all metadata document clients for an API', async ({ page }) => {
await page.getByRole('tab', { name: 'Metadata document clients' }).click();
await page.getByLabel('Allow all metadata document clients').click();
await page.getByLabel(apis.orders.permissions.readOrders.name, { exact: true }).click();
await page.getByRole('button', { name: 'Save' }).nth(2).click();
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
await saveUnsavedChanges(page);
await page.reload();
await page.getByRole('tab', { name: 'Metadata document clients' }).click();
+145 -43
View File
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { cleanupBackend } from '../utils/cleanup.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async ({ page }) => {
await cleanupBackend();
@@ -13,11 +14,7 @@ test('Update general configuration', async ({ page }) => {
await page.getByRole('button', { name: 'Home Page' }).click();
await page.getByRole('option', { name: 'My Apps' }).click();
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Application configuration updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -28,6 +25,95 @@ test('Update general configuration', async ({ page }) => {
await page.waitForURL('/settings/apps');
});
test('Save configuration from every editable tab together', async ({ page }) => {
await page.getByLabel('Application Name', { exact: true }).fill('Combined Settings');
await page.getByRole('tab', { name: 'User Creation' }).click();
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await page.getByRole('option', { name: 'Open Signup' }).click();
await page.getByRole('tab', { name: 'Passkeys' }).click();
await page.getByRole('button', { name: 'User verification' }).click();
await page.getByRole('option', { name: 'Preferred' }).click();
await page.getByRole('tab', { name: 'Email' }).click();
await page.getByLabel('SMTP Host').fill('smtp.combined.test');
await page.getByLabel('SMTP Port').fill('587');
await page.getByLabel('SMTP From').fill('combined@example.com');
await page.getByRole('tab', { name: 'OIDC' }).click();
await page.getByRole('textbox').fill('https://combined.example.com/*');
await saveUnsavedChanges(page);
await page.getByRole('tab', { name: 'General' }).click();
await expect(page.getByLabel('Application Name', { exact: true })).toHaveValue(
'Combined Settings'
);
await page.getByRole('tab', { name: 'Email' }).click();
await expect(page.getByLabel('SMTP Host')).toHaveValue('smtp.combined.test');
await page.getByRole('tab', { name: 'Passkeys' }).click();
await expect(page.getByRole('button', { name: 'User verification' })).toContainText('Preferred');
await page.getByRole('tab', { name: 'OIDC' }).click();
await expect(page.getByRole('textbox')).toHaveValue('https://combined.example.com/*');
await page.reload();
await page.getByRole('tab', { name: 'General' }).click();
await expect(page.getByLabel('Application Name', { exact: true })).toHaveValue(
'Combined Settings'
);
await page.getByRole('tab', { name: 'User Creation' }).click();
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await expect(page.getByRole('option', { name: 'Open Signup' })).toHaveAttribute(
'aria-selected',
'true'
);
await page.keyboard.press('Escape');
await page.getByRole('tab', { name: 'Email' }).click();
await expect(page.getByLabel('SMTP Host')).toHaveValue('smtp.combined.test');
await page.getByRole('tab', { name: 'Passkeys' }).click();
await expect(page.getByRole('button', { name: 'User verification' })).toContainText('Preferred');
await page.getByRole('tab', { name: 'OIDC' }).click();
await expect(page.getByRole('textbox')).toHaveValue('https://combined.example.com/*');
});
test('Invalid hidden configuration prevents every section from being saved', async ({ page }) => {
let updateRequests = 0;
page.on('request', (request) => {
if (
request.method() === 'PUT' &&
new URL(request.url()).pathname === '/api/application-configuration'
) {
updateRequests++;
}
});
await page.getByLabel('Application Name', { exact: true }).fill('Validated Together');
await page.getByRole('tab', { name: 'Email' }).click();
await page.getByLabel('SMTP Host').fill('smtp.validation.test');
await page.getByLabel('SMTP From').fill('validation@example.com');
await page.getByRole('tab', { name: 'General' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
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(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 saveUnsavedChanges(page);
await page.reload();
await page.getByRole('tab', { name: 'General' }).click();
await expect(page.getByLabel('Application Name', { exact: true })).toHaveValue(
'Validated Together'
);
await page.getByRole('tab', { name: 'Email' }).click();
await expect(page.getByLabel('SMTP Host')).toHaveValue('smtp.validation.test');
await expect(page.getByLabel('SMTP Port')).toHaveValue('587');
await expect(page.getByLabel('SMTP From')).toHaveValue('validation@example.com');
});
test.describe('Update user creation configuration', () => {
test.beforeEach(async ({ page }) => {
await page.getByRole('tab', { name: 'User Creation' }).click();
@@ -37,11 +123,7 @@ test.describe('Update user creation configuration', () => {
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await page.getByRole('option', { name: 'Open Signup' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -59,11 +141,7 @@ test.describe('Update user creation configuration', () => {
await expect(designersOption).toBeChecked();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -81,11 +159,7 @@ test.describe('Update user creation configuration', () => {
await page.getByPlaceholder('Key').nth(1).fill('another-claim');
await page.getByPlaceholder('Value').nth(1).fill('another-value');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -114,10 +188,7 @@ test('Update passkey configuration', async ({ page }) => {
await expect(allowSyncedPasskeys).toBeChecked();
await allowSyncedPasskeys.click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Passkey configuration updated successfully'
);
await saveUnsavedChanges(page);
const registrationResponse = await page.request.get('/api/webauthn/register/start');
expect(registrationResponse.ok()).toBeTruthy();
@@ -157,11 +228,7 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('Email Login Code from Admin').click();
await page.getByLabel('API Key Expiration').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Email configuration updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -176,7 +243,52 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
});
test('Save LDAP configuration while LDAP remains disabled', async ({ page }) => {
await page.getByRole('tab', { name: 'LDAP' }).click();
const disableButton = page.getByRole('button', { name: 'Disable', exact: true });
if (await disableButton.isVisible()) {
await disableButton.click();
await expect(page.getByRole('button', { name: 'Enable', exact: true })).toBeVisible();
}
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 page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(softDeleteUsers).toBeChecked({ checked: originalValue });
await softDeleteUsers.click();
await saveUnsavedChanges(page);
await page.reload();
await page.getByRole('tab', { name: 'LDAP' }).click();
await expect(page.getByRole('button', { name: 'Enable', exact: true })).toBeVisible();
await expect(page.getByRole('switch', { name: 'Keep disabled users from LDAP' })).toBeChecked({
checked: !originalValue
});
});
test.describe('Update application images', () => {
test('should detect image resets as unsaved changes', async ({ page }) => {
await page
.getByLabel('Background Image', { exact: true })
.setInputFiles('resources/images/clouds.jpg');
await saveUnsavedChanges(page);
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 page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(
page.getByRole('button', { name: 'Reset to default Background Image', exact: true })
).toBeVisible();
});
test('should upload images and reset custom logos', async ({ page }) => {
await page
.getByLabel('Favicon', { exact: true })
@@ -196,11 +308,7 @@ test.describe('Update application images', () => {
await page
.getByLabel('Background Image', { exact: true })
.setInputFiles('resources/images/clouds.jpg');
await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Images updated successfully. It may take a few minutes to update.'
);
await saveUnsavedChanges(page);
await page.request
.get('/api/application-images/favicon')
@@ -234,15 +342,11 @@ test.describe('Update application images', () => {
);
})
);
await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();
await saveUnsavedChanges(page);
for (const response of await Promise.all(logoDeleteResponses)) {
expect(response.status()).toBe(204);
}
await expect(page.locator('[data-type="success"]')).toHaveText(
'Images updated successfully. It may take a few minutes to update.'
);
// Without a custom logo the endpoint falls back to the logo bundled with Pocket ID
await page.request
.get('/api/application-images/logo?light=true')
@@ -264,10 +368,8 @@ test.describe('Update application images', () => {
const emailLogoInput = page.getByLabel('Email Logo', { exact: true });
await emailLogoInput.setInputFiles('resources/images/cloud-logo.svg');
await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText(
'File must be of type PNG or JPEG'
);
await expect(page.getByText('File must be of type PNG or JPEG', { exact: true })).toBeVisible();
});
});
+21
View File
@@ -20,3 +20,24 @@ test('keyboard focus stays on sidebar link after navigating', async ({ page }) =
await page.waitForURL('**/settings/audit-log');
await expect(auditLog).toBeFocused();
});
test('unsaved changes block navigation until they are discarded', async ({ page }) => {
await page.goto('/settings/account');
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();
const myApps = page.getByRole('link', { name: 'My Apps' });
await myApps.click();
await expect(page).toHaveURL(/\/settings\/account$/);
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 myApps.click();
await expect(page).toHaveURL(/\/settings\/apps$/);
await expect(page.getByText('You have unsaved changes', { exact: true })).toHaveCount(0);
});
+45 -27
View File
@@ -3,6 +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';
test.beforeEach(async () => await cleanupBackend());
@@ -77,12 +78,7 @@ test('Edit OIDC client', async ({ page }) => {
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png');
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
const clientForm = page.getByLabel('Name').locator('xpath=ancestor::form');
await clientForm.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'OIDC client updated successfully'
);
await saveUnsavedChanges(page);
await expect(page.getByRole('img', { name: 'Nextcloud updated logo' }).first()).toBeVisible();
await page.request
.get(`/api/oidc/clients/${oidcClient.id}/logo`)
@@ -134,8 +130,7 @@ test('Update OIDC client token lifetimes', async ({ page }) => {
await expect(refreshLifetime).toHaveValue('720');
await refreshLifetime.fill('336');
await card.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await saveUnsavedChanges(page);
await page.reload();
await expect(card.getByLabel('Access token lifetime', { exact: true })).toHaveValue('90');
@@ -146,21 +141,44 @@ test('Update OIDC client token lifetimes', async ({ page }) => {
await expect(card.getByLabel('Refresh token inactivity timeout unit')).toHaveText('Days');
await card.getByLabel('Access token lifetime', { exact: true }).fill('0');
await card.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(card.getByText('Token lifetime must be at least 1 minute.')).toBeVisible();
await card.getByLabel('Access token lifetime', { exact: true }).fill('525601');
await card.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(card.getByText('Token lifetime cannot exceed 365 days.')).toBeVisible();
await card.getByLabel('Access token lifetime', { exact: true }).fill('1.5');
await card.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(card.getByText('Token lifetime must use whole-minute increments.')).toBeVisible();
await card.getByLabel('Access token lifetime', { exact: true }).fill('60');
await card.getByLabel('Refresh token inactivity timeout', { exact: true }).fill('30');
await card.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await saveUnsavedChanges(page);
});
test('Save OIDC client details and token lifetimes together', async ({ page }) => {
const client = oidcClients.nextcloud;
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
const name = page.getByLabel('Name');
const accessLifetime = page
.getByTestId('token-lifetimes-card')
.getByLabel('Access token lifetime', { exact: true });
await name.fill('Nextcloud with custom lifetime');
await accessLifetime.fill('2');
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(name).toHaveValue(client.name);
await expect(accessLifetime).toHaveValue('1');
await name.fill('Nextcloud with custom lifetime');
await accessLifetime.fill('2');
await saveUnsavedChanges(page);
await page.reload();
await expect(name).toHaveValue('Nextcloud with custom lifetime');
await expect(accessLifetime).toHaveValue('2');
});
test('Update OIDC client federated credentials', async ({ page }) => {
@@ -178,7 +196,7 @@ test('Update OIDC client federated credentials', async ({ page }) => {
response.request().method() === 'PUT' &&
response.url().endsWith(`/api/oidc/clients/${client.id}`)
);
await card.getByRole('button', { name: 'Save' }).click();
await saveUnsavedChanges(page);
expect((await cardUpdate).ok()).toBeTruthy();
await page.reload();
@@ -186,17 +204,23 @@ test('Update OIDC client federated credentials', async ({ page }) => {
await expect(card.getByLabel('Subject')).toHaveValue('workload-client');
await expect(card.getByLabel('Audience')).toHaveValue('https://pocket-id.example.com');
await card.getByRole('radio', { name: 'Public keys' }).click();
await card.getByRole('button', { name: 'Add another federated client credential' }).click();
await expect(card.getByLabel('Issuer')).toHaveCount(2);
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(card.getByLabel('Issuer')).toHaveCount(1);
await expect(card.getByRole('radio', { name: 'JWKS URL' })).toBeChecked();
// Saving the main client form must preserve credentials managed by the separate card
await page.locator('[role="tab"][data-value="general"]').click();
const description = page.getByLabel('Description');
await description.fill('Updated without replacing federated credentials');
const clientForm = description.locator('xpath=ancestor::form');
const formUpdate = page.waitForResponse(
(response) =>
response.request().method() === 'PUT' &&
response.url().endsWith(`/api/oidc/clients/${client.id}`)
);
await clientForm.getByRole('button', { name: 'Save' }).click();
await saveUnsavedChanges(page);
expect((await formUpdate).ok()).toBeTruthy();
await page.goto(`/settings/admin/oidc-clients/${client.id}#credentials`);
@@ -228,7 +252,7 @@ test('Update OIDC client federated credentials with public keys', async ({ page
const pasteInput = card.getByLabel('Public key', { exact: true });
const addKeyButton = card.getByRole('button', { name: 'Add public key' });
const publicKeys = card.getByTestId('federated-identity-public-key');
const saveButton = card.getByRole('button', { name: 'Save' });
const saveButton = page.getByRole('button', { name: 'Save', exact: true });
const waitForClientUpdate = () =>
page.waitForResponse(
(response) =>
@@ -254,9 +278,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.locator('[data-type="error"]').filter({ hasText: 'private key material' })
).toBeVisible();
await expect(page.getByText(/private key material/)).toBeVisible();
await publicKeys.last().getByRole('button').click();
await expect(publicKeys).toHaveCount(3);
@@ -268,9 +290,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.locator('[data-type="error"]').filter({ hasText: 'missing the "kid" property' })
).toBeVisible();
await expect(page.getByText(/missing the "kid" property/)).toBeVisible();
await publicKeys.last().getByRole('button').click();
await expect(publicKeys).toHaveCount(3);
@@ -278,7 +298,7 @@ test('Update OIDC client federated credentials with public keys', async ({ page
await expect(publicKeys).toHaveCount(2);
const cardUpdate = waitForClientUpdate();
await saveButton.click();
await saveUnsavedChanges(page);
expect((await cardUpdate).ok()).toBeTruthy();
await page.reload();
@@ -466,9 +486,7 @@ test('Update OIDC client allowed user groups', async ({ page }) => {
await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click();
await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Allowed user groups updated successfully')).toBeVisible();
await saveUnsavedChanges(page);
await page.reload();
+3 -5
View File
@@ -4,6 +4,7 @@ import { cleanupBackend } from '../utils/cleanup.util';
import { generateIdToken } from '../utils/jwt.util';
import * as oidcUtil from '../utils/oidc.util';
import passkeyUtil from '../utils/passkey.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -1626,8 +1627,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
await parToggle.click();
}
await page.getByRole('button', { name: 'Save', exact: true }).first().click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await saveUnsavedChanges(page);
await page.reload();
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
@@ -1676,9 +1676,7 @@ test.describe('OIDC skip consent', () => {
// Disabling it and saving must persist across a reload
await toggle.click();
await expect(toggle).not.toBeChecked();
const clientForm = toggle.locator('xpath=ancestor::form');
await clientForm.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await saveUnsavedChanges(page);
await page.reload();
await expect(page.getByRole('switch', { name: 'Skip Consent Screen' })).not.toBeChecked();
+9 -9
View File
@@ -1,6 +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';
async function configureOidcClient(page: Page) {
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
@@ -34,11 +35,17 @@ 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 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 page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://scim.provider/api');
await page.reload();
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://scim.provider/api');
@@ -54,11 +61,7 @@ test.describe('SCIM Configuration', () => {
await page.getByLabel('SCIM Endpoint').fill('http://new.scim.provider/api');
await page.getByLabel('SCIM Token').fill('evenmoresecrettoken');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'SCIM configuration updated successfully.'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -134,10 +137,7 @@ test.describe('SCIM Sync', () => {
await developersCheckbox.click();
await expect(developersCheckbox).toHaveAttribute('data-state', 'unchecked');
await page.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByText('Allowed user groups updated successfully', { exact: true })
).toBeVisible();
await saveUnsavedChanges(page);
await syncScimServiceProvider(page);
+6 -21
View File
@@ -1,6 +1,7 @@
import test, { expect } from '@playwright/test';
import { oidcClients, userGroups, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -34,9 +35,7 @@ test('Edit user group', async ({ page }) => {
await page.getByLabel('Name', { exact: true }).fill('developers_updated');
await page.getByRole('button', { name: 'Save' }).nth(0).click();
await expect(page.locator('[data-type="success"]')).toHaveText('User group updated successfully');
await saveUnsavedChanges(page);
await expect(page.getByLabel('Friendly Name')).toHaveValue('Developers updated');
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('developers_updated');
});
@@ -49,9 +48,7 @@ test('Update user group users', async ({ page }) => {
await page.getByRole('row', { name: users.tim.username }).getByRole('checkbox').click();
await page.getByRole('row', { name: users.craig.username }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText('Users updated successfully');
await saveUnsavedChanges(page);
await page.reload();
@@ -90,11 +87,7 @@ test('Update user group custom claims', async ({ page }) => {
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
await page.waitForLoadState('networkidle');
@@ -107,11 +100,7 @@ test('Update user group custom claims', async ({ page }) => {
// Remove one custom claim
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
await page.waitForLoadState('networkidle');
@@ -136,11 +125,7 @@ test('Update user group allowed user groups', async ({ page }) => {
await page.getByRole('row', { name: oidcClients.tailscale.name }).getByRole('checkbox').click();
await page.getByRole('row', { name: oidcClients.immich.name }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Allowed OIDC clients updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
+11 -24
View File
@@ -2,6 +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';
test.beforeEach(async () => await cleanupBackend());
@@ -128,9 +129,7 @@ test('Update user', async ({ page }) => {
await page.getByLabel('Display Name').fill('Crack Apple');
await page.getByLabel('Email').fill('crack.apple@test.com');
await page.getByLabel('Username').fill('crack');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.locator('[data-type="success"]')).toHaveText('User updated successfully');
await saveUnsavedChanges(page);
});
test('Update user fails with already taken email', async ({ page }) => {
@@ -145,9 +144,9 @@ test('Update user fails with already taken email', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Email').fill(users.tim.email);
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Email is already in use');
await expect(page.getByText('Email is already in use', { exact: true })).toBeVisible();
});
test('Update user fails with already taken username', async ({ page }) => {
@@ -162,9 +161,9 @@ test('Update user fails with already taken username', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Username').fill(users.tim.username);
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
});
test('Update user fails with already taken username in different casing', async ({ page }) => {
@@ -179,9 +178,9 @@ test('Update user fails with already taken username in different casing', async
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Username').fill(users.tim.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
});
test('Update user custom claims', async ({ page }) => {
@@ -199,11 +198,7 @@ test('Update user custom claims', async ({ page }) => {
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -215,11 +210,7 @@ test('Update user custom claims', async ({ page }) => {
// Remove one custom claim
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -237,11 +228,7 @@ test('Update user group assignments', async ({ page }) => {
await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click();
await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'User groups updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
+7 -7
View File
@@ -2,6 +2,7 @@ import test, { expect, type Page } from '@playwright/test';
import { signupTokens, userGroups, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
async function setSignupMode(
page: Page,
@@ -11,13 +12,12 @@ async function setSignupMode(
await page.goto('/settings/admin/application-configuration');
await page.getByRole('tab', { name: 'User Creation' }).click();
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await page.getByRole('option', { name: mode }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
);
const signupMode = page.getByRole('button', { name: 'Enable User Signups' });
if (!(await signupMode.textContent())?.includes(mode)) {
await signupMode.click();
await page.getByRole('option', { name: mode }).click();
await saveUnsavedChanges(page);
}
if (signout) {
await page.context().clearCookies();
+6
View File
@@ -0,0 +1,6 @@
import { expect, type Page } from '@playwright/test';
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();
}