// @pdsjs/core/account-ui - Server-rendered account pages // Sign-in form and the account sections, styled to match the OAuth consent // page. Every page is plain HTML: no scripts, no requests off this server. import { B64_SCRIPT, escapeHtml } from './html.js'; // Shared with the consent page in oauth.js: same palette, same control sizing, // so a user moving between authorize and account sees one product. const STYLES = ` *{box-sizing:border-box} body{font-family:system-ui,sans-serif;margin:0;background:#09090b;color:#fafafa} a{color:#fafafa} h2{color:#fff;margin:0 0 8px;font-size:22px} h3{color:#fff;margin:28px 0 12px;font-size:16px;font-weight:500} p{color:#a1a1aa;line-height:1.5} code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px} .shell{max-width:480px;margin:0 auto;padding:20px} .topbar{border-bottom:1px solid #18181b} .topbar .bar{display:flex;align-items:center;justify-content:space-between;gap:12px;max-width:840px;margin:0 auto;padding:14px 20px} .topbar .host{color:#fff;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .layout{display:flex;gap:32px;max-width:840px;margin:0 auto;padding:24px 20px} .nav{display:flex;flex-direction:column;gap:2px;width:160px;flex-shrink:0} .nav a{display:block;padding:8px 12px;border-radius:6px;color:#a1a1aa;text-decoration:none;font-size:14px} .nav a:hover{background:#101013;color:#fafafa} .nav a[aria-current="page"]{background:#18181b;color:#fff} .content{flex:1;min-width:0} .subtitle{margin:0 0 24px} .error{color:#f87171;background:#1f1113;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #3f1d20} .success{color:#4ade80;background:#0f1d14;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #1e3a29} .notice{background:#18181b;border:1px solid #2e2e34;border-radius:6px;padding:12px;margin:16px 0;color:#a1a1aa;font-size:13px} .empty{color:#6b6b74;font-size:14px;padding:16px 0} label{display:block;margin:16px 0 6px;color:#a1a1aa;font-size:14px} label.inline{display:flex;align-items:center;gap:8px;margin:12px 0 0;font-size:14px} label.inline input{width:auto;margin:0} input[type="text"],input[type="password"]{width:100%;padding:12px;background:#18181b;border:1px solid #2e2e34;border-radius:6px;color:#fff;font-size:16px} input:focus{outline:none;border-color:#fafafa;box-shadow:0 0 0 2px rgba(59,130,246,0.35)} button{padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s} .primary{width:100%;margin-top:24px;background:#fafafa;color:#18181b;border:none} .primary:hover{background:#e4e4e7} .secondary{background:#18181b;color:#fafafa;border:1px solid #2e2e34;font-size:14px;padding:8px 14px} .secondary:hover{background:#26262b} .danger{background:#1f1113;color:#f87171;border:1px solid #3f1d20;font-size:14px;padding:8px 14px} .danger:hover{background:#2a1517} .summary{display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;margin:8px 0 32px} .summary .avatar{display:flex;align-items:center;justify-content:center;width:80px;height:80px;border-radius:50%;background:#18181b;border:1px solid #2e2e34;color:#6b6b6b;flex-shrink:0} .summary .avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover;background:#18181b} .summary .avatar svg{width:44%;height:44%} .summary .name{color:#fff;font-size:20px;font-weight:500;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .summary .handle{color:#6b6b74;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .summary p{font-size:14px;margin:8px 0 0} .items{border:1px solid #18181b;border-radius:8px;overflow:hidden} .item{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding:12px 16px} .item+.item{border-top:1px solid #18181b} .item .key{color:#6b6b74;font-size:13px;white-space:nowrap} .item .value{color:#fafafa;font-size:14px;text-align:right;word-break:break-all} .rows{border:1px solid #18181b;border-radius:8px;overflow:hidden} .row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:14px 16px} .row+.row{border-top:1px solid #18181b} .row .title{color:#fafafa;font-size:14px;word-break:break-all} .row .meta{color:#6b6b74;font-size:12px;margin-top:4px;word-break:break-all} .row form{flex-shrink:0} .tag{display:inline-block;background:#18181b;border:1px solid #2e2e34;border-radius:4px;padding:1px 6px;margin-left:6px;color:#a1a1aa;font-size:11px;vertical-align:middle} .scopes{display:flex;flex-wrap:wrap;gap:4px;margin:6px 0 4px} .scope{background:#16223a;border:1px solid #274066;border-radius:4px;padding:1px 7px;color:#7dabf8;font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all} .stats{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:8px} .stat{flex:1 1 100px;border:1px solid #18181b;border-radius:8px;padding:12px 14px} .stat .n{color:#fff;font-size:20px;font-weight:500} .stat .l{color:#6b6b74;font-size:12px;margin-top:2px} .reveal{background:#0f1d14;border:1px solid #1e3a29;border-radius:8px;padding:16px;margin:16px 0} .reveal .pw{display:block;margin:10px 0 6px;color:#fff;font-size:20px;letter-spacing:1px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all} .reveal small{color:#86efac} .record{border:1px solid #18181b;border-radius:8px;margin:12px 0;padding:12px 16px;overflow-x:auto} .tree{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:1.6} .tree .kv{display:flex;gap:6px} .tree .kv.block{flex-direction:column;gap:0} .tree .kv.block>.nest{margin-left:2ch} .tree .k{color:#7dabf8;flex-shrink:0} .tree .s{color:#fafafa;word-break:break-word;white-space:pre-wrap} .tree .n{color:#fbbf24} .tree .b{color:#4ade80} .tree .z{color:#6b6b74} .tree .arr{display:flex;gap:6px} .tree .arr::before{content:'-';color:#6b6b74;flex-shrink:0} .tree .arr>.arr-item{flex:1;min-width:0} .tree img.thumb{display:block;max-width:160px;max-height:160px;border-radius:6px;margin:4px 0;border:1px solid #26262b} .back{display:inline-block;margin-bottom:16px;font-size:14px} @media (max-width:640px){ .layout{flex-direction:column;gap:16px;padding:16px} .nav{flex-direction:row;width:auto;overflow-x:auto;gap:4px} .nav a{white-space:nowrap} } `; // The account sections, in sidebar order. `path` doubles as the route key. const SECTIONS = [ { path: '/account', title: 'Home' }, { path: '/account/apps', title: 'Apps' }, { path: '/account/sessions', title: 'Sessions' }, { path: '/account/app-passwords', title: 'App passwords' }, { path: '/account/passkeys', title: 'Passkeys' }, { path: '/account/email', title: 'Email' }, { path: '/account/repo', title: 'Repository' }, { path: '/account/spaces', title: 'Spaces' }, { path: '/account/identity', title: 'Identity' }, ]; /** * Wrap page content in the document shell. * @param {{ title: string, body: string, script?: string }} params * @returns {string} HTML page content */ function renderPage({ title, body, script = '' }) { return ` ${escapeHtml(title)} ${body} ${script} `; } /** * Wrap section content in the signed-in frame: top bar, section nav, content. * @param {{ title: string, hostname: string, current: string, content: string, script?: string }} params * @returns {string} HTML page content */ function renderShell({ title, hostname, current, content, script = '' }) { const nav = SECTIONS.map( (section) => `${escapeHtml(section.title)}`, ).join('\n'); const body = `
${escapeHtml(hostname)}
${content}
`; return renderPage({ title: `${title} · ${hostname}`, body, script }); } /** * Render the account sign-in page. A single-account server knows its one * identity: with `account` set the form names it and asks for the password * alone. The editable identifier field is the fallback for a server that has * no identity yet. * @param {{ hostname: string, account?: string|null, error?: string, passkeys?: boolean }} params * @returns {string} HTML page content */ export function renderSignInPage({ hostname, account = null, error = '', passkeys = false, }) { const identifierField = account ? `

${escapeHtml(account)}

` : ` `; const body = `

Sign in

Sign in to the account hosted by ${escapeHtml(hostname)}.

${error ? `

${escapeHtml(error)}

` : ''} ${passkeys ? '' : ''}
${identifierField}
Verify the website address before entering your password. Only sign in on sites you recognize and trust.
${ passkeys ? `` : '' }
`; const script = passkeys ? `` : ''; return renderPage({ title: 'Sign in', body, script }); } /** * A person glyph for accounts with no profile picture — the same fallback the * reference account manager uses, rather than an empty disc. * @returns {string} HTML string */ function avatarFallback() { return ``; } /** * @param {{ handle: string|null, did: string, displayName?: string|null, avatarUrl?: string|null }} account * @param {string} hostname * @returns {string} HTML string */ function renderSummary(account, hostname) { const { handle, did, displayName = null, avatarUrl = null } = account; return `
${avatarUrl ? `` : avatarFallback()}
${displayName ? `
${escapeHtml(displayName)}
` : ''}
${escapeHtml(handle ? `@${handle}` : did)}

Your Atmosphere account is hosted by ${escapeHtml(hostname)}.

`; } /** * @param {Array<[string, string]>} pairs - Label/value pairs * @returns {string} HTML string */ function renderItems(pairs) { const rows = pairs .map( ([key, value]) => `
${escapeHtml(key)}${escapeHtml(value)}
`, ) .join('\n'); return `
${rows}
`; } /** * @param {string} message * @returns {string} HTML string */ function renderEmpty(message) { return `

${escapeHtml(message)}

`; } /** * Render the account home page. * @param {{ hostname: string, did: string, handle: string|null, status: string, displayName?: string|null, avatarUrl?: string|null, stats: {records: number, blobs: number, apps: number, appPasswords: number} }} params * @returns {string} HTML page content */ export function renderAccountPage({ hostname, did, handle, status, displayName = null, avatarUrl = null, stats, }) { const content = `${renderSummary({ handle, did, displayName, avatarUrl }, hostname)}
${stats.records}
Records
${stats.blobs}
Blobs
${stats.apps}
Apps
${stats.appPasswords}
App passwords

Account

${renderItems([ ['Handle', handle ? `@${handle}` : 'none'], ['DID', did], ['Status', status], ['Server', hostname], ])}`; return renderShell({ title: 'Account', hostname, current: '/account', content, }); } /** * Render the connected apps page. * @param {{ hostname: string, apps: Array<{clientId: string, scope: string, authorizedAt: string|null, keyThumbprint: string|null, sessionId: string|null}>, error?: string, notice?: string }} params * @returns {string} HTML page content */ export function renderAppsPage({ hostname, apps, error = '', notice = '' }) { const rows = apps .map( (app) => `
${escapeHtml(app.clientId)}
${(app.scope || 'atproto') .split(/\s+/) .filter(Boolean) .map((scope) => `${escapeHtml(scope)}`) .join('')}
${[ app.authorizedAt && `Authorized ${app.authorizedAt}`, app.keyThumbprint && `Key ${app.keyThumbprint}`, ] .filter(Boolean) .map((part) => escapeHtml(/** @type {string} */ (part))) .join(' · ')}
${ app.sessionId ? `
` : '' }
`, ) .join('\n'); const content = `

Apps

Applications holding an OAuth session on this account. Revoking stops the session renewing, and its current access token keeps working until it expires, within an hour.

${error ? `

${escapeHtml(error)}

` : ''} ${notice ? `

${escapeHtml(notice)}

` : ''} ${apps.length ? `
${rows}
` : renderEmpty('No app has been authorized yet.')}`; return renderShell({ title: 'Apps', hostname, current: '/account/apps', content, }); } /** * Render the app passwords page. * @param {{ hostname: string, passwords: Array<{name: string, privileged: boolean, createdAt: string}>, created?: {name: string, password: string}|null, error?: string, notice?: string }} params * @returns {string} HTML page content */ export function renderAppPasswordsPage({ hostname, passwords, created = null, error = '', notice = '', }) { const rows = passwords .map( (entry) => `
${escapeHtml(entry.name)}${entry.privileged ? 'privileged' : ''}
Created ${escapeHtml(entry.createdAt)}
`, ) .join('\n'); const content = `

App passwords

Credentials you hand to a client instead of your account password. Sessions opened with one cannot manage app passwords or reach privileged account state.

${error ? `

${escapeHtml(error)}

` : ''} ${notice ? `

${escapeHtml(notice)}

` : ''} ${ created ? `
New app password ${escapeHtml(created.name)}
${escapeHtml(created.password)} Copy it now. Only its hash is stored, so it cannot be shown again.
` : '' } ${passwords.length ? `
${rows}
` : renderEmpty('No app passwords yet.')}

Create an app password

`; return renderShell({ title: 'App passwords', hostname, current: '/account/app-passwords', content, }); } /** * Render the sessions page. * @param {{ hostname: string, sessions: Array<{jti: string, label: string, userAgent: string|null, createdAt: string, refreshedAt: string|null, appPassword: boolean}>, supported: boolean, readOnly: boolean, error?: string, notice?: string }} params * @returns {string} HTML page content */ export function renderSessionsPage({ hostname, sessions, supported, readOnly, error = '', notice = '', }) { const rows = sessions .map( (session) => `
${escapeHtml(session.label)}${session.appPassword ? 'limited' : ''}
Signed in ${escapeHtml(session.createdAt)}${ session.refreshedAt ? ` · renewed ${escapeHtml(session.refreshedAt)}` : '' }${session.userAgent ? `
${escapeHtml(session.userAgent)}` : ''}
${ readOnly ? '' : `
` }
`, ) .join('\n'); const content = `

Sessions

Logins opened with your account password or an app password. Apps signed in through OAuth are under Apps. Revoking stops the session renewing, and its current access token keeps working until it expires, within two hours.

${error ? `

${escapeHtml(error)}

` : ''} ${notice ? `

${escapeHtml(notice)}

` : ''} ${ supported ? `${sessions.length ? `
${rows}
` : renderEmpty('No password logins are open.')}` : '
This server does not keep a session registry, so password logins cannot be listed or revoked here.
' }`; return renderShell({ title: 'Sessions', hostname, current: '/account/sessions', content, }); } /** * Render the passkeys page. * @param {{ hostname: string, passkeys: Array<{credentialId: string, name: string, createdAt: string, lastUsedAt: string|null}>, supported: boolean, readOnly: boolean, challenge: string, rpId: string, accountName: string, did: string, error?: string, notice?: string }} params * @returns {string} HTML page content */ export function renderPasskeysPage({ hostname, passkeys, supported, readOnly, challenge, rpId, accountName, did, error = '', notice = '', }) { const rows = passkeys .map( (passkey) => `
${escapeHtml(passkey.name)}
Added ${escapeHtml(passkey.createdAt.slice(0, 10))} · ${ passkey.lastUsedAt ? `last used ${escapeHtml(passkey.lastUsedAt.slice(0, 10))}` : 'never used' }
${ readOnly ? '' : `
` }
`, ) .join('\n'); const canAdd = supported && !readOnly; const content = `

Passkeys

Sign in to this account with a device unlock instead of the account password.

${error ? `

${escapeHtml(error)}

` : ''} ${notice ? `

${escapeHtml(notice)}

` : ''} ${passkeys.length ? `
${rows}
` : renderEmpty('No passkeys yet.')} ${ canAdd ? `

Add a passkey

` : `
${ supported ? 'This server is running read-only, so a passkey cannot be added here.' : 'This server has no storage for passkeys.' }
` }`; const script = canAdd ? `` : ''; return renderShell({ title: 'Passkeys', hostname, current: '/account/passkeys', content, script, }); } /** * Render the email page. * @param {{ hostname: string, address: string|null, verified: boolean, pending: {address: string}|null, canSend: boolean, error?: string, notice?: string }} params * @returns {string} HTML page content */ export function renderEmailPage({ hostname, address, verified, pending, canSend, error = '', notice = '', }) { const current = address ? renderItems([ ['Address', address], ['State', verified ? 'confirmed' : 'unconfirmed'], ]) : renderEmpty('No email address on this account.'); const content = `

Email

An address for account recovery. This server sends nothing else to it.

${error ? `

${escapeHtml(error)}

` : ''} ${notice ? `

${escapeHtml(notice)}

` : ''} ${current} ${ pending ? `

Confirm ${escapeHtml(pending.address)}

Enter the six-digit code sent to that address. It expires in 15 minutes.

` : `

${address ? 'Change the address' : 'Add an address'}

${ canSend ? '' : '
No mail sender is configured, so an address saved here stays unconfirmed. Set one to prove it is reachable.
' }
${ address ? `
` : '' }` }`; return renderShell({ title: 'Email', hostname, current: '/account/email', content, }); } /** * Render the repository page. * @param {{ hostname: string, collections: Array<{name: string, count: number}>, blobs: {count: number, truncated: boolean}, commit: {rev: string, cid: string, seq: number}|null }} params * @returns {string} HTML page content */ export function renderRepoPage({ hostname, collections, blobs, commit }) { const rows = collections .map( (collection) => `
${collection.count} record${collection.count === 1 ? '' : 's'}
`, ) .join('\n'); const content = `

Repository

What this server holds for the account. These records are public, served to anyone through com.atproto.repo and com.atproto.sync.

${collections.length ? `
${rows}
` : renderEmpty('This repository has no records yet.')}

Storage

${renderItems([['Blobs', `${blobs.count}${blobs.truncated ? '+' : ''}`]])}

Latest commit

${ commit ? renderItems([ ['Revision', commit.rev], ['CID', commit.cid], ['Sequence', String(commit.seq)], ]) : renderEmpty('Nothing has been committed yet.') }`; return renderShell({ title: 'Repository', hostname, current: '/account/repo', content, }); } /** * Render the spaces page (permissioned data, proposal 0016). * @param {{ hostname: string, spaces: Array<{uri: string, spaceType: string, authority: string, isOwner: boolean, policy: string|null, createdAt: string, deleted: boolean, members: number, collections: Array<{collection: string, count: number}>}>, enabled: boolean }} params * @returns {string} HTML page content */ export function renderSpacesPage({ hostname, spaces, enabled }) { const rows = spaces .map((space) => { const records = space.collections .map( (c) => `${escapeHtml(c.collection)} (${c.count})`, ) .join(', '); return `
${escapeHtml(space.spaceType)}${space.isOwner ? 'yours' : ''}${space.deleted ? 'deleted' : ''}
${escapeHtml(space.uri)}
Authority ${escapeHtml(space.authority)}${space.policy ? ` · ${escapeHtml(space.policy)}` : ''} · created ${escapeHtml(space.createdAt)}
${space.members} member${space.members === 1 ? '' : 's'}${records ? ` · ${records}` : ' · no records'}
`; }) .join('\n'); const content = `

Spaces

Private repos shared through spaces, an implementation of proposal 0016. The record counts are what this account wrote. Other members' records live on their own servers.

${ enabled ? `${spaces.length ? `
${rows}
` : renderEmpty('This account is in no spaces.')}` : '
Permissioned data is off on this server. Enable it (PDS_ENABLE_SPACES=true) to see spaces here.
' }`; return renderShell({ title: 'Spaces', hostname, current: '/account/spaces', content, }); } /** * Render one value as an indented key/value tree, the way atproto record * browsers conventionally display records: keys and nesting carry the * structure, with none of JSON's brace and quote noise. * @param {unknown} value * @param {(cid: string) => string|null} blobUrl - URL for a blob this server * can serve, or null for one it cannot * @returns {string} HTML string */ function renderValueTree(value, blobUrl) { if (value === null || value === undefined) return 'null'; if (typeof value === 'string') { // An empty string shown as nothing reads as a rendering fault return value === '' ? '""' : `${escapeHtml(value)}`; } if (typeof value === 'number') return `${String(value)}`; if (typeof value === 'boolean') return `${String(value)}`; if (Array.isArray(value)) { // The wrapper keeps an object item's rows stacking: without it they // become siblings of the dash in the flex row and lay out side by side return value .map( (item) => `
${renderValueTree(item, blobUrl)}
`, ) .join(''); } const entries = Object.entries( /** @type {Record} */ (value), ); const rows = entries .map(([key, inner]) => { const nested = inner !== null && typeof inner === 'object'; return nested ? `
${escapeHtml(key)}:
${renderValueTree(inner, blobUrl)}
` : `
${escapeHtml(key)}:${renderValueTree(inner, blobUrl)}
`; }) .join(''); // A blob ref gets its thumbnail inline, served from this PDS's own blob // store, with the ref's fields still shown beneath it. const blob = /** @type {{$type?: string, ref?: {$link?: string}, mimeType?: string}} */ ( value ); if ( blob.$type === 'blob' && typeof blob.ref?.$link === 'string' && blob.mimeType?.startsWith('image/') ) { const src = blobUrl(blob.ref.$link); if (src) return `${rows}`; } return rows; } /** * Render the records of one collection, from the repo or from a space. * * Each card is the record in the shape getRecord answers with: uri, cid, * value. The labels are the protocol\'s own field names, so nothing here * needs a vocabulary of its own. * @param {{ hostname: string, section: string, back: {href: string, label: string}, collection: string, subtitles: string[], records: Array<{uri: string, cid: string, indexedAt?: string|null, value: unknown}>, blobUrl?: (cid: string) => string|null, truncated: boolean, error?: string }} params * @returns {string} HTML page content */ export function renderRecordsPage({ hostname, section, back, collection, subtitles, records, blobUrl = () => null, truncated, error = '', }) { const cards = records .map( (record) => `
uri:${escapeHtml(record.uri)}
cid:${escapeHtml(record.cid)}
${record.indexedAt ? `
indexedAt:${escapeHtml(record.indexedAt)}
` : ''}
value:
${renderValueTree(record.value, blobUrl)}
`, ) .join('\n'); const content = `← ${escapeHtml(back.label)}

${escapeHtml(collection)}

${subtitles.map((line) => `

${escapeHtml(line)}

`).join('\n')} ${truncated ? `

Showing the first ${records.length}.

` : ''} ${error ? `

${escapeHtml(error)}

` : ''} ${records.length ? cards : error ? '' : renderEmpty('No records in this collection.')}`; return renderShell({ title: collection, hostname, current: section, content, }); } /** * Render the identity page. * @param {{ hostname: string, did: string, handle: string|null, status: string, signingKey: string|null, pdsEndpoint: string, plcUrl: string, readOnly: boolean, error?: string }} params * @returns {string} HTML page content */ export function renderIdentityPage({ hostname, did, handle, status, signingKey, pdsEndpoint, plcUrl, readOnly, error = '', }) { const deactivated = status === 'deactivated'; const content = `

Identity

What this server publishes about the account, and the credentials behind it.

${error ? `

${escapeHtml(error)}

` : ''} ${renderItems([ ['DID', did], ['Handle', handle ? `@${handle}` : 'none'], ['PDS endpoint', pdsEndpoint], ['Directory', plcUrl], ['Signing key', signingKey || 'none'], ])}

Status

${deactivated ? 'Deactivated' : 'Active'}
${ deactivated ? 'Repo writes are refused and the account reports as inactive to the network.' : 'The account accepts writes and reports as active to the network.' }
${ readOnly ? '' : `
` }
${readOnly ? '
This server is running read-only, so account state cannot be changed here.
' : ''}`; return renderShell({ title: 'Identity', hostname, current: '/account/identity', content, }); }