diff --git a/samples/browser/quickstart/src/components/profileDialog.js b/samples/browser/quickstart/src/components/profileDialog.js index 1f0634b2..36e30dd8 100644 --- a/samples/browser/quickstart/src/components/profileDialog.js +++ b/samples/browser/quickstart/src/components/profileDialog.js @@ -1,6 +1,20 @@ -import { updateMeProfile } from '@thunderid/browser' +import { deepMerge, getUsersMe, getUsersMeMeta, updateMeProfile } from '@thunderid/browser' const ICON_CLOSE = `` +const ICON_PENCIL = `` + +// Attributes that are always read-only regardless of schema mutability +const ALWAYS_READONLY_KEYS = [ + 'attributes', + 'id', + 'isReadOnly', + 'isReadonly', + 'ouId', + 'sub', + 'username', + 'userName', + 'user_name', +] function escapeHtml(str) { if (str == null) return '' @@ -12,10 +26,45 @@ function escapeHtml(str) { .replace(/'/g, ''') } +function formatLabel(key) { + return key + .split(/(?=[A-Z])|[_.]/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' ') +} + +function fieldLabel(key, schemaEntry) { + return schemaEntry?.displayName || formatLabel(key) +} + +function isFieldEditable(key, schemaEntry) { + if (ALWAYS_READONLY_KEYS.includes(key)) return false + if (!schemaEntry) return false + if (schemaEntry.credential) return false + if (schemaEntry.readOnly || schemaEntry.mutability === 'READ_ONLY') return false + return true +} + function getAvatarUrl(user) { return user?.profile || user?.profileUrl || user?.picture || user?.URL || null } +// Deterministic gradient background derived from a name, matching `@thunderid/react`'s +// `Avatar` `background="random"` (default) behavior โ€” same hash and HSL formula, so a +// given user gets the same avatar color here as they would in the React/Vue quickstarts. +function generateAvatarBackground(name) { + const hash = name.split('').reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) & 0xffffffff, 0) + const seed = Math.abs(hash) + const hue1 = seed % 360 + const hue2 = (hue1 + 60 + (seed % 120)) % 360 + const saturation = 70 + (seed % 20) + const lightness1 = 55 + (seed % 15) + const lightness2 = 60 + (seed % 15) + const angle = 45 + (seed % 91) + return `linear-gradient(${angle}deg, hsl(${hue1}, ${saturation}%, ${lightness1}%), hsl(${hue2}, ${saturation}%, ${lightness2}%))` +} + function getInitials(user) { const given = user?.given_name || '' const family = user?.family_name || '' @@ -26,87 +75,239 @@ function getInitials(user) { return name.slice(0, 2).toUpperCase() } -export function renderProfileDialog(user) { +// `getDisplayName`: first + last name, falling back to +// username, email, then the `name` attribute. +function getDisplayName(user, attributes) { + const given = attributes?.given_name || user?.given_name + const family = attributes?.family_name || user?.family_name + if (given && family) return `${given} ${family}` + return user?.username || user?.email || attributes?.name || 'User' +} + +function renderAvatarInner(user, displayName) { const avatarUrl = getAvatarUrl(user) - const avatarHtml = avatarUrl - ? `` - : escapeHtml(getInitials(user)) - const given = escapeHtml(user?.given_name || '') - const family = escapeHtml(user?.family_name || '') + if (avatarUrl) { + return { className: '', html: `` } + } + return { + className: 'has-gradient', + html: escapeHtml(getInitials(user)), + style: `background:${generateAvatarBackground(displayName || 'User')}`, + } +} + +function createFetcher(auth) { + return async (url, config) => { + const token = await auth.getAccessToken() + return fetch(url, { + ...config, + headers: { ...config.headers, Authorization: `Bearer ${token}` }, + }) + } +} + +// Fetches the schema and the current attribute values needed to render and validate the profile view. +export async function fetchProfileFormContext({ baseUrl, auth }) { + const fetcher = createFetcher(auth) + + const [metaRes, profile] = await Promise.all([ + getUsersMeMeta({ baseUrl, fetcher }).catch(() => ({ schema: {} })), + getUsersMe({ baseUrl, fetcher }).catch(() => null), + ]) + + return { schema: metaRes?.schema || {}, profile } +} + +function renderFieldRow(key, schemaEntry, value) { + if (schemaEntry?.credential) return '' + + const label = fieldLabel(key, schemaEntry) + const editable = isFieldEditable(key, schemaEntry) + const hasValue = value !== undefined && value !== null && value !== '' + + // BaseUserProfile `shouldShow`: an empty read-only field is hidden entirely rather than rendered as a dash. + if (!hasValue && !editable) return '' + + const displayValue = hasValue ? escapeHtml(String(value)) : `Enter your ${label.toLowerCase()}` + + return ` +
+
${escapeHtml(label)}
+
+ ${displayValue} + ${editable ? `` : ''} +
+
` +} + +export function renderProfileDialog(user, { schema = {}, profile } = {}) { + const attributes = profile?.attributes || {} + const displayName = getDisplayName(user, attributes) + const avatar = renderAvatarInner(user, displayName) const email = escapeHtml(user?.email || user?.username || '') + const rows = Object.entries(schema) + .map(([key, schemaEntry]) => renderFieldRow(key, schemaEntry, attributes[key])) + .join('') + return `
` } -export function attachProfileDialogHandlers({ user, auth, onSaved }) { +// Validates a field value against its schema entry (required + regex), matching +// BaseUserProfile `handleFieldSave`. Returns an error message, or +// `null` when valid. +function validateField(schemaEntry, label, value) { + if (!schemaEntry) return null + + if (schemaEntry.required && !value) { + return `${label} is required.` + } + + if (schemaEntry.regex && value) { + try { + if (!new RegExp(schemaEntry.regex).test(value)) { + return `${label} is not in a valid format.` + } + } catch { + // Invalid regex on the schema itself โ€” nothing to enforce client-side. + } + } + + return null +} + +export function attachProfileDialogHandlers({ user, auth, schema = {}, profile, onSaved, onClose }) { const overlay = document.getElementById('profile-dialog-overlay') - const closeDialog = () => overlay?.remove() + const closeDialog = () => { + overlay?.remove() + onClose?.() + } document.getElementById('profile-dialog-close')?.addEventListener('click', closeDialog) - document.getElementById('profile-dialog-cancel')?.addEventListener('click', closeDialog) overlay?.addEventListener('click', (e) => { if (e.target === overlay) closeDialog() }) const errorEl = document.getElementById('profile-dialog-error') - const saveBtn = document.getElementById('profile-dialog-save') + const fieldList = document.getElementById('profile-field-list') + const baseUrl = import.meta.env.VITE_THUNDERID_BASE_URL + const fetcher = createFetcher(auth) - saveBtn?.addEventListener('click', async () => { - const givenName = document.getElementById('profile-first-name')?.value.trim() || '' - const familyName = document.getElementById('profile-last-name')?.value.trim() || '' + // Tracks the latest known attributes so successive per-field edits merge against + // up-to-date values without refetching the whole profile on every save. + let currentAttributes = { ...(profile?.attributes || {}) } - saveBtn.disabled = true - saveBtn.textContent = 'Saving...' - if (errorEl) { errorEl.hidden = true; errorEl.textContent = '' } + const refreshHeader = () => { + const mergedUser = { ...user, ...currentAttributes } + const displayName = getDisplayName(mergedUser, currentAttributes) - try { - await updateMeProfile({ - baseUrl: import.meta.env.VITE_THUNDERID_BASE_URL, - payload: { name: { givenName, familyName } }, - fetcher: async (url, config) => { - const token = await auth.getAccessToken() - return fetch(url, { - ...config, - headers: { ...config.headers, Authorization: `Bearer ${token}` }, - }) - }, - }) - - onSaved?.({ ...user, given_name: givenName, family_name: familyName }) - closeDialog() - } catch (err) { - if (errorEl) { - errorEl.hidden = false - errorEl.textContent = err?.message || 'Failed to update profile. Please try again.' + const avatarEl = overlay?.querySelector('.profile-dialog-avatar') + if (avatarEl) { + const avatar = renderAvatarInner(mergedUser, displayName) + avatarEl.className = `profile-dialog-avatar ${avatar.className}` + avatarEl.setAttribute('style', avatar.style || '') + avatarEl.innerHTML = avatar.html + } + + const nameEl = overlay?.querySelector('.profile-dialog-name') + if (nameEl) nameEl.textContent = displayName + } + + const showError = (message) => { + if (!errorEl) return + errorEl.hidden = false + errorEl.textContent = message + } + const clearError = () => { + if (!errorEl) return + errorEl.hidden = true + errorEl.textContent = '' + } + + const startEdit = (row, key, schemaEntry) => { + const display = row.querySelector('.profile-field-row-display') + if (!display) return + const currentValue = currentAttributes[key] ?? '' + + display.innerHTML = ` + +
+ + +
` + + const input = display.querySelector('input') + input?.focus() + + const cancel = () => { + row.outerHTML = renderFieldRow(key, schemaEntry, currentAttributes[key]) + } + + const save = async () => { + const value = input?.value.trim() || '' + const label = fieldLabel(key, schemaEntry) + const fieldError = validateField(schemaEntry, label, value) + if (fieldError) { + showError(fieldError) + return + } + clearError() + + const saveBtn = display.querySelector('[data-action="save"]') + if (saveBtn) saveBtn.disabled = true + + try { + const mergedAttributes = deepMerge(currentAttributes, { [key]: value }) + const updatedUser = await updateMeProfile({ baseUrl, payload: mergedAttributes, fetcher }) + + currentAttributes = { ...currentAttributes, [key]: updatedUser?.[key] ?? value } + row.outerHTML = renderFieldRow(key, schemaEntry, currentAttributes[key]) + refreshHeader() + + onSaved?.({ ...user, ...currentAttributes }) + } catch (err) { + showError(err?.message || 'Failed to update profile. Please try again.') + if (saveBtn) saveBtn.disabled = false } - saveBtn.disabled = false - saveBtn.textContent = 'Save' } + + display.addEventListener('click', (e) => { + const action = e.target.closest('[data-action]')?.dataset.action + if (action === 'save') save() + if (action === 'cancel') cancel() + }) + + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') save() + if (e.key === 'Escape') cancel() + }) + } + + fieldList?.addEventListener('click', (e) => { + const editBtn = e.target.closest('[data-action="edit"]') + if (!editBtn) return + const row = editBtn.closest('.profile-field-row') + const key = row?.dataset.field + if (!row || !key) return + startEdit(row, key, schema[key]) }) } diff --git a/samples/browser/quickstart/src/main.js b/samples/browser/quickstart/src/main.js index f9822a4f..52edd900 100644 --- a/samples/browser/quickstart/src/main.js +++ b/samples/browser/quickstart/src/main.js @@ -1,7 +1,7 @@ import './style.css' import auth, { missingEnvVars } from './auth.js' import { renderSignedOutNav, renderSignedInNav, attachNavHandlers, attachSignedOutNavHandlers } from './components/nav.js' -import { renderProfileDialog, attachProfileDialogHandlers } from './components/profileDialog.js' +import { renderProfileDialog, attachProfileDialogHandlers, fetchProfileFormContext } from './components/profileDialog.js' import { renderSignedOut, renderHome, renderConfigNeeded, startCountdown, attachSignedOutHandlers, attachConfigNeededHandlers } from './pages/home.js' import { renderTokenDebug, attachTokenHandlers } from './pages/token.js' @@ -46,16 +46,25 @@ function renderSignedInPage() { } } -function openManageProfile() { +async function openManageProfile() { const app = document.getElementById('app') if (!app) return - app.insertAdjacentHTML('beforeend', renderProfileDialog(user)) + const { schema, profile } = await fetchProfileFormContext({ + baseUrl: import.meta.env.VITE_THUNDERID_BASE_URL, + auth, + }) + + app.insertAdjacentHTML('beforeend', renderProfileDialog(user, { schema, profile })) attachProfileDialogHandlers({ user, auth, + schema, + profile, onSaved: (updatedUser) => { user = updatedUser + }, + onClose: () => { renderSignedInPage() }, }) diff --git a/samples/browser/quickstart/src/style.css b/samples/browser/quickstart/src/style.css index aac59665..27a3de04 100644 --- a/samples/browser/quickstart/src/style.css +++ b/samples/browser/quickstart/src/style.css @@ -13,6 +13,7 @@ --blue: #3688FF; --blue-hover: #2270e8; --blue-subtle: rgba(54, 136, 255, 0.08); + --secondary: #424242; --shadow-sm: 0 1px 3px rgba(5, 33, 63, 0.08); --shadow-md: 0 4px 16px rgba(5, 33, 63, 0.10); --radius: 10px; @@ -26,6 +27,7 @@ --text: #E0EAFF; --muted: rgba(224, 234, 255, 0.48); --blue-subtle: rgba(54, 136, 255, 0.14); + --secondary: #8b8b8b; --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); } @@ -1056,7 +1058,7 @@ body { .profile-dialog-overlay { position: fixed; inset: 0; - background: rgba(5, 33, 63, 0.45); + background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; @@ -1066,24 +1068,25 @@ body { .profile-dialog { width: 100%; - max-width: 380px; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; background: var(--card); - border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); - padding: 24px; } .profile-dialog-header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 16px; + padding: 24px 32px; + border-bottom: 1px solid var(--border); } .profile-dialog-header h2 { - font-size: 16px; - font-weight: 700; + font-size: 19px; + font-weight: 600; color: var(--text); } @@ -1105,21 +1108,39 @@ body { color: var(--blue); } +.profile-dialog-body { + padding: 16px 32px 32px; +} + +.profile-dialog-summary { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; +} + .profile-dialog-avatar { - width: 64px; - height: 64px; - margin: 0 auto 20px; + width: 70px; + height: 70px; border-radius: 50%; - background: var(--blue); - color: #fff; + border: 1px solid var(--border); + background: var(--card); + color: var(--text); display: flex; align-items: center; justify-content: center; - font-size: 20px; - font-weight: 700; + font-size: 28px; + font-weight: 600; overflow: hidden; } +.profile-dialog-avatar.has-gradient { + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + border: none; +} + .profile-dialog-avatar img { width: 100%; height: 100%; @@ -1136,48 +1157,137 @@ body { margin-bottom: 14px; } -.profile-dialog-field { - margin-bottom: 14px; +.profile-dialog-name { + font-size: 24px; + font-weight: 600; + color: var(--text); } -.profile-dialog-field label { - display: block; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; +.profile-dialog-subtitle { + font-size: 14px; color: var(--muted); - margin-bottom: 6px; } -.profile-dialog-field input { - width: 100%; - padding: 9px 12px; +.profile-field-list { + display: flex; + flex-direction: column; +} + +.profile-field-row { + display: flex; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} + +.profile-field-row-label { + font-size: 14px; + font-weight: 500; + color: var(--muted); + width: 120px; + flex-shrink: 0; + line-height: 28px; +} + +.profile-field-row-display { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.profile-field-row-value { + flex: 1; + font-size: 14px; + color: var(--text); + line-height: 28px; + word-break: break-word; + text-align: left; +} + +.profile-field-row-value.placeholder { + font-style: italic; + opacity: 0.7; +} + +.profile-field-edit-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex-shrink: 0; + border: none; + background: transparent; + color: var(--muted); + border-radius: var(--radius-sm); + cursor: pointer; + opacity: 0.7; +} + +.profile-field-edit-btn:hover { + opacity: 1; + color: var(--blue); +} + +.profile-field-row-input { + flex: 1; + padding: 4px 8px; font-size: 14px; font-family: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); + min-width: 0; } -.profile-dialog-field input:focus { +.profile-field-row-input:focus { outline: none; border-color: var(--blue); } -.profile-dialog-readonly { - padding: 9px 12px; - font-size: 14px; - color: var(--muted); - background: var(--bg); - border: 1px solid var(--border); +.profile-field-row-actions { + display: flex; + gap: 6px; +} + +.profile-field-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + padding: 0 12px; + border: none; border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s ease; } -.profile-dialog-actions { - display: flex; - justify-content: flex-end; - gap: 10px; - margin-top: 20px; +.profile-field-btn--save { + background: var(--blue); + color: #ffffff; +} + +.profile-field-btn--save:hover:not(:disabled) { + opacity: 0.9; +} + +.profile-field-btn--save:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.profile-field-btn--cancel { + background: var(--secondary); + color: #ffffff; +} + +.profile-field-btn--cancel:hover { + opacity: 0.9; } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 4fc0c15a..e8ca42c7 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -12,20 +12,22 @@ import {send} from './utils/api-request'; async function globalTeardown(): Promise { const username = process.env.TEST_USER_USERNAME; - if (!username) return; console.log('๐Ÿงน Deleting shared E2E test user...'); - const searchRes = await send('GET', `/users?attribute=username&value=${encodeURIComponent(username)}`); + const filter = `username eq "${username}"`; + const searchRes = await send('GET', `/users?filter=${encodeURIComponent(filter)}`); if (!searchRes.ok) { - console.warn(`โš ๏ธ Could not look up test user "${username}" for cleanup (HTTP ${searchRes.status})`); - return; + throw new Error( + `Failed to look up test user "${username}" for cleanup: HTTP ${searchRes.status}: ${await searchRes.text()}`, + ); } - const {users} = (await searchRes.json()) as {users?: {id: string}[]}; - const user = users?.[0]; - if (!user) { + const {users} = (await searchRes.json()) as {users?: {attributes?: {username?: string}; id: string}[]}; + const matches = (users ?? []).filter((candidate) => candidate.attributes?.username === username); + if (matches.length === 0) { console.warn(`โš ๏ธ Test user "${username}" not found โ€” nothing to clean up`); return; } + const user = matches[0]; const deleteRes = await send('DELETE', `/users/${user.id}`); if (!deleteRes.ok) { diff --git a/tests/e2e/pages/browser-quickstart.page.ts b/tests/e2e/pages/browser-quickstart.page.ts index 675080ab..d5d113a4 100644 --- a/tests/e2e/pages/browser-quickstart.page.ts +++ b/tests/e2e/pages/browser-quickstart.page.ts @@ -11,6 +11,11 @@ import {Page, expect} from '@playwright/test'; import {GateLoginPage} from './gate-login.page'; import {Timeouts} from '../constants/timeouts'; +export const ProfileFieldKeys = { + familyName: 'family_name', + givenName: 'given_name', +}; + export class BrowserQuickstartPage extends GateLoginPage { constructor(page: Page) { super(page); @@ -74,13 +79,24 @@ export class BrowserQuickstartPage extends GateLoginPage { .waitFor({state: 'visible', timeout: Timeouts.ELEMENT_VISIBILITY}); } - async fillProfileName(givenName: string, familyName: string): Promise { - await this.page.locator('#profile-first-name').fill(givenName); - await this.page.locator('#profile-last-name').fill(familyName); + /** Edits one field of the profile dialog, which renders each schema attribute as its own row + * with a pencil "Edit" button.*/ + async editProfileField(fieldKey: string, value: string): Promise { + const row = this.page.locator(`.profile-field-row[data-field="${fieldKey}"]`); + await row.locator('[data-action="edit"]').click(); + await row.locator('.profile-field-row-input').fill(value); + await row.locator('[data-action="save"]').click(); + await expect(row.locator('.profile-field-row-input')).toHaveCount(0, {timeout: Timeouts.DEFAULT_ACTION}); + } + + /** Verifies a field's row reverted from edit mode back to display mode showing the just-saved value*/ + async verifyProfileFieldValue(fieldKey: string, value: string): Promise { + const row = this.page.locator(`.profile-field-row[data-field="${fieldKey}"]`); + await expect(row.locator('.profile-field-row-value')).toHaveText(value, {timeout: Timeouts.ELEMENT_VISIBILITY}); } - async saveProfile(): Promise { - await this.page.locator('#profile-dialog-save').click(); + async closeManageProfile(): Promise { + await this.page.locator('#profile-dialog-close').click(); await this.page.locator('#profile-dialog-overlay').waitFor({state: 'hidden', timeout: Timeouts.DEFAULT_ACTION}); } diff --git a/tests/e2e/tests/browser-quickstart/sign-in-out.spec.ts b/tests/e2e/tests/browser-quickstart/sign-in-out.spec.ts index a3665e82..64021adb 100644 --- a/tests/e2e/tests/browser-quickstart/sign-in-out.spec.ts +++ b/tests/e2e/tests/browser-quickstart/sign-in-out.spec.ts @@ -8,6 +8,7 @@ import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; import {expect, test} from '../../fixtures/sample-apps'; +import {ProfileFieldKeys} from '../../pages/browser-quickstart.page'; import {decodeJwtPayload} from '../../utils/jwt'; const appUrl = sampleAppUrl(SampleApps.BROWSER); @@ -66,9 +67,13 @@ test.describe('browser/quickstart - Sign in and Sign out', () => { await browserQuickstartPage.verifyLoggedIn(); await browserQuickstartPage.openManageProfile(); - await browserQuickstartPage.fillProfileName('Profile', 'Updated'); - await browserQuickstartPage.saveProfile(); + await browserQuickstartPage.editProfileField(ProfileFieldKeys.givenName, 'Profile'); + await browserQuickstartPage.editProfileField(ProfileFieldKeys.familyName, 'Updated'); + await browserQuickstartPage.verifyProfileFieldValue(ProfileFieldKeys.givenName, 'Profile'); + await browserQuickstartPage.verifyProfileFieldValue(ProfileFieldKeys.familyName, 'Updated'); + + await browserQuickstartPage.closeManageProfile(); await browserQuickstartPage.verifyDisplayedName('Profile Updated'); }); });