From fdd961de942434c0cfc9f776cc02c3fc4e0dde12 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 10:58:01 +0000 Subject: [PATCH] feat(profile): shared signup profile fields via @profullstack/profile Email signup now asks for first_name and last_name (required) plus company_domain, job_title and linkedin_url (optional). The static login page gets the package's own markup from /api/v1/profile/signup-fields.js (CSP allows no inline script) and shows it only when Sign up is pressed; Log in and CoinPay never ask. POST /api/auth/signup validates before creating the user and saves into user_profiles after (never fatal). GET/PUT/PATCH /api/v1/me/profile are the package's handlers. Migration 0008 in both editions. Co-Authored-By: Claude Opus 5.5 --- apps/web/public/login.html | 7 ++ apps/web/public/login.js | 42 ++++++++- .../migrations-pg/0008_user_profiles.sql | 19 ++++ .../storage/migrations/0008_user_profiles.sql | 17 ++++ pnpm-lock.yaml | 24 ++++++ services/api/package.json | 1 + services/api/src/index.ts | 11 ++- services/api/src/profile.test.ts | 74 ++++++++++++++++ services/api/src/profile.ts | 86 +++++++++++++++++++ 9 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 packages/storage/migrations-pg/0008_user_profiles.sql create mode 100644 packages/storage/migrations/0008_user_profiles.sql create mode 100644 services/api/src/profile.test.ts create mode 100644 services/api/src/profile.ts diff --git a/apps/web/public/login.html b/apps/web/public/login.html index 5e0f69c6..c6fff4f8 100644 --- a/apps/web/public/login.html +++ b/apps/web/public/login.html @@ -26,6 +26,11 @@ .msg{margin-top:12px;font-size:13px;min-height:18px} .ok{color:var(--cyan)} .err{color:#ff8a9b} a{color:var(--cyan)} .home{display:block;text-align:center;margin-top:16px;font-size:12px} + /* The shared signup fields (@profullstack/profile): the label/input rules above style them. */ + .pfs-profile{border:0;padding:0;margin:0;min-width:0} + .pfs-profile__optional{opacity:.7} + .pfs-profile input[aria-invalid=true]{border-color:#ff8a9b} + .pfs-profile__error{color:#ff8a9b;font-size:12px;margin:4px 0 0}
@@ -37,6 +42,7 @@ +
@@ -44,6 +50,7 @@
← back to tronbrowser.dev
+ diff --git a/apps/web/public/login.js b/apps/web/public/login.js index 9b898fb4..e58f015d 100644 --- a/apps/web/public/login.js +++ b/apps/web/public/login.js @@ -2,16 +2,42 @@ // plain link (full-page redirect through the OAuth flow). const $ = (id) => document.getElementById(id); +// Sign up also asks for the shared profile fields (@profullstack/profile), +// written into #signup-fields by /api/v1/profile/signup-fields.js. Log in never does. +const PROFILE_FIELDS = ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url']; + +function profileBody() { + const out = {}; + for (const name of PROFILE_FIELDS) { + const input = document.querySelector(`#signup-fields [name="${name}"]`); + if (input) out[name] = input.value; + } + return out; +} + +function markErrors(errors) { + for (const input of document.querySelectorAll('#signup-fields input')) { + if (errors && errors[input.name]) input.setAttribute('aria-invalid', 'true'); + else input.removeAttribute('aria-invalid'); + } +} + async function go(path) { $('msg').textContent = '…'; $('msg').className = 'msg'; + const signup = path.endsWith('signup'); const r = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ email: $('email').value, password: $('password').value }), + body: JSON.stringify({ + email: $('email').value, + password: $('password').value, + ...(signup ? profileBody() : {}), + }), }); const d = await r.json().catch(() => ({})); + if (signup) markErrors(d.errors); if (r.ok) { $('msg').textContent = d.message || 'Signed in.'; $('msg').className = 'msg ok'; @@ -23,7 +49,19 @@ async function go(path) { } $('login').onclick = () => go('/api/auth/login'); -$('signup').onclick = () => go('/api/auth/signup'); +$('signup').onclick = () => { + const fields = $('signup-fields'); + // First press shows the signup fields; the second sends them. + if (fields.hidden) { + fields.hidden = false; + $('password').autocomplete = 'new-password'; + $('msg').textContent = 'Add your name, then press Sign up again.'; + $('msg').className = 'msg'; + document.querySelector('#signup-fields [name="first_name"]')?.focus(); + return; + } + go('/api/auth/signup'); +}; // Surface notices (e.g. expired verification link). const msg = new URLSearchParams(location.search).get('msg'); diff --git a/packages/storage/migrations-pg/0008_user_profiles.sql b/packages/storage/migrations-pg/0008_user_profiles.sql new file mode 100644 index 00000000..aa830216 --- /dev/null +++ b/packages/storage/migrations-pg/0008_user_profiles.sql @@ -0,0 +1,19 @@ +-- TronBrowser Postgres schema: 0008_user_profiles +-- The shared Profullstack profile (@profullstack/profile): email, first and last +-- name, company domain, job title and LinkedIn URL, one row per user. Asked for +-- on email signup; owner-only through /api/v1/me/profile. Generated with +-- `profile schema --key user_id --key-type text`. No backfill: users has no name. + +create table if not exists "user_profiles" ( + "user_id" text 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"); diff --git a/packages/storage/migrations/0008_user_profiles.sql b/packages/storage/migrations/0008_user_profiles.sql new file mode 100644 index 00000000..59e1afda --- /dev/null +++ b/packages/storage/migrations/0008_user_profiles.sql @@ -0,0 +1,17 @@ +-- The shared Profullstack profile (@profullstack/profile): email, first and last +-- name, company domain, job title and LinkedIn URL, one row per user. Legacy +-- SQLite edition of migrations-pg/0008_user_profiles.sql. + +create table if not exists "user_profiles" ( + "user_id" text primary key, + "email" text, + "first_name" text, + "last_name" text, + "company_domain" text, + "job_title" text, + "linkedin_url" text, + "created_at" text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + "updated_at" text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','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"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a86a9ed3..03a23b8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,9 @@ importers: '@profullstack/libsql-pg': specifier: ^0.1.1 version: 0.1.1(@libsql/client@0.14.0) + '@profullstack/profile': + specifier: 0.1.3 + version: 0.1.3(@libsql/client@0.14.0)(react@19.2.7) '@profullstack/x402-gateway': specifier: ^0.11.0 version: 0.11.0 @@ -1285,6 +1288,22 @@ packages: resolution: {integrity: sha512-BtP67qx/+AQuKNtgIoxZWS2cTr1zc2hKTP7mqPhVpiEoxAgCEYHqtr+5aMRRAu+mmElXQSKjaU7ydbGKzG+WPg==} engines: {node: '>=20.11'} + '@profullstack/profile@0.1.3': + resolution: {integrity: sha512-P+VdOqCnNGWa5W8EGLU/ls3hwovRcY8bOBOhWZp3h23sW2LDMFyeWF/pUfO+iiYH3E2Tie2/9xyUnpO4ctwSHw==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + '@libsql/client': '>=0.6.0' + postgres: ^3.4.0 + react: '>=18' + peerDependenciesMeta: + '@libsql/client': + optional: true + postgres: + optional: true + react: + optional: true + '@profullstack/referrals@0.1.0': resolution: {integrity: sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg==} engines: {node: '>=18'} @@ -5046,6 +5065,11 @@ snapshots: '@profullstack/notifications@0.1.3': {} + '@profullstack/profile@0.1.3(@libsql/client@0.14.0)(react@19.2.7)': + optionalDependencies: + '@libsql/client': 0.14.0 + react: 19.2.7 + '@profullstack/referrals@0.1.0(react@19.2.7)': optionalDependencies: react: 19.2.7 diff --git a/services/api/package.json b/services/api/package.json index 4a1b1eaf..1976f61f 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -22,6 +22,7 @@ "@logicsrc/agentswarm": "^0.1.0", "@profullstack/coinpay-onboard": "0.2.0", "@profullstack/libsql-pg": "^0.1.1", + "@profullstack/profile": "0.1.3", "@profullstack/x402-gateway": "^0.11.0", "@tronbrowser/sdk": "workspace:*", "deepagents": "^1.10.5", diff --git a/services/api/src/index.ts b/services/api/src/index.ts index e5100eba..7eaf707a 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -19,6 +19,7 @@ import { extLoginTarget } from './ext-login.js'; import { assertDatabaseUrl, db } from './db.js'; import { pushService } from './push/routes.js'; import { createCrawlGate, crawlCheck } from './crawl-gate.js'; +import { mountProfile, profileStore, saveSignup, signupProfileErrors } from './profile.js'; // Fail fast: a missing or non-Postgres DATABASE_URL is a deploy error, not a // condition to limp along under (there is no file-database fallback). @@ -170,11 +171,16 @@ app.get('/api/auth/coinpay/callback', async (c) => { /* ---------- Email + password ---------- */ app.post('/api/auth/signup', async (c) => { - const { email, password } = await c.req.json().catch(() => ({})); + const body = await c.req.json().catch(() => ({})); + const { email, password } = body; if (!email || !password || password.length < 8) return c.json({ error: 'email and 8+ char password required' }, 400); + // The shared profile fields (first + last name required), checked before any account exists. + const invalid = signupProfileErrors(body); + if (invalid) return c.json(invalid, 400); if (await userByEmail(email)) return c.json({ error: 'email already registered' }, 409); const id = uuid(); await createUser({ id, authMethod: 'password', email, passwordHash: hashPassword(password), emailVerified: false }); + await saveSignup(profileStore(), id, body, email); const vtok = token(); await putEmailToken(vtok, id, 'verify', EMAIL_TOKEN_TTL); const link = `${baseUrl(c)}/api/auth/verify?token=${vtok}`; @@ -212,6 +218,9 @@ app.get('/api/auth/me', async (c) => { return c.json({ signedIn: true, id: user.id, authMethod: user.auth_method, email: user.email, emailVerified: !!user.email_verified }); }); +/* ---------- Shared profile (@profullstack/profile): /api/v1/me/profile ---------- */ +mountProfile(app, { currentUser }); + /* ---------- Settings sync ---------- */ app.get('/api/settings', async (c) => { const user = await currentUser(c); diff --git a/services/api/src/profile.test.ts b/services/api/src/profile.test.ts new file mode 100644 index 00000000..06539aa0 --- /dev/null +++ b/services/api/src/profile.test.ts @@ -0,0 +1,74 @@ +import { createClient } from '@libsql/client'; +import { createProfileStore } from '@profullstack/profile'; +import { Hono } from 'hono'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { mountProfile, saveSignup, signupProfileErrors, SIGNUP_FIELDS_SCRIPT } from './profile.js'; + +// The package's store on an in-memory SQLite: what actually lands in user_profiles. +const store = createProfileStore(createClient({ url: 'file::memory:' })); +beforeAll(async () => { + await store.migrate(); +}); + +const SIGNUP = { + email: 'Ada@Acme.com', + password: 'x'.repeat(12), + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'https://www.acme.com/about', + job_title: 'VP Engineering', + linkedin_url: 'in/ada-lovelace', +}; + +describe('signup profile fields', () => { + it('refuses a signup without a name before any account exists', () => { + const invalid = signupProfileErrors({ email: 'ada@acme.com', password: 'x'.repeat(12) }); + expect(invalid).not.toBeNull(); + expect(Object.keys(invalid!.errors)).toEqual(expect.arrayContaining(['first_name', 'last_name'])); + expect(signupProfileErrors(SIGNUP)).toBeNull(); + }); + + it('a signup with the fields stores them, normalized', async () => { + await saveSignup(store, 'user-1', SIGNUP, SIGNUP.email); + expect(await store.get('user-1')).toMatchObject({ + email: 'ada@acme.com', + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'acme.com', + job_title: 'VP Engineering', + linkedin_url: 'https://www.linkedin.com/in/ada-lovelace', + }); + }); + + it('the static login page gets the five fields from the package, not email', () => { + for (const name of ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url']) { + expect(SIGNUP_FIELDS_SCRIPT).toContain(`name=\\"${name}\\"`); + } + expect(SIGNUP_FIELDS_SCRIPT).not.toContain('name=\\"email\\"'); + }); +}); + +describe('/api/v1/me/profile', () => { + const app = new Hono(); + mountProfile(app, { + currentUser: async (c: any) => + c.req.header('authorization') === 'Bearer ok' ? { id: 'user-1', email: 'ada@acme.com' } : null, + store: () => store, + }); + + it('is 401 without a session', async () => { + expect((await app.request('/api/v1/me/profile')).status).toBe(401); + }); + + it('reads and patches the owner profile', async () => { + const got = await app.request('/api/v1/me/profile', { headers: { authorization: 'Bearer ok' } }); + expect((await got.json()).profile.job_title).toBe('VP Engineering'); + const patched = await app.request('/api/v1/me/profile', { + method: 'PATCH', + headers: { authorization: 'Bearer ok', 'content-type': 'application/json' }, + body: JSON.stringify({ job_title: 'CTO' }), + }); + expect(patched.status).toBe(200); + expect((await patched.json()).profile.job_title).toBe('CTO'); + }); +}); diff --git a/services/api/src/profile.ts b/services/api/src/profile.ts new file mode 100644 index 00000000..d633f316 --- /dev/null +++ b/services/api/src/profile.ts @@ -0,0 +1,86 @@ +import { + COLUMNS, + createProfileHandlers, + createProfileStore, + renderProfileFields, + saveSignupProfile, + validateProfile, + type ProfileStore, +} from '@profullstack/profile'; +import type { Hono } from 'hono'; +import { db } from './db.js'; + +/** + * The shared Profullstack profile (@profullstack/profile): email, first and + * last name, company domain, job title and LinkedIn URL, in `user_profiles`. + * Asked for on email signup only (never on log in, never for CoinPay accounts), + * and readable/editable by the owner at /api/v1/me/profile. + */ + +let _store: ProfileStore | null = null; +export function profileStore(): ProfileStore { + _store ??= createProfileStore(db()); + return _store; +} + +/** Signup shows the five fields beside the page's own email box. */ +export const SIGNUP_FIELDS = COLUMNS.filter((f) => f !== 'email'); + +/** + * Check a signup body's profile fields before any account exists. Returns the + * error response body, or null when it is fine. + */ +export function signupProfileErrors(body: unknown): { error: string; errors: Record } | null { + const check = validateProfile(body as Record); + if (check.ok) return null; + const errors = check.errors as Record; + return { error: Object.values(errors)[0] ?? 'Some fields need fixing.', errors }; +} + +/** Save them once the user exists. Logged, never fatal: the API can fill them later. */ +export async function saveSignup( + store: ProfileStore, + userId: string, + body: unknown, + email: string, +): Promise { + try { + await saveSignupProfile(store, userId, body as Record, { email }); + } catch (e) { + console.error('[signup] profile save failed:', (e as Error).message); + } +} + +/** + * The login page is static, so its signup fields come from here: a same-origin + * script (the CSP allows no inline script) that writes the package's own + * markup into #signup-fields. + */ +export const SIGNUP_FIELDS_SCRIPT = `(()=>{const el=document.getElementById('signup-fields');if(el)el.innerHTML=${JSON.stringify( + renderProfileFields({ fields: SIGNUP_FIELDS }), +)};})();\n`; + +/** GET/PUT/PATCH /api/v1/me/profile plus the signup-fields script. */ +export function mountProfile( + app: Hono, + { currentUser, store = profileStore }: { currentUser: (c: any) => Promise<{ id: string; email: string | null } | null>; store?: () => ProfileStore }, +): void { + let handlers: ReturnType | null = null; + const h = () => + (handlers ??= createProfileHandlers({ + store: store(), + getUser: async (_req, c) => { + const user = await currentUser(c); + return user ? { id: user.id, email: user.email } : null; + }, + })); + app.get('/api/v1/me/profile', (c) => h().GET(c.req.raw, c)); + app.put('/api/v1/me/profile', (c) => h().PUT(c.req.raw, c)); + app.patch('/api/v1/me/profile', (c) => h().PATCH(c.req.raw, c)); + app.get('/api/v1/profile/signup-fields.js', (c) => + c.body(SIGNUP_FIELDS_SCRIPT, 200, { + 'content-type': 'text/javascript; charset=utf-8', + 'cache-control': 'public, max-age=300', + }), + ); +}