Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 255 additions & 55 deletions samples/browser/quickstart/src/components/profileDialog.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { updateMeProfile } from '@thunderid/browser'
import { deepMerge, getUsersMe, getUsersMeMeta, updateMeProfile } from '@thunderid/browser'

const ICON_CLOSE = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`
const ICON_PENCIL = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>`
const ICON_CHECK = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`
const ICON_CANCEL = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`

// 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 ''
Expand All @@ -12,10 +28,45 @@ function escapeHtml(str) {
.replace(/'/g, '&#39;')
}

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 || ''
Expand All @@ -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
? `<img src="${escapeHtml(avatarUrl)}" alt="" />`
: escapeHtml(getInitials(user))
const given = escapeHtml(user?.given_name || '')
const family = escapeHtml(user?.family_name || '')
if (avatarUrl) {
return { className: '', html: `<img src="${escapeHtml(avatarUrl)}" alt="" />` }
}
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 `
<div class="profile-field-row" data-field="${escapeHtml(key)}">
<div class="profile-field-row-label">${escapeHtml(label)}</div>
<div class="profile-field-row-display">
<span class="profile-field-row-value${!hasValue ? ' placeholder' : ''}">${displayValue}</span>
${editable ? `<button type="button" class="profile-field-edit-btn" data-action="edit" aria-label="Edit ${escapeHtml(label)}">${ICON_PENCIL}</button>` : ''}
</div>
</div>`
}

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 `
<div class="profile-dialog-overlay" id="profile-dialog-overlay">
<div class="profile-dialog" role="dialog" aria-modal="true" aria-label="Manage Profile">
<div class="profile-dialog-header">
<h2>Manage Profile</h2>
<h2>Profile</h2>
<button class="profile-dialog-close" id="profile-dialog-close" aria-label="Close">${ICON_CLOSE}</button>
</div>
<div class="profile-dialog-avatar">${avatarHtml}</div>
<div class="profile-dialog-error" id="profile-dialog-error" hidden></div>
<div class="profile-dialog-field">
<label for="profile-first-name">First name</label>
<input type="text" id="profile-first-name" value="${given}" />
</div>
<div class="profile-dialog-field">
<label for="profile-last-name">Last name</label>
<input type="text" id="profile-last-name" value="${family}" />
</div>
<div class="profile-dialog-field">
<label>Email</label>
<div class="profile-dialog-readonly">${email || '—'}</div>
</div>
<div class="profile-dialog-actions">
<button class="btn-outline" id="profile-dialog-cancel">Cancel</button>
<button class="btn-primary" id="profile-dialog-save">Save</button>
<div class="profile-dialog-body">
<div class="profile-dialog-summary">
<div class="profile-dialog-avatar ${avatar.className}" style="${avatar.style || ''}">${avatar.html}</div>
<div>
<div class="profile-dialog-name">${escapeHtml(displayName)}</div>
${email ? `<div class="profile-dialog-subtitle">${email}</div>` : ''}
</div>
</div>
<div class="profile-dialog-error" id="profile-dialog-error" hidden></div>
<div class="profile-field-list" id="profile-field-list">${rows}</div>
</div>
</div>
</div>`
}

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 = `
<input type="text" class="profile-field-row-input" value="${escapeHtml(currentValue)}" />
<div class="profile-field-row-actions">
<button type="button" class="profile-field-edit-btn" data-action="save" aria-label="Save">${ICON_CHECK}</button>
<button type="button" class="profile-field-edit-btn" data-action="cancel" aria-label="Cancel">${ICON_CANCEL}</button>
</div>`

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])
})
}
13 changes: 10 additions & 3 deletions samples/browser/quickstart/src/main.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading