From 6150ff69c209b1b016c9a1be7ea9c8f0eb60cf37 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 10:50:29 +0000 Subject: [PATCH] feat: signup profile fields via @profullstack/profile /signup asks for first/last name (required) plus company domain, job title and LinkedIn (optional). Validated before the link is minted, carried on login_tokens.profile and saved to user_profiles when the link is opened; a failed save never blocks sign-in. /login is unchanged. GET/PUT/PATCH /api/v1/me/profile and a Your details panel on /account. Schema in db/schema.pg.sql (applied to prod). Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 ++ db/schema.pg.sql | 20 +++++++++++ package.json | 1 + src/app/account/page.tsx | 21 +++++++++++- src/app/api/auth/magic/route.ts | 16 +++++++-- src/app/api/v1/me/profile/route.ts | 27 +++++++++++++++ src/app/auth/verify/route.ts | 11 ++++-- src/app/globals.css | 17 +++++++++ src/components/AuthForm.tsx | 27 +++++++++++++-- src/components/ProfileForm.tsx | 55 ++++++++++++++++++++++++++++++ src/lib/auth/magic.ts | 25 ++++++++++---- src/lib/auth/signupProfile.ts | 53 ++++++++++++++++++++++++++++ src/lib/profile.ts | 15 ++++++++ test/signup-profile.test.mjs | 48 ++++++++++++++++++++++++++ 14 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 src/app/api/v1/me/profile/route.ts create mode 100644 src/components/ProfileForm.tsx create mode 100644 src/lib/auth/signupProfile.ts create mode 100644 src/lib/profile.ts create mode 100644 test/signup-profile.test.mjs diff --git a/bun.lock b/bun.lock index 5424333..e46046a 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@profullstack/libsql-pg": "^0.1.2", "@profullstack/openprofile": "^0.1.2", "@profullstack/player": "0.15.0", + "@profullstack/profile": "0.1.3", "@profullstack/scraper-daemon": "0.2.1", "@profullstack/submit-feed": "^0.1.3", "@profullstack/throttle": "^0.3.3", @@ -196,6 +197,8 @@ "@profullstack/player": ["@profullstack/player@0.15.0", "", { "dependencies": { "hls.js": "^1.7.1", "mpegts.js": "^1.8.2" }, "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-vcOuLZ59jpHzTKAGbd1hjdhjYaNxKoAq5D8RCQszVYRYZEwobJCn/LJy1CeZzaBDfVBCRziWR2lwkWlYHVVFCg=="], + "@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/scraper-daemon": ["@profullstack/scraper-daemon@0.2.1", "", { "peerDependencies": { "bullmq": ">=6", "ioredis": ">=5" }, "optionalPeers": ["bullmq", "ioredis"], "bin": { "scraper-daemon": "bin/scraper-daemon.js" } }, "sha512-oqmN0eOPmNAz7Q88XOIL2xEb87jc32IbIWyteUtmIhqqLHHQhu19lsgrrtoNClH/6tQoI7cSQFCAnoFH1YoNXw=="], "@profullstack/submit-feed": ["@profullstack/submit-feed@0.1.3", "", { "dependencies": { "fast-xml-parser": "5.11.1" } }, "sha512-RMH294Q1qFJ6L01pU5q/yowshx1Xpu4gfGC2OuNaxyZ5jySqM9FvgqvVuWH+G18711SqzNq+DL8dvbAaBgBIvA=="], diff --git a/db/schema.pg.sql b/db/schema.pg.sql index 802687a..22ba6f9 100644 --- a/db/schema.pg.sql +++ b/db/schema.pg.sql @@ -40,6 +40,10 @@ create table if not exists login_tokens ( ); create index if not exists i_login_email on login_tokens(email, created_at desc); create index if not exists i_login_ip on login_tokens(ip, created_at desc); +-- The /signup form's fields (JSON, already through @profullstack/profile's +-- validateProfile). They ride on the link because the account is only made +-- when it is opened; saved to user_profiles then. +alter table login_tokens add column if not exists profile text; create table if not exists credentials ( id text primary key, @@ -62,6 +66,22 @@ create table if not exists follows ( ); create index if not exists i_follows_slug on follows(slug); +-- The signup profile (@profullstack/profile 0.1.3, `profile schema --key-type +-- bigint`), keyed by users.id. Server-only: no public page or API reads it. +create table if not exists "user_profiles" ( + "user_id" bigint primary key, + "email" text, + "first_name" text, + "last_name" text, + "company_domain" text, + "job_title" text, + "linkedin_url" text, + "created_at" timestamptz not null default now(), + "updated_at" timestamptz not null default now() +); +create index if not exists "user_profiles_email" on "user_profiles" (lower("email")); +create index if not exists "user_profiles_company_domain" on "user_profiles" ("company_domain"); + -- One row per refresh-pipeline run; what /crawlstats reads. create table if not exists refresh_runs ( id bigint generated by default as identity primary key, diff --git a/package.json b/package.json index ff1c269..d63ae7a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@profullstack/libsql-pg": "^0.1.2", "@profullstack/openprofile": "^0.1.2", "@profullstack/player": "0.15.0", + "@profullstack/profile": "0.1.3", "@profullstack/scraper-daemon": "0.2.1", "@profullstack/submit-feed": "^0.1.3", "@profullstack/throttle": "^0.3.3", diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 8a3b796..b1adbbd 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -5,6 +5,9 @@ import { count } from "@/lib/db"; import { currentUser } from "@/lib/auth/session"; import { credentialsFor } from "@/lib/auth/passkey"; import PasskeyManager from "@/components/PasskeyManager"; +import ProfileForm from "@/components/ProfileForm"; +import { COLUMNS } from "@profullstack/profile"; +import { profileStore } from "@/lib/profile"; import Ad from "@/components/Ad"; import { AD_TEXT } from "@/lib/ads"; @@ -19,10 +22,17 @@ export default async function Account() { const user = await currentUser(); if (!user) redirect("/login?next=/account"); - const [passkeys, follows] = await Promise.all([ + const [passkeys, follows, mine] = await Promise.all([ credentialsFor(user.id), count("SELECT COUNT(*) n FROM follows WHERE user_id = ?", [user.id]), + // A missing table must not take the account page down with it. + profileStore().get(user.id).catch(() => null), ]); + // Only the six fields cross to the client form, as plain strings. + const profileValues: Record = Object.fromEntries( + COLUMNS.map((c) => [c, mine?.[c] == null ? null : String(mine[c])]), + ); + if (!profileValues.email) profileValues.email = user.email; return (
@@ -42,6 +52,15 @@ export default async function Account() { the reader came here to press. */} +
+

Your details

+

+ Your name and, if you like, where you work. Only the email and your name are + required. +

+ +
+

Following

diff --git a/src/app/api/auth/magic/route.ts b/src/app/api/auth/magic/route.ts index 017fc0b..4dfd489 100644 --- a/src/app/api/auth/magic/route.ts +++ b/src/app/api/auth/magic/route.ts @@ -2,6 +2,7 @@ import { headers } from "next/headers"; import { normaliseEmail, pruneLinks, requestLink } from "@/lib/auth/magic"; import { origin } from "@/lib/auth/session"; import { safeNext } from "@/lib/auth/next"; +import { signupProfileFrom } from "@/lib/auth/signupProfile"; export const dynamic = "force-dynamic"; @@ -20,7 +21,8 @@ export async function POST(req: Request) { } catch { return Response.json(SAME_ANSWER); } - const { email: rawEmail, next: rawNext } = (body ?? {}) as Record; + const fields = (body ?? {}) as Record; + const { email: rawEmail, next: rawNext } = fields; const email = normaliseEmail(rawEmail); if (!email) { return Response.json( @@ -28,13 +30,23 @@ export async function POST(req: Request) { { status: 400 }, ); } + // /signup sends its profile fields with `signup: true`; they are checked + // before any link is minted, so a refused form never mails anybody. Saying + // a name is missing reveals nothing about who has an account. + const signup = signupProfileFrom(fields, email); + if (!signup.ok) { + return Response.json( + { ok: false, message: "A few of those fields need another look.", errors: signup.errors }, + { status: 400 }, + ); + } const h = await headers(); const ip = h.get("x-forwarded-for")?.split(",")[0].trim() || h.get("x-real-ip") || null; try { - await requestLink(email, await origin(), safeNext(rawNext), ip); + await requestLink(email, await origin(), safeNext(rawNext), ip, signup.pending); await pruneLinks(); } catch (err) { // A Resend outage must not become an account oracle either. diff --git a/src/app/api/v1/me/profile/route.ts b/src/app/api/v1/me/profile/route.ts new file mode 100644 index 0000000..d6fa35e --- /dev/null +++ b/src/app/api/v1/me/profile/route.ts @@ -0,0 +1,27 @@ +import { createProfileHandlers } from "@profullstack/profile"; +import { currentUser } from "@/lib/auth/session"; +import { profileStore } from "@/lib/profile"; + +export const dynamic = "force-dynamic"; + +/** + * The signed-in listener's signup profile: GET it, PUT a whole one, PATCH + * some fields. Session cookie only; anybody else gets a 401. Built on first + * request so the build never touches the database. + */ +let handlers: ReturnType | null = null; + +function profileHandlers() { + handlers ??= createProfileHandlers({ + store: profileStore(), + getUser: async () => { + const user = await currentUser(); + return user ? { id: String(user.id), email: user.email } : null; + }, + }); + return handlers; +} + +export const GET = (req: Request) => profileHandlers().GET(req); +export const PUT = (req: Request) => profileHandlers().PUT(req); +export const PATCH = (req: Request) => profileHandlers().PATCH(req); diff --git a/src/app/auth/verify/route.ts b/src/app/auth/verify/route.ts index f4e83cf..a533eef 100644 --- a/src/app/auth/verify/route.ts +++ b/src/app/auth/verify/route.ts @@ -2,6 +2,8 @@ import { redirect } from "next/navigation"; import { consumeLink } from "@/lib/auth/magic"; import { findOrCreateUser, startSession } from "@/lib/auth/session"; import { safeNext } from "@/lib/auth/next"; +import { saveVerifiedProfile } from "@/lib/auth/signupProfile"; +import { profileStore } from "@/lib/profile"; export const dynamic = "force-dynamic"; @@ -10,12 +12,15 @@ export async function GET(req: Request) { const token = params.get("token") || ""; const next = safeNext(params.get("next")); - const email = token ? await consumeLink(token) : null; - if (!email) redirect("/login?expired=1"); + const link = token ? await consumeLink(token) : null; + if (!link) redirect("/login?expired=1"); // An unknown address makes the account here. There is no separate sign-up // flow to keep in step — opening the link IS the registration. - const user = await findOrCreateUser(email); + const user = await findOrCreateUser(link.email); + // A /signup link carries the form's name and details; saved now the account + // exists. Never blocks the sign-in. + await saveVerifiedProfile(profileStore(), user, link.profile); await startSession(user.id); redirect(next || "/following"); } diff --git a/src/app/globals.css b/src/app/globals.css index 2038a03..ebbddf2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -270,6 +270,23 @@ button.btn:disabled { opacity: .55; cursor: default; } .auth-swap { margin-top: 28px; font-size: 14.5px; color: var(--muted); } .auth-swap a { color: var(--accent); text-decoration: underline; } .auth-done { padding: 60px 0; } + +/* The signup profile from @profullstack/profile (/signup, /account): labelled + rows that match the email box above them. */ +.pfs-profile { border: 0; padding: 0; margin: 0 0 4px; min-width: 0; } +.pfs-profile__row label { + display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; + font-family: ui-sans-serif, system-ui, sans-serif; +} +.pfs-profile__optional { opacity: .8; } +.pfs-profile input[type="text"], .pfs-profile input[type="url"], .pfs-profile input[type="email"] { + width: 100%; padding: 12px 14px; font-size: 16px; font-family: inherit; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); color: var(--text); margin-bottom: 12px; +} +.pfs-profile input:focus { outline: 2px solid var(--accent); outline-offset: -1px; } +.pfs-profile input[aria-invalid="true"] { border-color: var(--accent); } +.pfs-profile__error { color: var(--accent); font-size: 13.5px; margin: -6px 0 12px; } .auth-done h2 { font-size: 26px; margin: 0 0 12px; letter-spacing: -0.02em; } .panel { diff --git a/src/components/AuthForm.tsx b/src/components/AuthForm.tsx index 7293c84..5d7401b 100644 --- a/src/components/AuthForm.tsx +++ b/src/components/AuthForm.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { startAuthentication } from "@simplewebauthn/browser"; +import { ProfileFields } from "@profullstack/profile/react"; import { suggestEmail } from "@/lib/auth/typo"; /** @@ -28,6 +29,8 @@ export default function AuthForm({ ); const [error, setError] = useState(null); const [suggestion, setSuggestion] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + const formRef = useRef(null); /** * A near-miss like `.con` is checked here because it cannot be checked @@ -51,16 +54,24 @@ export default function AuthForm({ async function send(address: string) { setBusy(true); setError(null); + // /signup also sends the profile fields; /login only ever sends the address. + const profile: Record = {}; + if (mode === "signup" && formRef.current) { + for (const [k, v] of new FormData(formRef.current)) if (k !== "email") profile[k] = v; + profile.signup = true; + } try { const res = await fetch("/api/auth/magic", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ email: address, next }), + body: JSON.stringify({ ...profile, email: address, next }), }); const body = await res.json(); if (!res.ok) { + setFieldErrors(body.errors || {}); setError(body.message || "That did not work. Try again."); } else { + setFieldErrors({}); setSent(true); setSentTo(address); setNote(body.message); @@ -146,7 +157,7 @@ export default function AuthForm({ {note && !sent &&

{note}

} -
+ )} + {/* /signup only: the profile a new account starts with. Kept mounted + (uncontrolled) so a refused submit keeps what was typed. */} + {mode === "signup" && ( + + )} + diff --git a/src/components/ProfileForm.tsx b/src/components/ProfileForm.tsx new file mode 100644 index 0000000..cef1d5e --- /dev/null +++ b/src/components/ProfileForm.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { ProfileFields } from "@profullstack/profile/react"; + +type Values = Record; + +/** + * The signup profile, editable on /account: prefilled from the server, saved + * with PATCH /api/v1/me/profile. Uncontrolled, re-keyed on save so the inputs + * show what the server normalized them to. + */ +export default function ProfileForm({ initial }: { initial: Values }) { + const [values, setValues] = useState(initial); + const [errors, setErrors] = useState>({}); + const [status, setStatus] = useState(""); + const [busy, setBusy] = useState(false); + + async function save(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setStatus(""); + try { + const body = Object.fromEntries(new FormData(e.currentTarget).entries()); + const res = await fetch("/api/v1/me/profile", { + method: "PATCH", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + setErrors({}); + setValues(data.profile ?? data); + setStatus("Saved."); + } else { + setErrors(data.errors ?? {}); + setStatus(data.errors ? "A few of those fields need another look." : "Could not save. Try again."); + } + } catch { + setStatus("Could not save. Try again."); + } finally { + setBusy(false); + } + } + + return ( + + + + {status && {status}} + + ); +} diff --git a/src/lib/auth/magic.ts b/src/lib/auth/magic.ts index ce00f20..679f617 100644 --- a/src/lib/auth/magic.ts +++ b/src/lib/auth/magic.ts @@ -26,6 +26,7 @@ export async function requestLink( origin: string, next: string, ip: string | null, + profile: string | null = null, ): Promise { const since = now() - TTL; const byEmail = await one<{ n: number }>( @@ -43,8 +44,8 @@ export async function requestLink( const raw = token(); await db().execute({ - sql: "INSERT INTO login_tokens(token_hash, email, created_at, expires_at, ip) VALUES(?,?,?,?,?)", - args: args([sha256(raw), email, now(), now() + TTL, ip]), + sql: "INSERT INTO login_tokens(token_hash, email, created_at, expires_at, ip, profile) VALUES(?,?,?,?,?,?)", + args: args([sha256(raw), email, now(), now() + TTL, ip, profile]), }); const known = await one<{ id: number }>("SELECT id FROM users WHERE email = ?", [email]); @@ -53,11 +54,21 @@ export async function requestLink( await sendMail(email, mail.subject, mail.html, mail.text); } -/** Burns the token and returns the address it proved, or null. */ -export async function consumeLink(raw: string): Promise { +/** + * Burns the token and returns the address it proved, with the signup profile + * it carried (JSON, or null for a sign-in link), or null. + */ +export async function consumeLink( + raw: string, +): Promise<{ email: string; profile: string | null } | null> { const hash = sha256(raw); - const row = await one<{ email: string; expires_at: number; used_at: number | null }>( - "SELECT email, expires_at, used_at FROM login_tokens WHERE token_hash = ?", + const row = await one<{ + email: string; + profile: string | null; + expires_at: number; + used_at: number | null; + }>( + "SELECT email, profile, expires_at, used_at FROM login_tokens WHERE token_hash = ?", [hash], ); if (!row || row.used_at !== null || row.expires_at < now()) return null; @@ -68,7 +79,7 @@ export async function consumeLink(raw: string): Promise { args: args([now(), hash]), }); if (Number(res.rowsAffected ?? 0) < 1) return null; - return row.email; + return { email: row.email, profile: row.profile ?? null }; } /** Housekeeping so the table does not grow without bound. */ diff --git a/src/lib/auth/signupProfile.ts b/src/lib/auth/signupProfile.ts new file mode 100644 index 0000000..84380dd --- /dev/null +++ b/src/lib/auth/signupProfile.ts @@ -0,0 +1,53 @@ +import { validateProfile, saveSignupProfile, type ProfileStore } from "@profullstack/profile"; + +/** + * The /signup form's profile (@profullstack/profile: name, company, title, + * LinkedIn). There is no account until the emailed link is opened, so the + * validated fields ride on the link (login_tokens.profile, JSON) and are saved + * on verify. Plain sign-in never sends them and is never asked for them. + * + * Kept free of the database so it can be tested on its own. + */ + +/** Only a body that says it is a signup is checked; /login posts just an email. */ +export function signupProfileFrom( + body: Record, + email: string, +): + | { ok: true; pending: string | null } + | { ok: false; errors: Record } { + if (body.signup !== true) return { ok: true, pending: null }; + const check = validateProfile({ ...body, email }); + if (!check.ok) return { ok: false, errors: check.errors }; + return { ok: true, pending: JSON.stringify(check.profile) }; +} + +/** The fields a link carried, or null for a sign-in link or a mangled value. */ +export function parsePending(raw: unknown): Record | null { + if (typeof raw !== "string" || !raw) return null; + try { + const value = JSON.parse(raw); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; + } catch { + return null; + } +} + +/** + * Saves what the signup form gave, once the link proved the address. A failure + * here is logged and swallowed: it must never stand between someone and + * signing in, and the account page lets them fill it in again. + */ +export async function saveVerifiedProfile( + store: ProfileStore, + user: { id: number | string; email: string }, + raw: unknown, +): Promise { + const fields = parsePending(raw); + if (!fields) return; + try { + await saveSignupProfile(store, String(user.id), fields, { email: user.email }); + } catch (err) { + console.error("signup profile not saved:", err); + } +} diff --git a/src/lib/profile.ts b/src/lib/profile.ts new file mode 100644 index 0000000..0823bd7 --- /dev/null +++ b/src/lib/profile.ts @@ -0,0 +1,15 @@ +import { createProfileStore, type ProfileStore } from "@profullstack/profile"; +import { db } from "@/lib/db"; + +let store: ProfileStore | null = null; + +/** + * The signup profile (@profullstack/profile) in `user_profiles`, keyed by + * users.id; the table is created by db/schema.pg.sql. Built on first use so + * `next build` never opens the database. The libSQL-shaped Postgres client + * says it is Postgres, but the dialect is pinned rather than inferred. + */ +export function profileStore(): ProfileStore { + store ??= createProfileStore(db(), { keyType: "bigint", dialect: "postgres" }); + return store; +} diff --git a/test/signup-profile.test.mjs b/test/signup-profile.test.mjs new file mode 100644 index 0000000..4771b9c --- /dev/null +++ b/test/signup-profile.test.mjs @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Database } from 'bun:sqlite'; +import { createProfileStore } from '@profullstack/profile'; +import { signupProfileFrom, parsePending, saveVerifiedProfile } from '../src/lib/auth/signupProfile.ts'; + +test('a /signup link carries the fields and they are stored on verify', async () => { + const sent = signupProfileFrom( + { + signup: true, + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'https://www.Acme.com/about', + job_title: 'VP Engineering', + linkedin_url: 'in/ada-lovelace', + }, + 'ada@acme.com', + ); + assert.equal(sent.ok, true); + assert.ok(sent.pending, 'a signup link carries a profile'); + + const store = createProfileStore(new Database(':memory:')); + await store.migrate(); + await saveVerifiedProfile(store, { id: 7, email: 'ada@acme.com' }, sent.pending); + + const saved = await store.get('7'); + assert.equal(saved.email, 'ada@acme.com'); + assert.equal(saved.first_name, 'Ada'); + assert.equal(saved.last_name, 'Lovelace'); + assert.equal(saved.company_domain, 'acme.com'); + assert.equal(saved.job_title, 'VP Engineering'); + assert.equal(saved.linkedin_url, 'https://www.linkedin.com/in/ada-lovelace'); +}); + +test('a signup without a name is refused before any link is minted', () => { + const sent = signupProfileFrom({ signup: true, first_name: 'Ada' }, 'ada@acme.com'); + assert.equal(sent.ok, false); + assert.ok(sent.errors.last_name); +}); + +test('plain sign-in is never asked for the fields', async () => { + const sent = signupProfileFrom({ email: 'ada@acme.com' }, 'ada@acme.com'); + assert.deepEqual(sent, { ok: true, pending: null }); + assert.equal(parsePending(null), null); + assert.equal(parsePending('not json'), null); + // A sign-in link saves nothing, and a broken store never throws at the caller. + await saveVerifiedProfile({ save() { throw new Error('boom'); } }, { id: 1, email: 'a@b.co' }, null); +});