From 04bdb647498ae3d7f71299985107ad1dfb9d373c Mon Sep 17 00:00:00 2001 From: janithjay Date: Mon, 17 Aug 2026 15:52:24 +0530 Subject: [PATCH] Make browser quickstart profile management schema-driven Signed-off-by: janithjay --- .../src/components/profileDialog.js | 310 ++++++++++++++---- samples/browser/quickstart/src/main.js | 13 +- samples/browser/quickstart/src/style.css | 147 ++++++--- 3 files changed, 373 insertions(+), 97 deletions(-) diff --git a/samples/browser/quickstart/src/components/profileDialog.js b/samples/browser/quickstart/src/components/profileDialog.js index 1f0634b2..0fbbd1db 100644 --- a/samples/browser/quickstart/src/components/profileDialog.js +++ b/samples/browser/quickstart/src/components/profileDialog.js @@ -1,6 +1,22 @@ -import { updateMeProfile } from '@thunderid/browser' +import { deepMerge, getUsersMe, getUsersMeMeta, updateMeProfile } from '@thunderid/browser' const ICON_CLOSE = `` +const ICON_PENCIL = `` +const ICON_CHECK = `` +const ICON_CANCEL = `` + +// 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 +28,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 +77,236 @@ 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 }) { const overlay = document.getElementById('profile-dialog-overlay') const closeDialog = () => overlay?.remove() 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..29b433db 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,14 +46,21 @@ 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 renderSignedInPage() diff --git a/samples/browser/quickstart/src/style.css b/samples/browser/quickstart/src/style.css index aac59665..87de6c8a 100644 --- a/samples/browser/quickstart/src/style.css +++ b/samples/browser/quickstart/src/style.css @@ -1056,7 +1056,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 +1066,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 +1106,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 +1155,98 @@ 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; - font-family: inherit; color: var(--text); - background: var(--bg); - border: 1px solid var(--border); + 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-dialog-field input:focus { - outline: none; - border-color: var(--blue); +.profile-field-edit-btn:hover { + opacity: 1; + color: var(--blue); } -.profile-dialog-readonly { - padding: 9px 12px; +.profile-field-row-input { + flex: 1; + padding: 4px 8px; font-size: 14px; - color: var(--muted); + font-family: inherit; + color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); + min-width: 0; } -.profile-dialog-actions { +.profile-field-row-input:focus { + outline: none; + border-color: var(--blue); +} + +.profile-field-row-actions { display: flex; - justify-content: flex-end; - gap: 10px; - margin-top: 20px; + gap: 2px; }