From 68dfcec6e38b0b9474a133e6dace2db792822547 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 10:50:22 +0000 Subject: [PATCH] feat: signup profile fields via @profullstack/profile Create account now asks for first and last name (required) plus company domain, job title and LinkedIn (optional), validated with the shared @profullstack/profile package before the account is created and saved to its user_profiles table (keyed by the auth-system user id) afterwards. GET/PUT/PATCH /api/v1/me/profile serves it, and the signed-in account sheet lets people fill in or change what they skipped. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 +++ package.json | 1 + src/server/accounts.js | 61 ++++++++++++++++++++++++++++++++++++++++-- src/server/server.js | 21 +++++++++++++-- test/accounts.test.js | 50 ++++++++++++++++++++++++++++++++++ test/cli-sync.test.js | 8 +++++- web/public/app.css | 14 ++++++++++ web/src/account.js | 6 ++++- web/src/app.js | 33 +++++++++++++++++++++-- 9 files changed, 189 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 6fb0acc..9348d4b 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.10.3", "@profullstack/player": "^0.14.0", + "@profullstack/profile": "0.1.3", "@profullstack/x402-gateway": "^0.11.0", "jsonwebtoken": "^9.0.2", "pg": "^8.23.1", @@ -90,6 +91,8 @@ "@profullstack/player": ["@profullstack/player@0.14.0", "", { "dependencies": { "hls.js": "^1.7.1", "mpegts.js": "^1.8.2" }, "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-HBDNkb17jGhZrkpksCp1iB07yWjfKIeDQq8LarhA04bKBZdX+vkRRTN1gsxXiAM3T4t0Zxj+99dpTA0uPtKofw=="], + "@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/x402-gateway": ["@profullstack/x402-gateway@0.11.0", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-Lfrz+SRTwQZfcoCaVtaaMNPJx74Lp/5oyS7vJJAEuJmH1TjBzhE7dWquISkj1p42oc+VW5I9xH9p7YhEKqmr4Q=="], "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], diff --git a/package.json b/package.json index d2c889e..7268c1b 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.10.3", "@profullstack/player": "^0.14.0", + "@profullstack/profile": "0.1.3", "@profullstack/x402-gateway": "^0.11.0", "jsonwebtoken": "^9.0.2", "pg": "^8.23.1" diff --git a/src/server/accounts.js b/src/server/accounts.js index 79914e6..e7a131d 100644 --- a/src/server/accounts.js +++ b/src/server/accounts.js @@ -14,6 +14,13 @@ import { AuthSystem, MemoryAdapter, PostgresAdapter } from '@profullstack/auth-s import { createOAuthServer, memoryStore as oauthMemoryStore } from '@profullstack/auth-system/oauth2'; import { OAUTH2_SCHEMA, postgresStore as oauthPostgresStore } from '@profullstack/auth-system/oauth2/postgres'; import { createEmailer } from '@profullstack/emailer'; +import { + createProfileHandlers, + createProfileStore, + saveSignupProfile, + schemaSql, + validateProfile, +} from '@profullstack/profile'; import jwt from 'jsonwebtoken'; /** The CLI, TUI and stdio MCP sign in as this client (code + PKCE, loopback). */ @@ -72,6 +79,9 @@ function postgresStore(adapter) { blob TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`); + // The shared profile (name, company, title, LinkedIn): @profullstack/profile's + // own user_profiles table, keyed by the auth-system user id (text). + await adapter.query(schemaSql({ keyType: 'text' })); })()); return { init, @@ -175,6 +185,21 @@ export function createAccounts(opts = {}) { opts.adapter || (env.DATABASE_URL ? new PostgresAdapter({ connectionString: env.DATABASE_URL }) : new MemoryAdapter()); const store = adapter instanceof PostgresAdapter ? postgresStore(adapter) : memoryStore(adapter); + // Profiles ride the same pg pool; with no database, an in-memory SQLite. + let profilesReady = null; + const profileStore = () => + (profilesReady ??= (async () => { + if (adapter instanceof PostgresAdapter) { + await store.init(); + return createProfileStore(adapter, { keyType: 'text' }); + } + const db = globalThis.Bun + ? new (await import('bun:sqlite')).Database(':memory:') + : new (await import('node:sqlite')).DatabaseSync(':memory:'); + const profiles = createProfileStore(db); + await profiles.migrate(); + return profiles; + })()); const box = sealer(env.HDTILT_LIBRARY_KEY || `${secret || 'dev'}:library`); const mediaKey = createHash('sha256') .update(`${secret || 'hdtilt-dev-secret'}:media`) @@ -282,7 +307,7 @@ export function createAccounts(opts = {}) { await store.init(); await oauth.revoke(token); }, - async register({ username, email, password }) { + async register({ username, email, password, ...fields }) { username = String(username || '').trim(); email = String(email || '') .trim() @@ -291,10 +316,18 @@ export function createAccounts(opts = {}) { throw new Error('Pick a username of 3 to 30 letters, numbers, dots, dashes or underscores'); if (!EMAIL.test(email)) throw new Error('That email address does not look right'); if (String(password || '').length < 8) throw new Error('Use a password of at least 8 characters'); + const check = validateProfile({ ...fields, email }); + if (!check.ok) { + const err = new Error(Object.values(check.errors)[0]); + err.code = 'invalid_profile'; + err.fields = check.errors; + throw err; + } await store.init(); if (await store.emailForUsername(username)) throw new Error('That username is taken'); + let created; try { - await auth.register({ email, password, profile: { username } }); + created = await auth.register({ email, password, profile: { username } }); } catch (e) { // Same answer whether or not the address exists, so the form cannot be // used to find out who has an account. @@ -302,8 +335,32 @@ export function createAccounts(opts = {}) { if (/unique|duplicate/i.test(e.message)) throw new Error('That username is taken'); throw e; } + try { + await saveSignupProfile(await profileStore(), created.user.id, check.profile, { email }); + } catch (e) { + // The account exists and the email is on its way: a profile that did not + // save is filled in later from the account sheet, never a failed signup. + console.warn(`hdtilt: signup profile not saved: ${e.message}`); + } return { ok: true, sent: true }; }, + /** GET/PUT/PATCH /api/v1/me/profile for a signed-in user (Web Request in, Response out). */ + async profile(claims, request) { + const handlers = createProfileHandlers({ + store: await profileStore(), + getUser: async () => { + const u = await adapter.getUserById(claims.userId); + return u ? { id: u.id, email: u.email } : null; + }, + }); + const handle = handlers[request.method]; + return handle + ? handle(request) + : new Response(JSON.stringify({ error: 'GET, PUT or PATCH' }), { + status: 405, + headers: { 'content-type': 'application/json' }, + }); + }, async verify(token) { let r; try { diff --git a/src/server/server.js b/src/server/server.js index aab7ea0..412fa37 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -240,7 +240,8 @@ export function createApp(opts = {}) { path.startsWith('/api/auth/') || path === '/api/me' || path === '/api/library' || - path === '/api/oauth/authorize' + path === '/api/oauth/authorize' || + path === '/api/v1/me/profile' ) { if (!accounts) return send(res, 404, { error: 'accounts are not enabled on this server' }); return account(req, res, path); @@ -385,7 +386,11 @@ export function createApp(opts = {}) { async function account(req, res, path) { const fail = (e, status = 400) => - send(res, e.code === 'conflict' ? 409 : status, { error: e.message, ...(e.code ? { code: e.code } : {}) }); + send(res, e.code === 'conflict' ? 409 : status, { + error: e.message, + ...(e.code ? { code: e.code } : {}), + ...(e.fields ? { errors: e.fields } : {}), + }); if (path.startsWith('/api/auth/')) { if (req.method !== 'POST') return send(res, 405, { error: 'POST' }); if (limited(clientIp(req))) return send(res, 429, { error: 'Too many tries; wait a minute' }); @@ -431,6 +436,18 @@ export function createApp(opts = {}) { if (!claims) return send(res, 401, { error: 'Sign in first' }); try { if (path === '/api/me') return send(res, 200, await accounts.me(claims)); + if (path === '/api/v1/me/profile') { + const write = req.method === 'PUT' || req.method === 'PATCH'; + const r = await accounts.profile( + claims, + new Request(`http://hdtilt.local${path}`, { + method: req.method, + headers: { 'content-type': 'application/json' }, + ...(write ? { body: JSON.stringify(await readJson(req)) } : {}), + }), + ); + return send(res, r.status, await r.json()); + } // The consent page: only a browser session (not another OAuth token) may grant one. if (path === '/api/oauth/authorize') { if (claims.via === 'oauth') return send(res, 403, { error: 'Sign in on the website to approve' }); diff --git a/test/accounts.test.js b/test/accounts.test.js index 13e2241..1443ce8 100644 --- a/test/accounts.test.js +++ b/test/accounts.test.js @@ -43,6 +43,11 @@ describe('sign up', () => { username: 'Viewer_1', email: 'Viewer@Example.org', password: 'couch-potato', + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'https://www.Acme.com/about', + job_title: 'VP Engineering', + linkedin_url: 'in/ada-lovelace', }); expect(r.status).toBe(200); expect(mail).toHaveLength(1); @@ -53,6 +58,18 @@ describe('sign up', () => { expect((await login.json()).code).toBe('unverified'); }); + it('needs a first and last name', async () => { + const r = await post('/api/auth/register', { + username: 'nameless', + email: 'n@example.org', + password: 'longenough', + }); + expect(r.status).toBe(400); + const body = await r.json(); + expect(body.code).toBe('invalid_profile'); + expect(Object.keys(body.errors).sort()).toEqual(['first_name', 'last_name']); + }); + it('can send the link again', async () => { await post('/api/auth/resend', { email: 'viewer@example.org' }); expect(mail).toHaveLength(2); @@ -88,6 +105,8 @@ describe('sign up', () => { username: 'viewer_1', email: 'other@example.org', password: 'couch-potato', + first_name: 'Grace', + last_name: 'Hopper', }); expect((await r.json()).error).toContain('taken'); }); @@ -97,6 +116,8 @@ describe('sign up', () => { username: 'someone', email: 'viewer@example.org', password: 'couch-potato', + first_name: 'Grace', + last_name: 'Hopper', }); expect(r.status).toBe(200); }); @@ -119,6 +140,35 @@ describe('sign in', () => { }); }); +describe('profile', () => { + const me = (init = {}) => + fetch(at('/api/v1/me/profile'), { + ...init, + headers: { 'content-type': 'application/json', authorization: `Bearer ${session.accessToken}` }, + }); + it('needs a session', async () => { + const r = await fetch(at('/api/v1/me/profile')); + expect(r.status).toBe(401); + expect((await r.json()).error).toBeTruthy(); + }); + it('kept what the signup form gave, normalized', async () => { + const { profile } = await (await me()).json(); + expect(profile).toMatchObject({ + email: 'viewer@example.org', + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'acme.com', + job_title: 'VP Engineering', + linkedin_url: 'https://www.linkedin.com/in/ada-lovelace', + }); + }); + it('takes a PATCH from the account sheet', async () => { + const r = await me({ method: 'PATCH', body: JSON.stringify({ job_title: 'CTO' }) }); + expect(r.status).toBe(200); + expect((await (await me()).json()).profile).toMatchObject({ job_title: 'CTO', first_name: 'Ada' }); + }); +}); + describe('library', () => { const get = (token) => fetch(at('/api/library'), { headers: { authorization: `Bearer ${token}` } }); const put = (token, body) => diff --git a/test/cli-sync.test.js b/test/cli-sync.test.js index 2d4781a..1bd3581 100644 --- a/test/cli-sync.test.js +++ b/test/cli-sync.test.js @@ -53,7 +53,13 @@ beforeAll(async () => { env: { SITE_URL: base, HDTILT_JWT_SECRET: 'cli-secret-cli-secret-cli-secret' }, }); srv = await startServer({ port, public: true, accounts, authPerMinute: 1000 }); - await post('/api/auth/register', { username: 'tvroom', email: 'tv@example.org', password: 'remote-control' }); + await post('/api/auth/register', { + username: 'tvroom', + email: 'tv@example.org', + password: 'remote-control', + first_name: 'Tv', + last_name: 'Room', + }); const token = new URL(mail[0].text.match(/https?:\/\/\S+/)[0]).searchParams.get('token'); jwt = (await post('/api/auth/verify', { token })).tokens.accessToken; // What the web app would have synced from another device. diff --git a/web/public/app.css b/web/public/app.css index 1a690f1..512e637 100644 --- a/web/public/app.css +++ b/web/public/app.css @@ -691,6 +691,20 @@ label { .field:focus { border-color: var(--accent); } +.pfs-profile { + border: 0; + margin: 0; + padding: 0; + min-width: 0; +} +.pfs-profile__optional { + opacity: 0.7; +} +.pfs-profile__error { + color: #fca5a5; + font-size: 0.85rem; + margin: 0.3rem 0 0; +} .actions { display: flex; gap: 0.6rem; diff --git a/web/src/account.js b/web/src/account.js index d0c4b25..61ffe51 100644 --- a/web/src/account.js +++ b/web/src/account.js @@ -83,7 +83,11 @@ export async function signIn(login, password) { await renewMediaCookie(); return r.user; } -export const register = (username, email, password) => call('/api/auth/register', { username, email, password }); +export const register = (username, email, password, profile = {}) => + call('/api/auth/register', { ...profile, username, email, password }); +/** The shared profile (name, company, title, LinkedIn) for the signed-in account. */ +export const getProfile = () => call('/api/v1/me/profile', undefined, { auth: true }); +export const saveProfile = (fields) => call('/api/v1/me/profile', fields, { auth: true, method: 'PATCH' }); export const resend = (email) => call('/api/auth/resend', { email }); export const forgot = (email) => call('/api/auth/forgot', { email }); export const reset = (token, password) => call('/api/auth/reset', { token, password }); diff --git a/web/src/app.js b/web/src/app.js index 5480a90..8508aba 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -2,6 +2,7 @@ // overlay that a remote, a keyboard, a mouse or a finger can drive. import { attachSource, isTvBrowser } from '@profullstack/player'; +import { renderProfileFields } from '@profullstack/profile'; import { indexAt, nowNext, progress } from '../../src/core/epg.js'; import { filterByWords } from '../../src/core/match.js'; import { catchupUrl, groupsOf } from '../../src/core/sources.js'; @@ -1849,6 +1850,15 @@ const busy = async (btn, label, fn) => { } }; +/** The shared profile fields (@profullstack/profile); the form already has its own email box. */ +const PROFILE_FIELDS = ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url']; +const profileFields = (values = {}) => + renderProfileFields({ fields: PROFILE_FIELDS, idPrefix: 'a-', values }).replaceAll( + ' Object.fromEntries(PROFILE_FIELDS.map((n) => [n, form.elements[n].value.trim()])); + /** Sign in, create an account, or (signed in) the account itself. `after` runs after sign-in. */ function openAccount({ tab = 'in', after = null, note = '' } = {}) { const u = account.current(); @@ -1859,8 +1869,24 @@ function openAccount({ tab = 'in', after = null, note = '' } = {}) {

${esc(u.email)}

Your playlists, favorites and recent channels follow you to every device you sign in on, including hdtilt tui after hdtilt login.

-
`, +
+
${profileFields()}
+
`, ); + const pf = sh.querySelector('#pf'); + account + .getProfile() + .then(({ profile }) => { + for (const n of PROFILE_FIELDS) if (profile?.[n]) pf.elements[n].value = profile[n]; + }) + .catch(() => {}); + pf.onsubmit = (e) => { + e.preventDefault(); + $('p-err').textContent = ''; + busy(e.submitter, 'Saving…', () => account.saveProfile(profileValues(pf))) + .then(() => toast('Profile saved')) + .catch((x) => ($('p-err').textContent = x.message)); + }; sh.querySelector('#a-sync').onclick = (e) => busy(e.target, 'Syncing…', () => account.syncNow()) .then(() => toast('Synced')) @@ -1884,6 +1910,7 @@ function openAccount({ tab = 'in', after = null, note = '' } = {}) { tab === 'up' ? ` + ${profileFields()} ` : ` ` @@ -1908,7 +1935,9 @@ function openAccount({ tab = 'in', after = null, note = '' } = {}) { try { if (tab === 'up') { const email = $('a-mail').value.trim(); - await busy(btn, 'Creating…', () => account.register($('a-user').value.trim(), email, $('a-pass').value)); + await busy(btn, 'Creating…', () => + account.register($('a-user').value.trim(), email, $('a-pass').value, profileValues($('af'))), + ); return openCheckEmail(email); } await busy(btn, 'Signing in…', () => account.signIn($('a-login').value.trim(), $('a-pass').value));