chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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 `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>${STYLES}</style></head>
<body>
${body}
${script}
</body></html>`;
}
/**
* 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) =>
`<a href="${section.path}"${section.path === current ? ' aria-current="page"' : ''}>${escapeHtml(section.title)}</a>`,
).join('\n');
const body = `<div class="topbar"><div class="bar">
<div class="host">${escapeHtml(hostname)}</div>
<form method="POST" action="/account/sign-out"><button type="submit" class="secondary">Sign out</button></form>
</div></div>
<div class="layout">
<nav class="nav">${nav}</nav>
<div class="content">${content}</div>
</div>`;
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
? `<label>Account</label>
<p class="subtitle"><b>${escapeHtml(account)}</b></p>
<input type="hidden" name="username" value="${escapeHtml(account)}">`
: `<label for="username">Identifier</label>
<input type="text" id="username" name="username" value=""
placeholder="handle or DID" autocapitalize="none" autocorrect="off" spellcheck="false"
autocomplete="username" required autofocus>`;
const body = `<div class="shell">
<h2>Sign in</h2>
<p class="subtitle">Sign in to the account hosted by <b>${escapeHtml(hostname)}</b>.</p>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
${passkeys ? '<p class="error" id="passkey-error" hidden></p>' : ''}
<form method="POST" action="/account/sign-in">
${identifierField}
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required${account ? ' autofocus' : ''}>
<div class="notice">Verify the website address before entering your password. Only sign in on sites you recognize and trust.</div>
<button type="submit" class="primary">Sign in</button>
</form>
${
passkeys
? `<button type="button" class="secondary" id="passkey-signin"
style="width:100%;margin-top:12px;padding:12px">Use a passkey</button>`
: ''
}
</div>`;
const script = passkeys
? `<script>
${B64_SCRIPT}
const err = document.getElementById('passkey-error');
const show = (m) => { err.textContent = m; err.hidden = false; };
document.getElementById('passkey-signin').addEventListener('click', async () => {
err.hidden = true;
if (!window.PublicKeyCredential) return show('This browser has no passkey support.');
try {
const start = await fetch('/account/passkey/challenge');
const { challenge, rpId, error } = await start.json();
if (!start.ok) return show(error || 'Could not start a passkey sign-in.');
const assertion = await navigator.credentials.get({ publicKey: {
challenge: unb64(challenge),
rpId,
userVerification: 'preferred',
timeout: 120000,
}});
const response = await fetch('/account/passkey/sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialId: b64(assertion.rawId),
authenticatorData: b64(assertion.response.authenticatorData),
clientDataJSON: b64(assertion.response.clientDataJSON),
signature: b64(assertion.response.signature),
}),
});
const result = await response.json();
if (!response.ok) return show(result.error || 'That passkey was refused.');
location.href = '/account';
} catch (e) {
show(e && e.name === 'NotAllowedError' ? 'Cancelled.' : String(e && e.message || e));
}
});
</script>`
: '';
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 `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
}
/**
* @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 `<div class="summary">
<div class="avatar">${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="">` : avatarFallback()}</div>
${displayName ? `<div class="name">${escapeHtml(displayName)}</div>` : ''}
<div class="handle">${escapeHtml(handle ? `@${handle}` : did)}</div>
<p>Your Atmosphere account is hosted by <b>${escapeHtml(hostname)}</b>.</p>
</div>`;
}
/**
* @param {Array<[string, string]>} pairs - Label/value pairs
* @returns {string} HTML string
*/
function renderItems(pairs) {
const rows = pairs
.map(
([key, value]) =>
`<div class="item"><span class="key">${escapeHtml(key)}</span><span class="value">${escapeHtml(value)}</span></div>`,
)
.join('\n');
return `<div class="items">${rows}</div>`;
}
/**
* @param {string} message
* @returns {string} HTML string
*/
function renderEmpty(message) {
return `<p class="empty">${escapeHtml(message)}</p>`;
}
/**
* 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)}
<div class="stats">
<div class="stat"><div class="n">${stats.records}</div><div class="l">Records</div></div>
<div class="stat"><div class="n">${stats.blobs}</div><div class="l">Blobs</div></div>
<div class="stat"><div class="n">${stats.apps}</div><div class="l">Apps</div></div>
<div class="stat"><div class="n">${stats.appPasswords}</div><div class="l">App passwords</div></div>
</div>
<h3>Account</h3>
${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) => `<div class="row"><div>
<div class="title">${escapeHtml(app.clientId)}</div>
<div class="scopes">${(app.scope || 'atproto')
.split(/\s+/)
.filter(Boolean)
.map((scope) => `<span class="scope">${escapeHtml(scope)}</span>`)
.join('')}</div>
<div class="meta">${[
app.authorizedAt && `Authorized ${app.authorizedAt}`,
app.keyThumbprint && `Key ${app.keyThumbprint}`,
]
.filter(Boolean)
.map((part) => escapeHtml(/** @type {string} */ (part)))
.join(' · ')}</div>
</div>
${
app.sessionId
? `<form method="POST" action="/account/apps/revoke">
<input type="hidden" name="session" value="${escapeHtml(app.sessionId)}">
<button type="submit" class="danger">Revoke</button>
</form>`
: ''
}</div>`,
)
.join('\n');
const content = `<h2>Apps</h2>
<p class="subtitle">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.</p>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
${notice ? `<p class="success">${escapeHtml(notice)}</p>` : ''}
${apps.length ? `<div class="rows">${rows}</div>` : 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) => `<div class="row"><div>
<div class="title">${escapeHtml(entry.name)}${entry.privileged ? '<span class="tag">privileged</span>' : ''}</div>
<div class="meta">Created ${escapeHtml(entry.createdAt)}</div>
</div>
<form method="POST" action="/account/app-passwords/revoke">
<input type="hidden" name="name" value="${escapeHtml(entry.name)}">
<button type="submit" class="danger">Revoke</button>
</form></div>`,
)
.join('\n');
const content = `<h2>App passwords</h2>
<p class="subtitle">Credentials you hand to a client instead of your account password. Sessions opened with one cannot manage app passwords or reach privileged account state.</p>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
${notice ? `<p class="success">${escapeHtml(notice)}</p>` : ''}
${
created
? `<div class="reveal">
<div>New app password <b>${escapeHtml(created.name)}</b></div>
<code class="pw">${escapeHtml(created.password)}</code>
<small>Copy it now. Only its hash is stored, so it cannot be shown again.</small>
</div>`
: ''
}
${passwords.length ? `<div class="rows">${rows}</div>` : renderEmpty('No app passwords yet.')}
<h3>Create an app password</h3>
<form method="POST" action="/account/app-passwords/create">
<label for="name">Name</label>
<input type="text" id="name" name="name" placeholder="e.g. phone client" required maxlength="64">
<label class="inline"><input type="checkbox" name="privileged" value="true"> Grant privileged access</label>
<button type="submit" class="primary">Create</button>
</form>`;
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) => `<div class="row"><div>
<div class="title">${escapeHtml(session.label)}${session.appPassword ? '<span class="tag">limited</span>' : ''}</div>
<div class="meta">Signed in ${escapeHtml(session.createdAt)}${
session.refreshedAt
? ` · renewed ${escapeHtml(session.refreshedAt)}`
: ''
}${session.userAgent ? `<br>${escapeHtml(session.userAgent)}` : ''}</div>
</div>
${
readOnly
? ''
: `<form method="POST" action="/account/sessions/revoke">
<input type="hidden" name="jti" value="${escapeHtml(session.jti)}">
<button type="submit" class="danger">Revoke</button>
</form>`
}</div>`,
)
.join('\n');
const content = `<h2>Sessions</h2>
<p class="subtitle">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.</p>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
${notice ? `<p class="success">${escapeHtml(notice)}</p>` : ''}
${
supported
? `${sessions.length ? `<div class="rows">${rows}</div>` : renderEmpty('No password logins are open.')}`
: '<div class="notice">This server does not keep a session registry, so password logins cannot be listed or revoked here.</div>'
}`;
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) => `<div class="row"><div>
<div class="title">${escapeHtml(passkey.name)}</div>
<div class="meta">Added ${escapeHtml(passkey.createdAt.slice(0, 10))} · ${
passkey.lastUsedAt
? `last used ${escapeHtml(passkey.lastUsedAt.slice(0, 10))}`
: 'never used'
}</div>
</div>
${
readOnly
? ''
: `<form method="POST" action="/account/passkeys/revoke">
<input type="hidden" name="credentialId" value="${escapeHtml(passkey.credentialId)}">
<button type="submit" class="danger">Remove</button>
</form>`
}</div>`,
)
.join('\n');
const canAdd = supported && !readOnly;
const content = `<h2>Passkeys</h2>
<p class="subtitle">Sign in to this account with a device unlock instead of the account password.</p>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
${notice ? `<p class="success">${escapeHtml(notice)}</p>` : ''}
<p class="error" id="passkey-error" hidden></p>
${passkeys.length ? `<div class="rows">${rows}</div>` : renderEmpty('No passkeys yet.')}
${
canAdd
? `<h3>Add a passkey</h3>
<label for="passkey-name">Name</label>
<input type="text" id="passkey-name" placeholder="e.g. laptop" maxlength="64">
<button type="button" class="primary" id="passkey-add">Add a passkey</button>`
: `<div class="notice">${
supported
? 'This server is running read-only, so a passkey cannot be added here.'
: 'This server has no storage for passkeys.'
}</div>`
}`;
const script = canAdd
? `<script>
${B64_SCRIPT}
const err = document.getElementById('passkey-error');
const show = (m) => { err.textContent = m; err.hidden = false; };
document.getElementById('passkey-add').addEventListener('click', async () => {
err.hidden = true;
if (!window.PublicKeyCredential) return show('This browser has no passkey support.');
try {
const credential = await navigator.credentials.create({ publicKey: {
challenge: unb64(${JSON.stringify(challenge)}),
rp: { id: ${JSON.stringify(rpId)}, name: ${JSON.stringify(hostname)} },
user: {
id: new TextEncoder().encode(${JSON.stringify(did)}),
name: ${JSON.stringify(accountName)},
displayName: ${JSON.stringify(accountName)},
},
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
excludeCredentials: ${JSON.stringify(
passkeys.map((p) => ({ id: p.credentialId })),
)}.map((c) => ({ type: 'public-key', id: unb64(c.id) })),
timeout: 120000,
}});
const response = await fetch('/account/passkeys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('passkey-name').value,
credentialId: b64(credential.rawId),
publicKey: b64(credential.response.getPublicKey()),
algorithm: credential.response.getPublicKeyAlgorithm(),
clientDataJSON: b64(credential.response.clientDataJSON),
}),
});
const result = await response.json();
if (!response.ok) return show(result.error || 'Could not add that passkey.');
location.reload();
} catch (e) {
show(e && e.name === 'NotAllowedError' ? 'Cancelled.' : String(e && e.message || e));
}
});
</script>`
: '';
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 = `<h2>Email</h2>
<p class="subtitle">An address for account recovery. This server sends nothing else to it.</p>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
${notice ? `<p class="success">${escapeHtml(notice)}</p>` : ''}
${current}
${
pending
? `<h3>Confirm ${escapeHtml(pending.address)}</h3>
<p>Enter the six-digit code sent to that address. It expires in 15 minutes.</p>
<form method="POST" action="/account/email/verify">
<label for="code">Code</label>
<input type="text" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}"
autocomplete="one-time-code" required autofocus>
<button type="submit" class="primary">Confirm</button>
</form>
<form method="POST" action="/account/email/cancel" style="margin-top:12px">
<button type="submit" class="secondary">Cancel this change</button>
</form>`
: `<h3>${address ? 'Change the address' : 'Add an address'}</h3>
${
canSend
? ''
: '<div class="notice">No mail sender is configured, so an address saved here stays unconfirmed. Set one to prove it is reachable.</div>'
}
<form method="POST" action="/account/email">
<label for="address">Email address</label>
<input type="text" id="address" name="address" inputmode="email"
autocapitalize="none" autocorrect="off" spellcheck="false" autocomplete="email" required>
<button type="submit" class="primary">${canSend ? 'Send a code' : 'Save'}</button>
</form>
${
address
? `<form method="POST" action="/account/email/remove" style="margin-top:12px">
<button type="submit" class="danger">Remove address</button>
</form>`
: ''
}`
}`;
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) => `<div class="row"><div>
<div class="title"><a href="/account/repo/records?collection=${encodeURIComponent(collection.name)}">${escapeHtml(collection.name)}</a></div>
</div><div class="meta">${collection.count} record${collection.count === 1 ? '' : 's'}</div></div>`,
)
.join('\n');
const content = `<h2>Repository</h2>
<p class="subtitle">What this server holds for the account. These records are public, served to anyone through com.atproto.repo and com.atproto.sync.</p>
${collections.length ? `<div class="rows">${rows}</div>` : renderEmpty('This repository has no records yet.')}
<h3>Storage</h3>
${renderItems([['Blobs', `${blobs.count}${blobs.truncated ? '+' : ''}`]])}
<h3>Latest commit</h3>
${
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) =>
`<a href="/account/spaces/records?space=${encodeURIComponent(space.uri)}&collection=${encodeURIComponent(c.collection)}">${escapeHtml(c.collection)} (${c.count})</a>`,
)
.join(', ');
return `<div class="row"><div>
<div class="title">${escapeHtml(space.spaceType)}${space.isOwner ? '<span class="tag">yours</span>' : ''}${space.deleted ? '<span class="tag">deleted</span>' : ''}</div>
<div class="meta">${escapeHtml(space.uri)}<br>
Authority ${escapeHtml(space.authority)}${space.policy ? ` · ${escapeHtml(space.policy)}` : ''} · created ${escapeHtml(space.createdAt)}<br>
${space.members} member${space.members === 1 ? '' : 's'}${records ? ` · ${records}` : ' · no records'}</div>
</div></div>`;
})
.join('\n');
const content = `<h2>Spaces</h2>
<p class="subtitle">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.</p>
${
enabled
? `${spaces.length ? `<div class="rows">${rows}</div>` : renderEmpty('This account is in no spaces.')}`
: '<div class="notice">Permissioned data is off on this server. Enable it (PDS_ENABLE_SPACES=true) to see spaces here.</div>'
}`;
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 '<span class="z">null</span>';
if (typeof value === 'string') {
// An empty string shown as nothing reads as a rendering fault
return value === ''
? '<span class="z">""</span>'
: `<span class="s">${escapeHtml(value)}</span>`;
}
if (typeof value === 'number')
return `<span class="n">${String(value)}</span>`;
if (typeof value === 'boolean')
return `<span class="b">${String(value)}</span>`;
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) =>
`<div class="arr"><div class="arr-item">${renderValueTree(item, blobUrl)}</div></div>`,
)
.join('');
}
const entries = Object.entries(
/** @type {Record<string, unknown>} */ (value),
);
const rows = entries
.map(([key, inner]) => {
const nested = inner !== null && typeof inner === 'object';
return nested
? `<div class="kv block"><span class="k">${escapeHtml(key)}:</span><div class="nest">${renderValueTree(inner, blobUrl)}</div></div>`
: `<div class="kv"><span class="k">${escapeHtml(key)}:</span>${renderValueTree(inner, blobUrl)}</div>`;
})
.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 `<img class="thumb" src="${escapeHtml(src)}" alt="">${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) => `<div class="record"><div class="tree">
<div class="kv"><span class="k">uri:</span><span class="s">${escapeHtml(record.uri)}</span></div>
<div class="kv"><span class="k">cid:</span><span class="s">${escapeHtml(record.cid)}</span></div>
${record.indexedAt ? `<div class="kv"><span class="k">indexedAt:</span><span class="s">${escapeHtml(record.indexedAt)}</span></div>` : ''}
<div class="kv block"><span class="k">value:</span><div class="nest">${renderValueTree(record.value, blobUrl)}</div></div>
</div></div>`,
)
.join('\n');
const content = `<a class="back" href="${escapeHtml(back.href)}">← ${escapeHtml(back.label)}</a>
<h2>${escapeHtml(collection)}</h2>
${subtitles.map((line) => `<p class="subtitle">${escapeHtml(line)}</p>`).join('\n')}
${truncated ? `<p class="subtitle">Showing the first ${records.length}.</p>` : ''}
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
${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 = `<h2>Identity</h2>
<p class="subtitle">What this server publishes about the account, and the credentials behind it.</p>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
${renderItems([
['DID', did],
['Handle', handle ? `@${handle}` : 'none'],
['PDS endpoint', pdsEndpoint],
['Directory', plcUrl],
['Signing key', signingKey || 'none'],
])}
<h3>Status</h3>
<div class="rows"><div class="row"><div>
<div class="title">${deactivated ? 'Deactivated' : 'Active'}</div>
<div class="meta">${
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.'
}</div>
</div>
${
readOnly
? ''
: `<form method="POST" action="/account/status">
<input type="hidden" name="action" value="${deactivated ? 'activate' : 'deactivate'}">
<button type="submit" class="${deactivated ? 'secondary' : 'danger'}">${deactivated ? 'Activate' : 'Deactivate'}</button>
</form>`
}
</div></div>
${readOnly ? '<div class="notice">This server is running read-only, so account state cannot be changed here.</div>' : ''}`;
return renderShell({
title: 'Identity',
hostname,
current: '/account/identity',
content,
});
}