feat!: replace custom claims with custom fields

This commit is contained in:
Elias Schneider
2026-05-23 16:07:37 +02:00
parent 5db570bf66
commit 3428fb35d7
68 changed files with 3193 additions and 1016 deletions
+26 -1
View File
@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test';
import { emailVerificationTokens, users } from '../data';
import { customFields, emailVerificationTokens, users } from '../data';
import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util';
@@ -22,6 +22,31 @@ test('Update account details', async ({ page }) => {
);
});
test('Update self editable custom fields from account settings', async ({ page }) => {
await page.goto('/settings/account');
await expect(page.getByLabel(customFields.internalId.displayName)).not.toBeVisible();
await page.getByLabel(customFields.department.displayName).fill('Engineering3');
await page.getByLabel(customFields.nickname.displayName).fill('');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByText('Department must only contain letters')).toBeVisible();
await expect(page.getByText('Field is required')).toBeVisible();
await page.getByLabel(customFields.department.displayName).fill('Design');
await page.getByLabel(customFields.nickname.displayName).fill('Timmy');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Account details updated successfully'
);
await page.reload();
await expect(page.getByLabel(customFields.department.displayName)).toHaveValue('Design');
await expect(page.getByLabel(customFields.nickname.displayName)).toHaveValue('Timmy');
});
test('Update account details fails with already taken email', async ({ page }) => {
await page.goto('/settings/account');
+121 -20
View File
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { expect, test, type Page } from '@playwright/test';
import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(async ({ page }) => {
@@ -31,14 +31,15 @@ test('Update general configuration', async ({ page }) => {
test.describe('Update user creation configuration', () => {
test.beforeEach(async ({ page }) => {
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
await page.getByRole('tab', { name: 'Users and groups' }).click();
await page.getByRole('button', { name: 'Expand card' }).first().click();
});
test('should save sign up mode', async ({ page }) => {
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await page.getByRole('option', { name: 'Open Signup' }).click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
@@ -55,7 +56,7 @@ test.describe('Update user creation configuration', () => {
await page.getByRole('option', { name: 'Designers' }).click();
await page.getByRole('combobox', { name: 'User Groups' }).click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
@@ -69,31 +70,131 @@ test.describe('Update user creation configuration', () => {
await expect(page.getByRole('option', { name: 'Designers' })).toBeChecked();
});
test('should save default custom claims for new signups', async ({ page }) => {
await page.getByRole('button', { name: 'Add custom claim' }).click();
await page.getByPlaceholder('Key').fill('test-claim');
await page.getByPlaceholder('Value').fill('test-value');
await page.getByRole('button', { name: 'Add another' }).click();
await page.getByPlaceholder('Key').nth(1).fill('another-claim');
await page.getByPlaceholder('Value').nth(1).fill('another-value');
test('should create, edit, validate, and delete custom fields', async ({ page }) => {
await openCustomFieldsCard(page);
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await addCustomField(page, {
displayName: 'Employee code',
key: 'employeeCode',
defaultValue: 'WRONG',
validationRegex: '^EMP-[0-9]+$',
validationErrorMessage: 'Use EMP-###'
});
await expect(page.getByText('Use EMP-###')).toBeVisible();
await page.getByLabel('Default value').fill('EMP-001');
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
'Custom fields updated successfully'
);
await addCustomField(page, {
displayName: 'Weekly hours',
key: 'weeklyHours',
type: 'Number',
target: 'Users and groups',
required: true,
defaultValue: '40'
});
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'Custom fields updated successfully'
);
await expect(page.getByRole('row', { name: /Employee code/ })).toBeVisible();
await expect(page.getByRole('row', { name: /Weekly hours/ })).toBeVisible();
await page.getByRole('row', { name: /Employee code/ }).click();
await expect(page.getByLabel('Type')).toBeDisabled();
await page.getByLabel('Display Name').fill('Employee code updated');
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'Custom fields updated successfully'
);
const weeklyHoursRow = page.getByRole('row', { name: /Weekly hours/ });
await weeklyHoursRow.getByRole('button').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'Custom fields updated successfully'
);
await page.reload();
await expect(page.getByPlaceholder('Key').first()).toHaveValue('test-claim');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('test-value');
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('another-claim');
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('another-value');
await page.getByRole('tab', { name: 'Users and groups' }).click();
await openCustomFieldsCard(page, false);
await expect(page.getByText('Employee code updated')).toBeVisible();
await expect(page.getByText('Weekly hours')).not.toBeVisible();
});
});
async function openCustomFieldsCard(page: Page, navigate = true) {
if (navigate) {
await page.goto('/settings/admin/application-configuration');
await page.getByRole('tab', { name: 'Users and groups' }).click();
}
const addButton = page.getByRole('button', { name: 'Add custom field' });
if (!(await addButton.isVisible().catch(() => false))) {
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
}
await expect(addButton).toBeVisible();
}
async function addCustomField(
page: Page,
field: {
displayName: string;
key: string;
type?: 'Text' | 'Number' | 'Boolean';
target?: 'Users' | 'Groups' | 'Users and groups';
required?: boolean;
userEditable?: boolean;
defaultValue?: string;
validationRegex?: string;
validationErrorMessage?: string;
}
) {
await page.getByRole('button', { name: 'Add custom field' }).click();
await page.getByLabel('Display Name').fill(field.displayName);
await page.getByLabel('Key').fill(field.key);
if (field.type && field.type !== 'Text') {
await page.getByLabel('Type').click();
await page.getByRole('option', { name: field.type, exact: true }).click();
}
if (field.target && field.target !== 'Users') {
await page.getByLabel('Available for').click();
await page.getByRole('option', { name: field.target, exact: true }).click();
}
if (field.required) {
await page.getByLabel('Required').click();
}
if (field.userEditable) {
await page.getByLabel('User editable').click();
}
if (field.defaultValue !== undefined) {
if (field.type === 'Boolean') {
await page.getByLabel('Default value').click();
await page
.getByRole('option', { name: field.defaultValue === 'true' ? 'Yes' : 'No', exact: true })
.click();
} else {
await page.getByLabel('Default value').fill(field.defaultValue);
}
}
if (field.validationRegex) {
await page.getByLabel('Validation regex').fill(field.validationRegex);
}
if (field.validationErrorMessage) {
await page.getByLabel('Validation error message').fill(field.validationErrorMessage);
}
await page.getByRole('button', { name: 'Save' }).last().click();
}
test('Update email configuration', async ({ page }) => {
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
await page.getByRole('tab', { name: 'Email' }).click();
await page.getByRole('button', { name: 'Expand card' }).first().click();
await page.getByLabel('SMTP Host').fill('smtp.gmail.com');
await page.getByLabel('SMTP Port').fill('587');
@@ -105,7 +206,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' }).nth(1).click();
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Email configuration updated successfully'
@@ -126,7 +227,7 @@ test('Update email configuration', async ({ page }) => {
test.describe('Update application images', () => {
test.beforeEach(async ({ page }) => {
await page.getByRole('button', { name: 'Expand card' }).nth(4).click();
await page.getByRole('button', { name: 'Expand card' }).last().click();
});
test('should upload images', async ({ page }) => {
+22 -1
View File
@@ -1,4 +1,5 @@
import test, { expect } from '@playwright/test';
import { customFields } from 'data';
import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(async () => await cleanupBackend());
@@ -12,7 +13,8 @@ test.describe('LDAP Integration', () => {
test('LDAP configuration is working properly', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await page.getByRole('button', { name: 'Expand card' }).nth(3).click();
await page.getByRole('tab', { name: 'Users and groups' }).click();
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeVisible();
await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/);
@@ -66,6 +68,25 @@ test.describe('LDAP Integration', () => {
await expect(page.getByText('LDAP').first()).toBeVisible();
});
test('LDAP custom fields are synced into users and groups', async ({ page }) => {
await page.goto('/settings/admin/users');
await page.getByRole('row', { name: 'testuser1' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByLabel(customFields.department.displayName)).toHaveValue('Engineering');
await page.goto('/settings/admin/user-groups');
await page
.getByRole('row', { name: 'admin_group' })
.getByRole('button', { name: 'Toggle menu' })
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(
page.getByRole('switch', { name: customFields.elevatedRights.displayName })
).toBeChecked();
});
test('LDAP users cannot be modified in PocketID', async ({ page }) => {
// Navigate to LDAP user details
await page.goto('/settings/admin/users');
+56 -2
View File
@@ -1,6 +1,11 @@
import test, { expect, type Page, type Request } from '@playwright/test';
import { oidcClients, refreshTokens, users } from '../data';
import * as jose from 'jose';
import { customFields, oidcClients, refreshTokens, userGroups, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import {
updateUserCustomFieldsViaApi,
updateUserGroupCustomFieldsViaApi
} from '../utils/custom-fields.util';
import { generateIdToken, generateOauthAccessToken } from '../utils/jwt.util';
import * as oidcUtil from '../utils/oidc.util';
import passkeyUtil from '../utils/passkey.util';
@@ -130,7 +135,11 @@ test('End session without id token hint shows confirmation page', async ({ page
test('End session with id token hint redirects to callback URL', async ({ page }) => {
const client = oidcClients.nextcloud;
const idToken = await generateIdToken("fe81c12a-7336-4aee-bebc-d901a873bf48", users.tim, client.id);
const idToken = await generateIdToken(
'fe81c12a-7336-4aee-bebc-d901a873bf48',
users.tim,
client.id
);
let redirectedCorrectly = false;
await page
.goto(
@@ -190,6 +199,51 @@ test('Successfully refresh tokens with valid refresh token', async ({ request })
expect(tokenData.refresh_token).not.toBe(token);
});
test('ID token includes configured custom fields when profile scope is requested', async ({
page
}) => {
await updateUserCustomFieldsViaApi(page, users.tim.id, [
{ customFieldId: customFields.department.id, value: 'Engineering' },
{ customFieldId: customFields.nickname.id, value: 'Timi' }
]);
await updateUserGroupCustomFieldsViaApi(page, userGroups.designers.id, [
{ customFieldId: customFields.elevatedRights.id, value: 'true' }
]);
const { token, clientId, userId } = refreshTokens.filter(
(refreshToken) => !refreshToken.expired
)[0];
const refreshToken = await page.request
.post('/api/test/refreshtoken', {
data: {
rt: token,
client: clientId,
user: userId
}
})
.then((r) => r.text());
const refreshResponse = await page.request.post('/api/oidc/token', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
form: {
grant_type: 'refresh_token',
client_id: clientId,
refresh_token: refreshToken,
client_secret: oidcClients.nextcloud.secret
}
});
expect(refreshResponse.ok()).toBeTruthy();
const tokenData = await refreshResponse.json();
const idToken = jose.decodeJwt(tokenData.id_token);
expect(idToken.department).toBe('Engineering');
expect(idToken.nickname).toBe('Timi');
expect(idToken.elevatedRights).toBe(true);
});
test('Refresh token fails when used for the wrong client', async ({ request }) => {
const { token, clientId, userId } = refreshTokens.filter((token) => !token.expired)[0];
const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY';
+12 -39
View File
@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test';
import { oidcClients, userGroups, users } from '../data';
import { customFields, oidcClients, userGroups, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(async () => await cleanupBackend());
@@ -74,56 +74,29 @@ test('Delete user group', async ({ page }) => {
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
});
test('Update user group custom claims', async ({ page }) => {
test('Update user group custom fields', async ({ page }) => {
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
await page.getByRole('button', { name: 'Expand card' }).first().click();
await expect(
page.getByRole('switch', { name: customFields.elevatedRights.displayName })
).not.toBeChecked();
// Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click();
await page.getByRole('switch', { name: customFields.elevatedRights.displayName }).click();
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByPlaceholder('Key').fill('customClaim1');
await page.getByPlaceholder('Value').fill('customClaim1_value');
await page.getByRole('button', { name: 'Add another' }).click();
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
await page.getByRole('button', { name: 'Save' }).nth(2).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await expect(page.locator('[data-type="success"]')).toHaveText('User group updated successfully');
await page.reload();
await page.waitForLoadState('networkidle');
// Check if custom claims are saved
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
// Remove one custom claim
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).nth(2).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await page.reload();
await page.waitForLoadState('networkidle');
// Check if custom claim is removed
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
await expect(
page.getByRole('switch', { name: customFields.elevatedRights.displayName })
).toBeChecked();
});
test('Update user group allowed user groups', async ({ page }) => {
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
await page.getByRole('button', { name: 'Expand card' }).click();
// Unrestricted OIDC clients should be checked and disabled
const nextcloudRow = page
+20 -37
View File
@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test';
import { userGroups, users } from '../data';
import { customFields, userGroups, users } from '../data';
import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util';
@@ -184,48 +184,27 @@ test('Update user fails with already taken username in different casing', async
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
});
test('Update user custom claims', async ({ page }) => {
test('Update user custom fields', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
await page.getByLabel(customFields.department.displayName).fill('Engineering3');
await page.getByLabel(customFields.nickname.displayName).fill('');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByText('Department must only contain letters')).toBeVisible();
await expect(page.getByText('Field is required')).toBeVisible();
// Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click();
await page.getByLabel(customFields.department.displayName).fill('Design');
await page.getByLabel(customFields.internalId.displayName).fill('123');
await page.getByLabel(customFields.nickname.displayName).fill('Timmy');
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByPlaceholder('Key').fill('customClaim1');
await page.getByPlaceholder('Value').fill('customClaim1_value');
await page.getByRole('button', { name: 'Add another' }).click();
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await expect(page.locator('[data-type="success"]')).toHaveText('User updated successfully');
await page.reload();
// Check if custom claims are saved
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
// Remove one custom claim
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await page.reload();
// Check if custom claim is removed
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
await expect(page.getByLabel(customFields.department.displayName)).toHaveValue('Design');
await expect(page.getByLabel(customFields.nickname.displayName)).toHaveValue('Timmy');
await expect(page.getByLabel(customFields.internalId.displayName)).toHaveValue('123');
});
test('Update user group assignments', async ({ page }) => {
@@ -263,7 +242,11 @@ test('Admin can view another user passkeys', async ({ page }) => {
test('Admin can delete another user passkey and audit log is created', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.locator('[data-slot="item"]').filter({ hasText: 'Passkey 2' }).getByLabel('Delete').click();
await page
.locator('[data-slot="item"]')
.filter({ hasText: 'Passkey 2' })
.getByLabel('Delete')
.click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully');
+31 -4
View File
@@ -1,5 +1,5 @@
import test, { expect, type Page } from '@playwright/test';
import { signupTokens, userGroups, users } from '../data';
import { customFields, signupTokens, userGroups, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util';
@@ -10,10 +10,11 @@ async function setSignupMode(
) {
await page.goto('/settings/admin/application-configuration');
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
await page.getByRole('tab', { name: 'Users and groups' }).click();
await page.getByRole('button', { name: 'Expand card' }).first().click();
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await page.getByRole('option', { name: mode }).click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
@@ -117,7 +118,7 @@ test.describe('User Signup', () => {
await expect(page.getByText('Set up your passkey')).toBeVisible();
const response = await page.request.get('/api/users/me').then((res) => res.json());
expect(response.userGroups.map((g) => g.id)).toContain(userGroups.developers.id);
expect(response.userGroups.map((g: any) => g.id)).toContain(userGroups.developers.id);
});
test('Signup with token - invalid token shows error', async ({ page }) => {
@@ -161,6 +162,32 @@ test.describe('User Signup', () => {
await expect(page.getByText('Set up your passkey')).toBeVisible();
});
test('Open signup - required custom fields are shown and saved', async ({ page }) => {
await setSignupMode(page, 'Open Signup', false);
await page.context().clearCookies();
await page.goto('/signup');
await expect(page.getByLabel(customFields.nickname.displayName)).toBeVisible();
await expect(page.getByLabel(customFields.elevatedRights.displayName)).not.toBeVisible();
await expect(page.getByLabel(customFields.department.displayName)).not.toBeVisible();
await expect(page.getByLabel(customFields.internalId.displayName)).not.toBeVisible();
await page.getByLabel('First name').fill('Jane');
await page.getByLabel('Last name').fill('Smith');
await page.getByLabel('Username').fill('janecustomfield');
await page.getByLabel('Email').fill('jane.customfield@test.com');
await page.getByLabel(customFields.nickname.displayName).fill('123');
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.getByRole('button', { name: 'Skip for now' }).click();
await page.getByRole('button', { name: 'Skip for now' }).nth(1).click();
await page.waitForURL('/settings/account');
await expect(page.getByLabel(customFields.nickname.displayName)).toHaveValue('123');
});
test('Open signup - validation errors', async ({ page }) => {
await setSignupMode(page, 'Open Signup');