From d1ea5d73363f45e63a152239c3b092334d80740a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 10:53:51 +0000 Subject: [PATCH 1/2] feat(profile): signup profile fields via @profullstack/profile "New to readm3? Create an account" on /account adds first name, last name, company domain, job title and LinkedIn URL to the email form (@profullstack/profile 0.1.3, rendered at build time; the company-from-work-email helper is served as a file for the strict CSP). /api/auth/email validates them before any link is sent and keeps them on the email challenge; verify saves them to user_profiles once the account exists, and a failed save never blocks sign-in. Plain sign-in and CoinPay never ask for them. GET/PUT/PATCH /api/v1/me/profile serves the package handlers for the session user, and the account panel edits the same fields. user_profiles is created by migrate() when the database opens; display names typed as "First Last" are backfilled with fillBlanks (handles and email local parts are skipped). Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 ++ package.json | 1 + server/accounts.ts | 39 ++++++++++++++----- server/api.ts | 17 +++++++++ server/profile.ts | 45 ++++++++++++++++++++++ server/test/profile.test.ts | 74 +++++++++++++++++++++++++++++++++++++ site/account.ts | 42 ++++++++++++++++++++- site/assets/account.css | 1 + site/assets/account.html | 8 ++++ site/build.ts | 11 +++++- 10 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 server/profile.ts create mode 100644 server/test/profile.test.ts diff --git a/bun.lock b/bun.lock index 102dadf..33d1bfe 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@profullstack/cli": "^0.1.0", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.0", + "@profullstack/profile": "0.1.3", "@profullstack/synconfig": "^0.1.3", "@profullstack/x402-gateway": "^0.11.0", "highlight.js": "^11.12.0", @@ -154,6 +155,8 @@ "@profullstack/keys": ["@profullstack/keys@0.1.0", "", {}, "sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w=="], + "@profullstack/profile": ["@profullstack/profile@0.1.3", "", { "peerDependencies": { "@libsql/client": ">=0.6.0", "postgres": "^3.4.0", "react": ">=18" }, "optionalPeers": ["@libsql/client", "postgres", "react"], "bin": { "profile": "src/cli.js" } }, "sha512-P+VdOqCnNGWa5W8EGLU/ls3hwovRcY8bOBOhWZp3h23sW2LDMFyeWF/pUfO+iiYH3E2Tie2/9xyUnpO4ctwSHw=="], + "@profullstack/synconfig": ["@profullstack/synconfig@0.1.3", "", {}, "sha512-Ty/90ibgu2DVlablDeoFdItcobK+c8cMQFvuTfJOUlCfyvQZWoPjgt0JHPwTvq+ynOQ4i1ivBQ3K3PY/S4rObA=="], "@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.11.0", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-Lfrz+SRTwQZfcoCaVtaaMNPJx74Lp/5oyS7vJJAEuJmH1TjBzhE7dWquISkj1p42oc+VW5I9xH9p7YhEKqmr4Q=="], diff --git a/package.json b/package.json index 304d480..c76799c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@profullstack/cli": "^0.1.0", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.0", + "@profullstack/profile": "0.1.3", "@profullstack/synconfig": "^0.1.3", "@profullstack/x402-gateway": "^0.11.0", "highlight.js": "^11.12.0", diff --git a/server/accounts.ts b/server/accounts.ts index 6544b8c..0600ec8 100644 --- a/server/accounts.ts +++ b/server/accounts.ts @@ -2,6 +2,8 @@ import { Database } from "bun:sqlite"; import { createHash, randomBytes } from "node:crypto"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; +import { saveSignupProfile, validateProfile } from "@profullstack/profile"; +import { profiles } from "./profile.ts"; const secret = () => randomBytes(32).toString("base64url"); const id = () => randomBytes(12).toString("base64url"); @@ -13,7 +15,7 @@ export type Account = { id: string; username: string; displayName: string; admin export type Mail = { to: string; url: string }; export type SendMail = (mail: Mail) => Promise; export class AccountError extends Error { - constructor(public status: number, message: string) { super(message); } + constructor(public status: number, message: string, public errors?: Record) { super(message); } } const publicColumns = "u.id,u.username,u.displayName,u.admin,u.createdAt,e.email,e.emailVerifiedAt"; /** @@ -67,6 +69,11 @@ export class Accounts { ${IDENTITY_SCHEMA} ${OAUTH_SCHEMA} `); + // The signup form's profile fields ride on the emailed link until it is spent. + const challengeColumns = this.db.query("PRAGMA table_info(email_challenges)").all() as { name: string }[]; + if (!challengeColumns.some(column => column.name === "profile")) + this.db.exec("ALTER TABLE email_challenges ADD COLUMN profile TEXT"); + profiles(this.db); } private rate(key: string, limit: number, seconds: number) { const current = Date.now(); @@ -105,22 +112,29 @@ export class Accounts { } private challenge(token: unknown) { if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new AccountError(400, "This sign-in link is invalid or has expired. Request a new link."); - const row = this.db.query("SELECT email FROM email_challenges WHERE tokenHash=? AND expiresAt>?").get(hash(token), stamp()) as { email: string } | null; + const row = this.db.query("SELECT email, profile FROM email_challenges WHERE tokenHash=? AND expiresAt>?").get(hash(token), stamp()) as { email: string; profile: string | null } | null; if (!row) throw new AccountError(400, "This sign-in link is invalid or has expired. Request a new link."); return row; } - private async requestLink(input: unknown, ip: string, requestedNext?: unknown) { + private async requestLink(input: unknown, ip: string, requestedNext?: unknown, signup?: Record) { const next = typeof requestedNext === "string" && SAFE_NEXT.test(requestedNext) ? requestedNext : null; this.rate(`send-ip:${ip}`, 20, 3600); const email = typeof input === "string" ? input.trim().toLowerCase() : ""; if (email.length > 254 || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i.test(email) || email.split("@")[0].length > 64) { throw new AccountError(400, "Enter a valid email address."); } + // Creating an account asks for the shared profile fields; a plain sign-in never does. + let profile: string | null = null; + if (signup) { + const check = validateProfile({ ...signup, email }); + if (!check.ok) throw new AccountError(400, Object.values(check.errors)[0] ?? "Some fields need fixing.", check.errors); + profile = JSON.stringify(check.profile); + } this.rate(`send-email-minute:${email}`, 1, 60); this.rate(`send-email-hour:${email}`, 5, 3600); const token = secret(); this.db.query("DELETE FROM email_challenges WHERE expiresAt<=?").run(stamp()); - this.db.query("INSERT INTO email_challenges VALUES (?,?,?,?)").run(hash(token), email, new Date(Date.now() + 15 * 60000).toISOString(), stamp()); + this.db.query("INSERT INTO email_challenges (tokenHash,email,expiresAt,createdAt,profile) VALUES (?,?,?,?,?)").run(hash(token), email, new Date(Date.now() + 15 * 60000).toISOString(), stamp(), profile); try { await this.sendMail({ to: email, url: `${this.origin}/account${next ? "?next=" + encodeURIComponent(next) : ""}#verify=${token}` }); } catch { this.db.query("DELETE FROM email_challenges WHERE tokenHash=?").run(hash(token)); @@ -130,7 +144,7 @@ export class Accounts { } private verify(token: unknown, oldToken: string) { return this.db.transaction(() => { - const { email } = this.challenge(token); + const { email, profile } = this.challenge(token); let identity = this.db.query("SELECT userId FROM account_emails WHERE email=?").get(email) as { userId: string } | null; if (!identity) { const userId = id(); @@ -142,9 +156,15 @@ export class Accounts { } this.db.query("DELETE FROM email_challenges WHERE email=?").run(email); const session = this.startSession(identity.userId, oldToken, "Email sign-in"); - return { user: this.authenticate(session)!, session }; + return { user: this.authenticate(session)!, session, profile }; })(); } + /** Save the fields typed on the signup form. A failure never costs anybody their sign-in: the account page asks again. */ + private async saveProfile(userId: string, email: string | null, profile: string | null) { + if (!profile) return; + try { await saveSignupProfile(await profiles(this.db).ready, userId, JSON.parse(profile), { email }); } + catch (error) { console.error("Signup profile save failed", error instanceof Error ? error.message : error); } + } async handle(request: Request, ip = "unknown"): Promise { const path = new URL(request.url).pathname; if (!path.startsWith("/api/auth/")) return null; @@ -162,11 +182,12 @@ export class Accounts { let args: Record; try { args = JSON.parse(body); if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error(); } catch { throw new AccountError(400, "Invalid request."); } - if (path === "/api/auth/email") return reply(await this.requestLink(args.email, ip, args.next)); + if (path === "/api/auth/email") return reply(await this.requestLink(args.email, ip, args.next, args.mode === "signup" ? args : undefined)); if (path === "/api/auth/preview" || path === "/api/auth/verify") { this.rate(`verify:${ip}`, 60, 60); - if (path.endsWith("preview")) return reply(this.challenge(args.token)); + if (path.endsWith("preview")) return reply({ email: this.challenge(args.token).email }); const result = this.verify(args.token, this.token(request)); + await this.saveProfile(result.user.id, result.user.email, result.profile); return reply({ user: result.user }, 200, this.cookie(result.session)); } if (path === "/api/auth/logout") { @@ -187,7 +208,7 @@ export class Accounts { } return reply({ error: "Not found." }, 404); } catch (error) { - if (error instanceof AccountError) return reply({ error: error.message }, error.status); + if (error instanceof AccountError) return reply({ error: error.message, ...(error.errors ? { errors: error.errors } : {}) }, error.status); console.error("Account request failed", error instanceof Error ? error.name : "Unknown error"); return reply({ error: "Something went wrong. Please try again." }, 500); } diff --git a/server/api.ts b/server/api.ts index 032e5bf..56cfb2d 100644 --- a/server/api.ts +++ b/server/api.ts @@ -1,3 +1,5 @@ +import { createProfileHandlers } from "@profullstack/profile"; +import { profiles } from "./profile.ts"; import { Store, HttpError, checksum, now, text, type User } from "./store.ts"; import { type CoinPay, isCoinPayToken } from "./coinpay.ts"; import { operate } from "./operations.ts"; @@ -157,6 +159,21 @@ export function createApi( if (user) store.ensureWorkspace(user); const path = url.pathname.slice(8); if (path === "me" && request.method === "GET") return json({ user }); + // The shared @profullstack/profile fields; private to the signed-in account. + if (path === "me/profile") { + const method = request.method as "GET" | "PUT" | "PATCH"; + if (!["GET", "PUT", "PATCH"].includes(method)) throw new HttpError(405, "Method not allowed."); + if (method !== "GET" && request.headers.has("cookie") && !request.headers.has("authorization") && !sentOrigin) + throw new HttpError(403, "Browser writes require a same-origin Origin header."); + const handlers = createProfileHandlers({ + store: await profiles(store.db).ready, + getUser: () => user && { + id: user.id, + email: store.get<{ email: string }>("SELECT email FROM account_emails WHERE userId=?", user.id)?.email ?? null, + }, + }); + return handlers[method](request); + } // CoinPay's webhook: no session, the signature over the raw body is the credential. if (path === "billing/coinpay/webhook" && request.method === "POST") { if (!money) throw new HttpError(404, "API route not found."); diff --git a/server/profile.ts b/server/profile.ts new file mode 100644 index 0000000..42f7a4f --- /dev/null +++ b/server/profile.ts @@ -0,0 +1,45 @@ +/** + * The shared Profullstack profile (@profullstack/profile): email, first and last + * name, company domain, job title and LinkedIn URL, in the package's own + * `user_profiles` table keyed by users.id. Private to the account: nothing here is + * shown to other people. One store per database, created and migrated on first use + * the way the rest of the schema is. + */ +import type { Database } from "bun:sqlite"; +import { createProfileStore, migrate, splitFullName, type ProfileStore } from "@profullstack/profile"; + +const stores = new WeakMap }>(); + +/** The profile store for this database; `ready` resolves once its table exists. */ +export function profiles(db: Database) { + let entry = stores.get(db); + if (!entry) { + const store = createProfileStore(db, { keyType: "text" }); + const ready = migrate(store).then(async () => { + await backfill(db, store); + return store; + }); + ready.catch(error => console.error("Profile migration failed", error instanceof Error ? error.message : error)); + entry = { store, ready }; + stores.set(db, entry); + } + return entry; +} + +/** + * One-time and idempotent: a display name someone typed as "First Last" becomes the + * profile's names. A single word, or the email's local part (the default name of an + * email signup), is a handle, not a name, and is left alone. fillBlanks never + * overwrites what a person saved. + */ +async function backfill(db: Database, store: ProfileStore) { + const rows = db.query(`SELECT u.id, u.displayName, e.email FROM users u LEFT JOIN account_emails e ON e.userId=u.id + WHERE instr(trim(u.displayName), ' ')>0 AND NOT EXISTS (SELECT 1 FROM user_profiles p WHERE p.user_id=u.id)`) + .all() as { id: string; displayName: string; email: string | null }[]; + for (const row of rows) { + if (row.email && row.displayName.trim().toLowerCase() === row.email.split("@")[0]) continue; + const name = splitFullName(row.displayName); + try { await store.fillBlanks(row.id, { ...name, ...(row.email ? { email: row.email } : {}) }); } + catch { /* a name the validator refuses (a link, say) is skipped */ } + } +} diff --git a/server/test/profile.test.ts b/server/test/profile.test.ts new file mode 100644 index 0000000..4f4fa41 --- /dev/null +++ b/server/test/profile.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Accounts, type Mail } from "../accounts.ts"; +import { createApi } from "../api.ts"; +import { profiles } from "../profile.ts"; +import { Store } from "../store.ts"; + +/** + * The shared @profullstack/profile fields: asked for only when creating an account, + * carried on the emailed link (the user row exists only once it is spent), saved on + * verify, and served at /api/v1/me/profile. + */ +const origin = "https://readm3.com"; +const opened: Accounts[] = []; +afterEach(() => { for (const app of opened.splice(0)) app.db.close(); }); +function fixture() { + const mail: Mail[] = []; + const app = new Accounts(":memory:", origin, async message => { mail.push(message); }); + opened.push(app); + const store = new Store(app.db); + const api = createApi(store, origin); + const request = (action: string, args: unknown, cookie?: string) => app.handle(new Request(`${origin}/api/auth/${action}`, { + method: "POST", + headers: { "content-type": "application/json", origin, ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(args), + }), "127.0.0.1").then(r => r!); + const token = () => new URLSearchParams(new URL(mail.at(-1)!.url).hash.slice(1)).get("verify")!; + return { app, mail, api, request, token }; +} + +describe("signup profile", () => { + test("a signup with the fields stores them once the link is verified", async () => { + const { app, api, request, token } = fixture(); + const sent = await request("email", { + email: "Ada@Acme.com", mode: "signup", first_name: "Ada", last_name: "Lovelace", + company_domain: "https://www.acme.com/about", job_title: "CTO", linkedin_url: "in/ada-lovelace", + }); + expect(sent.status).toBe(200); + expect(app.db.query("SELECT * FROM users").all()).toHaveLength(0); + const verified = await request("verify", { token: token() }); + expect(verified.status).toBe(200); + const { user } = await verified.json(); + const saved = await (await profiles(app.db).ready).get(user.id); + expect(saved).toMatchObject({ + email: "ada@acme.com", first_name: "Ada", last_name: "Lovelace", + company_domain: "acme.com", job_title: "CTO", linkedin_url: "https://www.linkedin.com/in/ada-lovelace", + }); + const cookie = verified.headers.get("set-cookie")!.split(";")[0]; + const response = await api(new Request(`${origin}/api/v1/me/profile`, { headers: { cookie } })); + expect(response!.status).toBe(200); + expect((await response!.json()).profile.job_title).toBe("CTO"); + }); + + test("a signup missing a name is refused before any link is sent", async () => { + const { app, mail, request } = fixture(); + const response = await request("email", { email: "ada@acme.com", mode: "signup", first_name: "Ada" }); + expect(response.status).toBe(400); + expect((await response.json()).errors.last_name).toBeTruthy(); + expect(mail).toHaveLength(0); + expect(app.db.query("SELECT * FROM email_challenges").all()).toHaveLength(0); + }); + + test("plain sign-in never asks for the fields", async () => { + const { app, request, token } = fixture(); + expect((await request("email", { email: "person@example.com" })).status).toBe(200); + const { user } = await (await request("verify", { token: token() })).json(); + expect(await (await profiles(app.db).ready).get(user.id)).toBeNull(); + }); + + test("the profile API needs a session", async () => { + const { api } = fixture(); + const response = await api(new Request(`${origin}/api/v1/me/profile`)); + expect(response!.status).toBe(401); + }); +}); diff --git a/site/account.ts b/site/account.ts index 59aba0a..dc1c489 100644 --- a/site/account.ts +++ b/site/account.ts @@ -6,6 +6,19 @@ const requestedNext = new URLSearchParams(location.search).get("next"); const next = requestedNext && /^\/(?:admin|viewer|oauth\/authorize)(?:\?|$)/.test(requestedNext) ? requestedNext : null; let email = ""; let token: string | null = null; +/** "Create an account" adds the shared profile fields to the email form; plain sign-in never asks for them. */ +let signup = false; +const PROFILE_FIELDS = ["first_name", "last_name", "company_domain", "job_title", "linkedin_url"] as const; +const fieldValues = (prefix: string) => + Object.fromEntries(PROFILE_FIELDS.map(name => [name, el(`${prefix}${name}`).value])); +function setSignup(on: boolean) { + signup = on; + el("signup-fields").hidden = !on; + // A disabled fieldset's required boxes do not block a plain sign-in. + el("signup-fields").querySelector("fieldset")!.disabled = !on; + el("signup-toggle").textContent = on ? "Already have an account? Sign in" : "New to readm3? Create an account"; + el("signup-toggle").setAttribute("aria-expanded", String(on)); +} function readLink() { token = new URLSearchParams(location.hash.slice(1)).get("verify"); if (location.hash) history.replaceState(null, "", location.pathname + location.search); @@ -79,6 +92,23 @@ function profile(user: Account) { el("account-email").textContent = user.email ?? "Signed in with CoinPay"; el("verified-badge").textContent = user.email ? "Email verified" : "CoinPay account"; el("display-name").value = user.displayName; + void loadDetails(); +} +async function profileApi(method: "GET" | "PATCH", body?: unknown) { + const response = await fetch("/api/v1/me/profile", { + method, credentials: "same-origin", cache: "no-store", + headers: body === undefined ? {} : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.errors ? Object.values(result.errors)[0] as string : result.error || "Could not save your details."); + return result as { profile: Record | null }; +} +async function loadDetails() { + try { + const { profile } = await profileApi("GET"); + for (const name of PROFILE_FIELDS) el(`details-${name}`).value = profile?.[name] ?? ""; + } catch { /* the fields stay empty; saving still works */ } } function cooldown(seconds: number) { clearInterval(timer); @@ -93,7 +123,7 @@ function cooldown(seconds: number) { tick(); timer = setInterval(tick, 1000); } async function sendLink() { - const response = await api<{ retryAfter: number }>("email", { email, next }); + const response = await api<{ retryAfter: number }>("email", { email, next, ...(signup ? { mode: "signup", ...fieldValues("signup-") } : {}) }); show("check-email"); el("sent-email").textContent = email; cooldown(response.retryAfter); } @@ -107,6 +137,14 @@ el("resend").addEventListener("click", () => { if (!el("check-email").hidden && el("error").hidden) cooldown(60); }); }); +el("signup-toggle").addEventListener("click", () => { setSignup(!signup); el(signup ? "signup-first_name" : "email").focus(); }); +el("details-form").addEventListener("submit", event => { + event.preventDefault(); + void action(el("details-form").querySelector("button")!, async () => { + await profileApi("PATCH", fieldValues("details-")); + await loadDetails(); message("status", "Your details have been saved."); + }); +}); for (const id of ["different-email", "cancel-verify"]) el(id).addEventListener("click", () => { token = null; show("sign-in"); el("email").focus(); }); el("confirm-verify").addEventListener("click", () => void action(el("confirm-verify"), async () => { const result = await api<{ user: Account }>("verify", { token }); token = null; profile(result.user); @@ -132,6 +170,7 @@ el("coinpay-unlink").addEventListener("click", () => void action(el("#sign-in .fine-print"); if (fine) fine.textContent = "Your browser opens readm3.com. Sign in there with your email or CoinPay, approve the desktop app, and come back."; @@ -157,4 +196,5 @@ async function initialize() { } window.addEventListener("pageshow", event => { if (event.persisted) { show("loading"); void initialize(); } }); window.addEventListener("hashchange", () => { readLink(); show("loading"); void initialize(); }); +setSignup(false); void initialize(); diff --git a/site/assets/account.css b/site/assets/account.css index 296f54c..e804b6c 100644 --- a/site/assets/account.css +++ b/site/assets/account.css @@ -1,3 +1,4 @@ :root{color-scheme:dark;--bg:#090c10;--surface:#10151b;--border:#26313c;--fg:#e8edf2;--muted:#a0adba;--green:#a3edba;--mono:ui-monospace,SFMono-Regular,Consolas,monospace;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--fg);background:var(--bg)} *{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;flex-direction:column}a{color:inherit;text-underline-offset:4px}button,input{font:inherit}button,a,input{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:wait;opacity:.6}[hidden]{display:none!important}:focus-visible{outline:2px solid var(--green);outline-offset:5px}.skip{position:fixed;top:-60px;left:20px;background:var(--fg);color:var(--bg);padding:12px;z-index:10}.skip:focus{top:16px}header,footer{width:min(1140px,100%);margin:0 auto;display:flex;align-items:center;justify-content:space-between;padding:28px 36px;gap:20px}header>a{text-decoration:none;font-size:14px;color:var(--muted)}header .brand{font:700 24px var(--mono);letter-spacing:-1px;color:var(--green)}main{width:min(1070px,100%);margin:auto;display:grid;grid-template-columns:1.1fr 1fr;align-items:center;gap:90px;padding:64px 36px 80px}.eyebrow{font:11px var(--mono);letter-spacing:2px;color:var(--muted);margin:0 0 24px}h1{font-size:clamp(42px,5vw,64px);letter-spacing:-3px;line-height:1.08;font-weight:550;margin:0 0 24px}h1 span{color:var(--green)}.intro-copy{max-width:370px;color:var(--muted);font-size:16px;line-height:1.65;margin:0 0 30px}.terminal{border:1px solid var(--border);border-radius:9px;max-width:356px;background:#0d1117;overflow:hidden}.terminal-bar{display:flex;gap:5px;align-items:center;padding:12px;border-bottom:1px solid #202832}.terminal-bar i{display:block;width:7px;height:7px;border-radius:50%;background:#36414d}.terminal-bar span{color:var(--muted);font:10px var(--mono);margin:auto}.terminal pre{font:12px/1.9 var(--mono);padding:19px 23px;margin:0}.dim{color:var(--muted)}.green{color:var(--green)}.local-note{color:var(--muted);font-size:12px;line-height:1.8;margin:25px 0 0}.local-note a{color:var(--fg)}.account-card{min-width:0;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:34px;box-shadow:0 16px 80px #0003}.card-mark{display:inline-grid;place-items:center;background:#192a21;border:1px solid #2e4d3b;color:var(--green);border-radius:9px;font:24px var(--mono);width:45px;height:45px;margin-bottom:25px}h2{font-size:25px;font-weight:550;letter-spacing:-.6px;line-height:1.25;margin:0 0 12px}.account-card p{color:var(--muted);font-size:14px;line-height:1.65;margin:0 0 24px;overflow-wrap:anywhere}.account-card strong{font-weight:500;color:var(--fg)}label{display:block;font-size:12px;font-weight:600;margin-bottom:9px}input{width:100%;background:var(--bg);border:1px solid #364250;color:var(--fg);border-radius:6px;padding:13px 12px;min-height:46px;font-size:16px}input::placeholder{color:#768493}.primary,.secondary{display:flex;align-items:center;justify-content:center;gap:14px;text-decoration:none;border:1px solid var(--green);border-radius:6px;background:var(--green);color:#102018;padding:13px 15px;width:100%;min-height:46px;font-size:14px;font-weight:650;margin-top:14px}.primary:hover{background:#b7f5ca}.secondary{background:transparent;color:var(--fg);border-color:#364250}.secondary:hover{background:#1a232c}.account-card .fine-print{font-size:12px;line-height:1.65;margin:22px 0 0}.text-button{padding:8px 0;border:0;background:transparent;color:var(--muted);font-size:12px;text-decoration:underline;text-underline-offset:4px;min-height:36px}.text-button:hover{color:var(--fg)}#different-email,#cancel-verify{display:block;margin:15px auto 0}.verified{color:var(--green);font-size:11px;border:1px solid #2e4d3b;border-radius:20px;padding:5px 9px}.profile-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:23px}.profile-heading .card-mark{margin:0}.account-card .account-email{color:var(--fg);margin-bottom:28px}.reader-link{margin-top:27px}.coinpay-link{margin-top:22px;padding-top:18px;border-top:1px solid var(--border)}.account-card .coinpay-link p{margin:0}.session-actions{display:flex;justify-content:space-between;gap:15px;margin-top:22px;padding-top:15px;border-top:1px solid var(--border)}.account-card #error{color:#ffb4b4;margin:20px 0 0}.account-card #status{color:var(--green);margin:20px 0 0}footer{color:var(--muted);font:11px/1.6 var(--mono);border-top:1px solid #1c252f;padding-top:20px;padding-bottom:20px}footer a{white-space:nowrap} @media(max-width:780px){main{gap:36px;padding-top:38px;grid-template-columns:1fr;max-width:470px}.intro h1{font-size:44px}.intro h1 span{display:block}.intro-copy,.terminal,.local-note{display:none}.eyebrow{margin-bottom:16px}.intro h1{margin:0}.account-card{padding:28px}header,footer{padding-left:24px;padding-right:24px}main{padding-left:24px;padding-right:24px;padding-bottom:48px}}@media(max-width:360px){.account-card{padding:22px}main{padding-left:16px;padding-right:16px}footer{flex-direction:column;align-items:flex-start;gap:8px}.session-actions{flex-direction:column;gap:0;align-items:flex-start}} +.pfs-profile{border:0;margin:0;padding:0;min-width:0}.pfs-profile__row{margin-top:16px}.pfs-profile__optional{color:var(--muted);font-weight:400}.account-card .pfs-profile__error{color:#ffb4b4;font-size:12px;margin:6px 0 0}#signup-toggle{display:block;margin:12px auto 0}.details-form{margin-top:22px;padding-top:18px;border-top:1px solid var(--border)}.account-card .details-note{font-size:12px;margin:0} diff --git a/site/assets/account.html b/site/assets/account.html index 0fd9b80..e722408 100644 --- a/site/assets/account.html +++ b/site/assets/account.html @@ -10,6 +10,7 @@ + @@ -35,8 +36,10 @@

Make yourself at home.

+
+

No password to remember. Every sign-in link is private, expires in 15 minutes, and works once.

@@ -65,6 +68,11 @@

Your account.

+
+

Your details. Private to your account, never shown to others.

+ __DETAILS_FIELDS__ + +