From dfc82755e63c91e4b53dc2e8afb85229c7ccff68 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 10:34:55 +0000 Subject: [PATCH] feat(auth): shared signup profile via @profullstack/profile The signup page asks for first name, last name, company domain, job title and LinkedIn beside the email box (first and last name required), validated by @profullstack/profile before any link exists. The fields ride on the login token (login_tokens.profile) and are saved to user_profiles when the link is spent; a failed save never blocks the sign-in. Plain sign-in is unchanged. - migration 0002: user_profiles (package DDL, keyed by users.id), the token column, and an idempotent backfill from users.display_name - GET/PUT/PATCH /api/v1/me/profile via mountProfileRoutes - Contact details panel on /settings (POST /settings/contact) - /vendor-profile.js serves PROFILE_ENHANCE_SCRIPT (CSP allows no inline script) Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 1 + apps/web/public/styles.css | 29 ++++++ apps/web/src/lib/profile.js | 15 +++ apps/web/src/routes/api.js | 7 +- apps/web/src/routes/auth.js | 39 +++++++- apps/web/src/routes/pages.js | 28 +++++- apps/web/src/routes/static.js | 9 ++ apps/web/src/views/account.jsx | 47 ++++++++- bun.lock | 3 + packages/auth/src/index.js | 10 +- packages/db/migrations/0002_user_profiles.sql | 39 ++++++++ packages/db/src/accounts.js | 35 ++++++- test/api.test.js | 99 +++++++++++++++++++ 13 files changed, 348 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/lib/profile.js create mode 100644 packages/db/migrations/0002_user_profiles.sql diff --git a/apps/web/package.json b/apps/web/package.json index fe83c7f..b833b6c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "dependencies": { "@profullstack/footer": "latest", "@profullstack/emailer": "^1.0.3", + "@profullstack/profile": "0.1.2", "@profullstack/throttle": "^0.3.3", "@profullstack/x402-gateway": "^0.11.0", "@r4ck/auth": "workspace:*", diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css index 3e77b62..c0173ec 100644 --- a/apps/web/public/styles.css +++ b/apps/web/public/styles.css @@ -454,6 +454,7 @@ button.button { input[type="search"], input[type="text"], input[type="email"], +input[type="url"], select { height: var(--control-h); border-radius: 10px; @@ -495,6 +496,34 @@ label { gap: 0.75rem; margin-bottom: 1rem; } +/* The shared profile fields (@profullstack/profile renderProfileFields): a bare + fieldset of label + input rows, made to read like the email box above them. */ +.pfs-profile { + display: grid; + gap: 0.75rem; + border: 0; + padding: 0; + margin: 0; + min-width: 0; +} +.pfs-profile__row { + display: grid; + gap: 0.35rem; +} +.pfs-profile__row label { + font-size: 0.9rem; + font-weight: 500; +} +.pfs-profile__optional { + color: var(--muted); + font-weight: 400; + font-size: 0.8rem; +} +.pfs-profile__error { + color: var(--err); + font-size: 0.85rem; + margin: 0; +} .inline-form { display: flex; gap: 0.5rem; diff --git a/apps/web/src/lib/profile.js b/apps/web/src/lib/profile.js new file mode 100644 index 0000000..935473e --- /dev/null +++ b/apps/web/src/lib/profile.js @@ -0,0 +1,15 @@ +import { createProfileStore } from '@profullstack/profile'; +import { sql } from '@r4ck/db'; + +/** + * The shared Profullstack profile (@profullstack/profile): email, names, company + * domain, job title and LinkedIn, in the user_profiles table keyed by users.id + * (migration 0002). Private to the account; never shown to anyone else. + */ +export const profiles = createProfileStore(sql); + +/** The session user as the package's handlers want it: `{ id, email }` or null. */ +export const profileUser = (c) => { + const user = c.get('user'); + return user ? { id: user.id, email: user.email } : null; +}; diff --git a/apps/web/src/routes/api.js b/apps/web/src/routes/api.js index ede9dcb..d643d17 100644 --- a/apps/web/src/routes/api.js +++ b/apps/web/src/routes/api.js @@ -1,15 +1,17 @@ +import { mountProfileRoutes } from '@profullstack/profile'; import * as auth from '@r4ck/auth'; import { config } from '@r4ck/config'; import { KINDS, SORTS } from '@r4ck/core/facets'; import { currentDaemon, openserverOrigins } from '@r4ck/sync/daemon'; import { meterApi, pricing } from '../lib/gate.js'; import { Denied, requireUser } from '../lib/http.js'; +import { profiles, profileUser } from '../lib/profile.js'; import * as svc from '../lib/service.js'; const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type, authorization, x-api-key, x-crawl-pass', - 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS', + 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'access-control-expose-headers': 'x-plan, x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset', }; @@ -64,6 +66,7 @@ export function registerApi(app) { '/api/v1/deals', '/api/v1/stats', '/api/v1/me', + '/api/v1/me/profile', '/api/v1/keys', '/api/v1/saved', ], @@ -180,6 +183,8 @@ export function registerApi(app) { saved: (await svc.listSaved(user)).saved, }); }); + // The shared @profullstack/profile row: GET/PUT/PATCH, the package's own handlers. + mountProfileRoutes(app, { store: profiles, getUser: (_req, c) => profileUser(c) }); app.get('/api/v1/keys', async (c) => c.json({ keys: (await auth.listApiKeys(requireUser(c).id)).map(keyOut) }), ); diff --git a/apps/web/src/routes/auth.js b/apps/web/src/routes/auth.js index 4a98e94..17d5b91 100644 --- a/apps/web/src/routes/auth.js +++ b/apps/web/src/routes/auth.js @@ -1,9 +1,11 @@ +import { saveSignupProfile, validateProfile } from '@profullstack/profile'; import * as auth from '@r4ck/auth'; import { config } from '@r4ck/config'; import * as accounts from '@r4ck/db/accounts'; import { getCookie, setCookie } from 'hono/cookie'; import { render, requireUser, respond } from '../lib/http.js'; import { sendLoginLink } from '../lib/mail.js'; +import { profiles } from '../lib/profile.js'; import { SignIn } from '../views/account.jsx'; const PK_COOKIE = 'r4k_pk'; @@ -23,23 +25,46 @@ export function registerAuth(app) { /** Request a link. The answer is identical whether or not the address exists. */ app.post('/api/auth/magic', async (c) => { const body = await c.req.parseBody(); + const json = (c.req.header('accept') ?? '').includes('application/json'); + + /* + * The signup form carries the shared profile fields; plain sign-in does not and + * is never asked for them. They are checked here, before any link exists, and + * a refusal goes back to the form with what was typed. The same answer for a + * known or unknown address, so it says nothing about who has an account. + */ + let profile = null; + if (body.mode === 'signup') { + const check = validateProfile(body); + if (!check.ok) { + if (json) return c.json({ error: 'Some fields need fixing.', errors: check.errors }, 400); + return c.html( + await render( + , + ), + 400, + ); + } + profile = check.profile; + } + const email = String(body.email ?? '') .trim() .toLowerCase(); const next = String(body.next ?? '/settings'); if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { try { - const url = await auth.createLoginLink(email, { next }); + const url = await auth.createLoginLink(email, { next, profile }); await sendLoginLink({ email, url }); } catch (err) { console.error('[auth] link send failed:', err.message); if (!config.mail.enabled) console.log( - `[auth] mail is not configured; the link would have been ${await auth.createLoginLink(email, { next })}`, + `[auth] mail is not configured; the link would have been ${await auth.createLoginLink(email, { next, profile })}`, ); } } - if ((c.req.header('accept') ?? '').includes('application/json')) return c.json({ ok: true }); + if (json) return c.json({ ok: true }); return c.html(await render()); }); @@ -57,6 +82,14 @@ export function registerAuth(app) { ), 400, ); + // The fields typed on the signup form, saved now that the account exists and + // the link has proved the mailbox. A failure here never costs anybody their + // sign-in: settings asks again. + if (result.profile) { + await saveSignupProfile(profiles, result.user.id, result.profile, { + email: result.user.email, + }).catch((err) => console.error('[auth] signup profile save failed:', err.message)); + } c.header('set-cookie', auth.sessionCookie(result.sessionId)); const next = c.req.query('next'); return c.redirect(next?.startsWith('/') ? next : '/settings', 303); diff --git a/apps/web/src/routes/pages.js b/apps/web/src/routes/pages.js index 0ed870a..8836b1e 100644 --- a/apps/web/src/routes/pages.js +++ b/apps/web/src/routes/pages.js @@ -1,8 +1,10 @@ +import { validateProfile } from '@profullstack/profile'; import * as auth from '@r4ck/auth'; import { config } from '@r4ck/config'; import * as accounts from '@r4ck/db/accounts'; import { accessFor } from '../lib/gate.js'; import { Denied, notice, render, requireUser, respond, wantsJson } from '../lib/http.js'; +import { profiles } from '../lib/profile.js'; import * as svc from '../lib/service.js'; import { Settings } from '../views/account.jsx'; import { Deals } from '../views/deals.jsx'; @@ -105,10 +107,11 @@ export function registerPages(app) { /* ------------------------------------------------------------ account -- */ app.get('/settings', async (c) => { const user = requireUser(c); - const [keys, passkeys, saved] = await Promise.all([ + const [keys, passkeys, saved, contact] = await Promise.all([ auth.listApiKeys(user.id), accounts.listPasskeys(user.id), svc.listSaved(user), + profiles.get(user.id), ]); return c.html( await render( @@ -117,12 +120,34 @@ export function registerPages(app) { keys={keys} passkeys={passkeys} saved={saved.saved} + contact={contact ?? { email: user.email }} freshKey={c.req.query('key')} {...notice(c)} />, ), ); }); + // The shared profile (names, company, title, LinkedIn), for a page without + // script; /api/v1/me/profile is the same thing for API callers. Not under + // /api/, where every answer is JSON, so a plain form post gets its redirect. + app.post('/settings/contact', async (c) => { + const user = requireUser(c); + const body = await c.req.parseBody(); + const check = validateProfile({ ...body, email: user.email }); + if (!check.ok) + return respond(c, { + json: { error: 'Some fields need fixing.', errors: check.errors }, + status: 400, + redirectTo: '/settings', + error: Object.values(check.errors)[0], + }); + const profile = await profiles.save(user.id, check.profile); + return respond(c, { + json: { profile }, + redirectTo: '/settings', + notice: 'Contact details saved.', + }); + }); app.post('/api/keys', async (c) => { const user = requireUser(c); const body = await c.req.parseBody(); @@ -159,6 +184,7 @@ export function registerPages(app) { const user = requireUser(c); const { sql } = await import('@r4ck/db'); await sql`delete from users where id = ${user.id}`; + await profiles.remove(user.id); c.header('set-cookie', auth.sessionCookie('', { clear: true })); return respond(c, { redirectTo: '/', notice: 'Account deleted.' }); }); diff --git a/apps/web/src/routes/static.js b/apps/web/src/routes/static.js index a5ca71c..6d840e9 100644 --- a/apps/web/src/routes/static.js +++ b/apps/web/src/routes/static.js @@ -1,4 +1,5 @@ import { join } from 'node:path'; +import { PROFILE_ENHANCE_SCRIPT } from '@profullstack/profile'; import { config } from '@r4ck/config'; import { sql } from '@r4ck/db'; import { assetVersion, PUBLIC } from '../lib/assets.js'; @@ -50,6 +51,14 @@ export function registerStatic(app) { ? serve(c, `icons/${c.req.param('file')}`, { cache: 'public, max-age=86400' }) : c.notFound(), ); + // The signup form's company-from-work-email helper, from @profullstack/profile. + // Served as a file because the policy allows no inline script. + app.get('/vendor-profile.js', (c) => + c.body(PROFILE_ENHANCE_SCRIPT, 200, { + 'content-type': TYPES.js, + 'cache-control': 'public, max-age=3600', + }), + ); app.get('/sw.js', (c) => serve(c, 'sw.js', { cache: 'no-cache' })); // curl -fsSL https://r4ck.dev/install.sh | sh app.get('/install.sh', (c) => serve(c, 'install.sh', { cache: 'public, max-age=300' })); diff --git a/apps/web/src/views/account.jsx b/apps/web/src/views/account.jsx index b606867..b2c3a5d 100644 --- a/apps/web/src/views/account.jsx +++ b/apps/web/src/views/account.jsx @@ -1,14 +1,32 @@ +import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile'; import { config } from '@r4ck/config'; +import { raw } from 'hono/html'; import { ago } from './home.jsx'; import { Layout } from './Layout.jsx'; -export function SignIn({ mode = 'login', next, sent = false, error, user = null }) { +/* + * The rest of the shared profile (@profullstack/profile), asked for on the signup + * page only. Email is the form's own box, so the package renders the other five. + * A plain sign-in never sees or needs them. + */ +const PROFILE_FIELDS = PROFILE_COLUMNS.filter((f) => f !== 'email'); + +export function SignIn({ + mode = 'login', + next, + sent = false, + error, + user = null, + values = null, + errors = null, +}) { const signup = mode === 'signup'; return ( : null} >

{signup ? 'Create an account' : 'Sign in'}

@@ -27,6 +45,7 @@ export function SignIn({ mode = 'login', next, sent = false, error, user = null <>
+ {signup ? : null} + {errors?.email ?

{errors.email}

: null} + {signup + ? raw( + renderProfileFields({ + fields: PROFILE_FIELDS, + values: values ?? {}, + errors: errors ?? {}, + }), + ) + : null} @@ -68,7 +98,7 @@ export function SignIn({ mode = 'login', next, sent = false, error, user = null ); } -export function Settings({ user, keys, passkeys, saved, freshKey, notice, error }) { +export function Settings({ user, keys, passkeys, saved, contact = null, freshKey, notice, error }) { return (
@@ -181,6 +211,19 @@ export function Settings({ user, keys, passkeys, saved, freshKey, notice, error

+
+

Contact details

+

+ Private to your account. Sent with {user.email}. Also at{' '} + /api/v1/me/profile. +

+ + {raw(renderProfileFields({ fields: PROFILE_FIELDS, values: contact ?? {} }))} + + +

Saved searches

{saved.length ? ( diff --git a/bun.lock b/bun.lock index 2a2e035..01bff64 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "dependencies": { "@profullstack/emailer": "^1.0.3", "@profullstack/footer": "latest", + "@profullstack/profile": "0.1.2", "@profullstack/r4ck": "workspace:*", "@profullstack/throttle": "^0.3.3", "@profullstack/x402-gateway": "^0.11.0", @@ -191,6 +192,8 @@ "@profullstack/keys": ["@profullstack/keys@0.1.0", "", {}, "sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w=="], + "@profullstack/profile": ["@profullstack/profile@0.1.2", "", { "peerDependencies": { "@libsql/client": ">=0.6.0", "postgres": "^3.4.0", "react": ">=18" }, "optionalPeers": ["@libsql/client", "postgres", "react"], "bin": { "profile": "src/cli.js" } }, "sha512-IRpMa8xFWIs5UnZiOZt5GSQCsNM7utq1ZgSqqJ/Buye89GcKCxhMtfRIe1zRHe+96Lc2egEuKeCfmNtxa3ej5Q=="], + "@profullstack/r4ck": ["@profullstack/r4ck@workspace:apps/cli"], "@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=="], diff --git a/packages/auth/src/index.js b/packages/auth/src/index.js index f900e8a..81ec2d5 100644 --- a/packages/auth/src/index.js +++ b/packages/auth/src/index.js @@ -31,12 +31,14 @@ export const expectedOrigins = (() => { const hashToken = (t) => createHash('sha256').update(t).digest(); -export async function createLoginLink(email, { next } = {}) { +export async function createLoginLink(email, { next, profile = null } = {}) { const token = randomBytes(32).toString('base64url'); await q.insertLoginToken({ tokenHash: hashToken(token), email: email.trim().toLowerCase(), expiresAt: new Date(Date.now() + TOKEN_TTL_MINUTES * 60_000), + // Signup form fields, already validated by the caller; saved on verify. + profile, }); const url = new URL('/auth/magic', config.siteUrl); url.searchParams.set('t', token); @@ -46,8 +48,10 @@ export async function createLoginLink(email, { next } = {}) { /** Also the registration path: an unknown address gets an account here. */ export async function consumeLoginLink(token, { userAgent } = {}) { - const email = await q.consumeLoginToken(hashToken(token)); + const tokenHash = hashToken(token); + const email = await q.consumeLoginToken(tokenHash); if (!email) return null; + const profile = await q.takeSignupProfile(tokenHash); const user = await q.findOrCreateUser(email, { admin: config.adminEmails.includes(String(email).toLowerCase()), }); @@ -56,7 +60,7 @@ export async function consumeLoginLink(token, { userAgent } = {}) { ttlDays: config.session.ttlDays, userAgent, }); - return { user, sessionId }; + return { user, sessionId, profile }; } export async function passkeyRegistrationOptions(user) { diff --git a/packages/db/migrations/0002_user_profiles.sql b/packages/db/migrations/0002_user_profiles.sql new file mode 100644 index 0000000..4ce74ae --- /dev/null +++ b/packages/db/migrations/0002_user_profiles.sql @@ -0,0 +1,39 @@ +-- The shared Profullstack profile: email, first_name, last_name, company_domain, +-- job_title, linkedin_url, owned by @profullstack/profile. +-- +-- The package's own table, keyed by users.id. DDL is its +-- `profile schema --key user_id --key-type uuid` output. No foreign key, as the +-- package documents; deleting a user is the host's job (/api/account/delete). +create table if not exists "user_profiles" ( + "user_id" uuid 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"); + +-- The account row is only created when the emailed link is spent, so the fields a +-- signup typed ride on the token until then. Validated before they are stored +-- here, and saved only once the link proves the mailbox. +alter table login_tokens add column if not exists profile jsonb; + +-- One-time backfill from users.display_name. First word to first_name, the rest +-- to last_name. Only blanks are filled, so a re-run never overwrites anything. +insert into user_profiles (user_id, email, first_name, last_name) +select + u.id, + lower(u.email::text), + split_part(btrim(u.display_name), ' ', 1), + nullif(btrim(substr(btrim(u.display_name), length(split_part(btrim(u.display_name), ' ', 1)) + 1)), '') +from users u +where nullif(btrim(u.display_name), '') is not null +on conflict (user_id) do update set + email = coalesce(user_profiles.email, excluded.email), + first_name = coalesce(user_profiles.first_name, excluded.first_name), + last_name = coalesce(user_profiles.last_name, excluded.last_name); diff --git a/packages/db/src/accounts.js b/packages/db/src/accounts.js index ae6dba3..bf643e4 100644 --- a/packages/db/src/accounts.js +++ b/packages/db/src/accounts.js @@ -1,4 +1,4 @@ -import { pgArray, sql, withJson } from './index.js'; +import { jsonb, pgArray, sql, withJson } from './index.js'; /* ------------------------------------------------------------- accounts -- */ @@ -26,8 +26,37 @@ export async function countUsers() { return n; } -export async function insertLoginToken({ tokenHash, email, expiresAt }) { - await sql`insert into login_tokens ${sql({ token_hash: tokenHash, email, expires_at: expiresAt })}`; +/** + * `profile` is the validated signup form (@profullstack/profile fields), held on + * the token because the account row does not exist until the link is spent. + * Bound as text and cast via ::text: Bun SQL binds a bare `::jsonb` string as a JSON string scalar. + */ +export async function insertLoginToken({ tokenHash, email, expiresAt, profile = null }) { + await sql` + insert into login_tokens (token_hash, email, expires_at, profile) + values (${tokenHash}, ${email}, ${expiresAt}, ${profile ? JSON.stringify(profile) : null}::text::jsonb) + `; +} + +/** + * The signup fields a just-spent link carried, handed out once and then cleared. + * Only a consumed token answers, so this can never run ahead of the sign-in. + */ +export async function takeSignupProfile(tokenHash) { + // RETURNING sees the row after the update, so the old value comes from a + // subquery: `returning profile` here would always hand back the null. + const [row] = await sql` + update login_tokens t set profile = null + from ( + select token_hash, profile from login_tokens + where token_hash = ${tokenHash} and consumed_at is not null and profile is not null + for update + ) old + where t.token_hash = old.token_hash + returning old.profile + `; + if (!row) return null; + return jsonb(row.profile, null); } /** Single-use by construction: the update is the consumption. Returns the address. */ diff --git a/test/api.test.js b/test/api.test.js index 4379661..a30e805 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -375,6 +375,105 @@ if (!url) { ).status, ).toBe(400); }); + test('signup asks for the profile fields; sign-in does not', async () => { + const signup = await ( + await app.request('/signup', { headers: { accept: 'text/html' } }) + ).text(); + for (const name of ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url']) + expect(signup).toContain(`name="${name}"`); + expect(signup.match(/name="email"/g)?.length).toBe(1); + expect(signup).toContain('/vendor-profile.js'); + const login = await ( + await app.request('/login', { headers: { accept: 'text/html' } }) + ).text(); + expect(login).not.toContain('name="first_name"'); + const script = await app.request('/vendor-profile.js'); + expect(script.headers.get('content-type')).toContain('javascript'); + }); + test('a signup with the fields stores them on verify', async () => { + const post = (fields) => + app.request('/api/auth/magic', { + method: 'POST', + headers: { accept: 'text/html', 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ mode: 'signup', next: '/settings', ...fields }), + }); + await sql`delete from login_tokens where email = 'ada@acme.test'`; + await sql`delete from users where email = 'ada@acme.test'`; + // Refused before any link exists, with what was typed kept. + const refused = await post({ email: 'ada@acme.test', first_name: 'Ada' }); + expect(refused.status).toBe(400); + const page = await refused.text(); + expect(page).toContain('value="Ada"'); + expect(page).toContain('name="last_name"'); + expect((await sql`select 1 from login_tokens where email = 'ada@acme.test'`).length).toBe(0); + + const ok = await post({ + email: 'Ada@Acme.test', + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'https://www.acme.test/about', + job_title: 'VP Engineering', + linkedin_url: 'in/ada-lovelace', + }); + expect(ok.status).toBe(200); + // Carried on the token, normalized, as a jsonb object (not a JSON string). + const [row] = await sql` + select profile, jsonb_typeof(profile) as t from login_tokens + where email = 'ada@acme.test' and profile is not null limit 1 + `; + expect(row.t).toBe('object'); + // The emailed URL is not readable here, so a link is minted with the same + // carried fields; spending it must save them. + const carried = typeof row.profile === 'string' ? JSON.parse(row.profile) : row.profile; + const link = await auth.createLoginLink('ada@acme.test', { profile: carried }); + const r = await app.request(link.replace(/^https?:\/\/[^/]+/, ''), { + headers: { accept: 'text/html' }, + }); + expect(r.status).toBe(303); + const cookie = r.headers.get('set-cookie').split(';')[0]; + const got = await app.request('/api/v1/me/profile', { + headers: { accept: 'application/json', cookie }, + }); + expect(got.status).toBe(200); + expect(JSON.stringify(await got.json())).toContain('"company_domain":"acme.test"'); + const [saved] = await sql` + select p.* from user_profiles p join users u on u.id = p.user_id + where u.email = 'ada@acme.test' + `; + expect(saved).toMatchObject({ + email: 'ada@acme.test', + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'acme.test', + job_title: 'VP Engineering', + linkedin_url: 'https://www.linkedin.com/in/ada-lovelace', + }); + // Settings shows the same fields, prefilled, and saves them without script. + const settings = await app.request('/settings', { headers: { accept: 'text/html', cookie } }); + expect(await settings.text()).toContain('value="VP Engineering"'); + const edit = await app.request('/settings/contact', { + method: 'POST', + headers: { + accept: 'text/html', + cookie, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ first_name: 'Ada', last_name: 'King', job_title: 'CTO' }), + }); + expect(edit.status).toBe(303); + const [edited] = await sql` + select p.last_name, p.job_title from user_profiles p join users u on u.id = p.user_id + where u.email = 'ada@acme.test' + `; + expect(edited).toEqual({ last_name: 'King', job_title: 'CTO' }); + // Handed out once: the spent token no longer holds it. + const spent = await sql` + select 1 from login_tokens + where email = 'ada@acme.test' and consumed_at is not null and profile is not null + `; + expect(spent.length).toBe(0); + expect((await app.request('/api/v1/me/profile')).status).toBe(401); + }); }); describe('mcp', () => {