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 `
-
${avatarHtml}
-
-
-
-
-
-
-
-
-
-
-
-
${email || '—'}
-
-
-
-
+
+
+
${avatar.html}
+
+
${escapeHtml(displayName)}
+ ${email ? `
${email}
` : ''}
+
+
+
+
${rows}
`
}
-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;
}