From c07f21cc96a57e4db802586ac997d5b55228360e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 10:56:10 +0000 Subject: [PATCH] feat: profile fields on /register (@profullstack/profile) /register now asks for first and last name (required) plus company domain, job title and LinkedIn (optional) with the shared @profullstack/profile 0.1.3 component. POST /api/auth/signup-profile validates them on the server before supabase.auth.signUp, then stores them for the new, still unconfirmed account (same address, created in the last 15 minutes, no profile yet). The fields also ride in signUp user metadata and are saved on /auth/callback if that call never lands. GET/PUT/PATCH /api/v1/me/profile plus a Profile card on /settings. Stored in a new server-only user_profiles table (RLS on, no anon or authenticated policies). contacts.company is CRM data and is untouched. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 + package.json | 1 + src/app/api/__tests__/signup-profile.test.ts | 89 ++++++++++++++++++++ src/app/api/auth/signup-profile/route.ts | 63 ++++++++++++++ src/app/api/v1/me/profile/route.ts | 22 +++++ src/app/auth/callback/route.ts | 3 + src/app/globals.css | 8 ++ src/app/register/page.tsx | 47 +++++++++++ src/app/settings/page.tsx | 3 + src/components/profile-card.tsx | 67 +++++++++++++++ src/lib/profile.ts | 44 ++++++++++ supabase/migrations/016_user_profiles.sql | 29 +++++++ 12 files changed, 379 insertions(+) create mode 100644 src/app/api/__tests__/signup-profile.test.ts create mode 100644 src/app/api/auth/signup-profile/route.ts create mode 100644 src/app/api/v1/me/profile/route.ts create mode 100644 src/components/profile-card.tsx create mode 100644 src/lib/profile.ts create mode 100644 supabase/migrations/016_user_profiles.sql diff --git a/bun.lock b/bun.lock index 3333b27e..02813bb3 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "smshub", "dependencies": { "@profullstack/footer": "latest", + "@profullstack/profile": "0.1.3", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/throttle": "^0.3.3", @@ -288,6 +289,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/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], "@profullstack/stack": ["@profullstack/stack@0.1.3", "", { "dependencies": { "@profullstack/emailer": "1.0.1", "@profullstack/referrals": "0.1.0" }, "peerDependencies": { "@supabase/ssr": ">=0.5.0", "next": ">=13.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@supabase/ssr", "next", "react"] }, "sha512-NOseYE5cWMwH75/luUE46kJLyAd1xD+DSgGgwhxvgNMoJYfZ9W9vKmqdcGA/4Lgahzf7hWfjNvmLtVIzAqwhJw=="], diff --git a/package.json b/package.json index 842fe98f..6142ec06 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@profullstack/footer": "latest", + "@profullstack/profile": "0.1.3", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/throttle": "^0.3.3", diff --git a/src/app/api/__tests__/signup-profile.test.ts b/src/app/api/__tests__/signup-profile.test.ts new file mode 100644 index 00000000..e069c26c --- /dev/null +++ b/src/app/api/__tests__/signup-profile.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const getUserById = vi.fn(); +const upsert = vi.fn(); +const existing = vi.fn(); + +// A service-role supabase-js stand-in: auth.admin plus the user_profiles table. +vi.mock("@/lib/supabase/server", () => ({ + createServiceClient: () => ({ + auth: { admin: { getUserById } }, + from: () => ({ + select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: existing(), error: null }) }) }), + upsert: (row: Record, opts: unknown) => { + upsert(row, opts); + return { select: () => ({ single: async () => ({ data: row, error: null }) }) }; + }, + }), + }), +})); + +const fields = { + email: "Ada@Acme.com", + first_name: "Ada", + last_name: "Lovelace", + company_domain: "https://www.acme.com/about", + job_title: " VP Engineering ", + linkedin_url: "in/ada-lovelace", +}; + +function post(body: unknown) { + return new NextRequest("http://localhost/api/auth/signup-profile", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +describe("POST /api/auth/signup-profile", () => { + beforeEach(() => { + vi.clearAllMocks(); + existing.mockReturnValue(null); + }); + + it("refuses a signup without first and last name", async () => { + const { POST } = await import("@/app/api/auth/signup-profile/route"); + const res = await POST(post({ email: "ada@acme.com" })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(Object.keys(body.errors)).toEqual(expect.arrayContaining(["first_name", "last_name"])); + }); + + it("stores the six fields for the account signUp just created", async () => { + getUserById.mockResolvedValue({ + data: { user: { id: "u1", email: "ada@acme.com", created_at: new Date().toISOString(), email_confirmed_at: null } }, + error: null, + }); + const { POST } = await import("@/app/api/auth/signup-profile/route"); + const res = await POST(post({ ...fields, user_id: "u1" })); + expect(res.status).toBe(200); + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: "u1", + 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", + }), + { onConflict: "user_id" }, + ); + }); + + it("will not write a confirmed or someone else's account", async () => { + getUserById.mockResolvedValue({ + data: { user: { id: "u2", email: "ada@acme.com", created_at: new Date().toISOString(), email_confirmed_at: "2026-01-01" } }, + error: null, + }); + const { POST } = await import("@/app/api/auth/signup-profile/route"); + expect((await POST(post({ ...fields, user_id: "u2" }))).status).toBe(403); + getUserById.mockResolvedValue({ + data: { user: { id: "u3", email: "other@acme.com", created_at: new Date().toISOString(), email_confirmed_at: null } }, + error: null, + }); + expect((await POST(post({ ...fields, user_id: "u3" }))).status).toBe(403); + expect(upsert).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/signup-profile/route.ts b/src/app/api/auth/signup-profile/route.ts new file mode 100644 index 00000000..41974da6 --- /dev/null +++ b/src/app/api/auth/signup-profile/route.ts @@ -0,0 +1,63 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { COLUMNS, validateProfile, saveSignupProfile } from "@profullstack/profile"; +import { createServiceClient } from "@/lib/supabase/server"; +import { profileStore } from "@/lib/profile"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// A just-created, still unconfirmed account may have its profile written once, +// without a session, for this long after signUp. +const FRESH_MS = 15 * 60 * 1000; + +/** + * POST /api/auth/signup-profile: the @profullstack/profile fields on /register. + * + * Without user_id: validate only (called before supabase.auth.signUp so a + * missing name stops the signup). Returns { ok, profile } or 400 { errors }. + * + * With user_id (right after signUp, before the email is confirmed): store the + * fields for that new account. Accepted only for an unconfirmed account created + * in the last 15 minutes whose address matches and that has no profile yet, so + * nobody can write someone else's profile. The same fields also ride in user + * metadata and are saved on /auth/callback if this call never happens. + */ +export async function POST(request: NextRequest) { + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + + const input: Record = Object.fromEntries(COLUMNS.map((c) => [c, body[c] ?? null])); + const check = validateProfile(input); + if (!check.ok) { + return NextResponse.json( + { error: Object.values(check.errors)[0] ?? "Some fields need fixing.", errors: check.errors }, + { status: 400 }, + ); + } + + const userId = typeof body.user_id === "string" ? body.user_id : ""; + if (!userId) return NextResponse.json({ ok: true, profile: check.profile }); + + try { + const admin = createServiceClient(); + const { data, error } = await admin.auth.admin.getUserById(userId); + const user = data?.user; + const fresh = user && Date.now() - new Date(user.created_at).getTime() < FRESH_MS; + if ( + error || + !user || + !fresh || + user.email_confirmed_at || + (user.email ?? "").toLowerCase() !== check.profile.email + ) { + return NextResponse.json({ error: "Not allowed" }, { status: 403 }); + } + const store = profileStore(); + if (await store.get(user.id)) return NextResponse.json({ ok: true, saved: false }); + const saved = await saveSignupProfile(store, user.id, input, { email: user.email }); + return NextResponse.json({ ok: saved.ok, saved: saved.ok }); + } catch (e) { + console.error("[signup-profile] save failed", e); + return NextResponse.json({ error: "Could not save profile" }, { status: 500 }); + } +} 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 00000000..c4732db1 --- /dev/null +++ b/src/app/api/v1/me/profile/route.ts @@ -0,0 +1,22 @@ +import { createProfileHandlers } from "@profullstack/profile"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { profileStore } from "@/lib/profile"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET / PUT / PATCH /api/v1/me/profile: the signed-in user's +// @profullstack/profile fields (session cookie). +const handlers = () => + createProfileHandlers({ + store: profileStore(), + getUser: async () => { + const supabase = await createServerSupabaseClient(); + const { data } = await supabase.auth.getUser(); + return data.user ? { id: data.user.id, email: data.user.email ?? null } : null; + }, + }); + +export const GET = (req: Request) => handlers().GET(req); +export const PUT = (req: Request) => handlers().PUT(req); +export const PATCH = (req: Request) => handlers().PATCH(req); diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 5e9c795b..235bad03 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getBrandSiteUrl } from "@/lib/brand-server"; import { safeNextPath } from "@/lib/safe-next"; +import { saveProfileFromMetadata } from "@/lib/profile"; export { safeNextPath }; @@ -47,6 +48,8 @@ export async function GET(request: NextRequest) { data: { user }, } = await supabase.auth.getUser(); signedIn = Boolean(user); + // First confirmation: store the /register profile fields (never blocks). + if (user) await saveProfileFromMetadata(user); } catch (error) { console.error("Auth callback error:", error); signedIn = false; diff --git a/src/app/globals.css b/src/app/globals.css index 0c96f3bb..83a7909f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -114,3 +114,11 @@ blocked slot leaves no hole. Low profile: one thin text_link line. */ .cp-ad { height: 0; overflow: hidden; } .cp-ad[data-cp-filled] { height: auto; overflow: visible; margin: 0.5rem 0; } + +/* @profullstack/profile fields: same look as the register/settings inputs. */ +.pfs-profile { border: 0; margin: 0; padding: 0; min-width: 0; display: grid; gap: 1rem; } +.pfs-profile__row label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; font-weight: 500; color: var(--color-gray-300); } +.pfs-profile__row input { width: 100%; padding: 0.5rem 0.75rem; background: var(--color-gray-900); border: 1px solid var(--color-gray-700); border-radius: 0.5rem; } +.pfs-profile__row input:focus { outline: none; border-color: transparent; box-shadow: 0 0 0 2px var(--color-blue-500); } +.pfs-profile__optional { font-weight: 400; color: var(--color-gray-500); } +.pfs-profile__error { margin-top: 0.25rem; font-size: 0.875rem; color: var(--color-red-400); } diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index c620b49d..a04d73cb 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -6,11 +6,20 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { Logo } from "@/components/logo"; import { nextFromLocation } from "@/lib/safe-next"; +import { ProfileFields } from "@profullstack/profile/react"; +import type { ProfileField } from "@profullstack/profile"; + +// Email has its own input; the shared @profullstack/profile component renders +// the other five (first/last name required, the rest optional). +const PROFILE_FIELDS: ProfileField[] = ["first_name", "last_name", "company_domain", "job_title", "linkedin_url"]; +type ProfileValues = Partial>; export default function RegisterPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [profile, setProfile] = useState({}); + const [fieldErrors, setFieldErrors] = useState>>({}); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [confirmSent, setConfirmSent] = useState(false); @@ -28,6 +37,7 @@ export default function RegisterPage() { e.preventDefault(); setLoading(true); setError(null); + setFieldErrors({}); if (password !== confirmPassword) { setError("Passwords do not match"); @@ -41,6 +51,23 @@ export default function RegisterPage() { return; } + // Validate the profile fields on the server before creating the account. + const fields = { ...Object.fromEntries(PROFILE_FIELDS.map((k) => [k, profile[k] ?? ""])), email }; + const check = await fetch("/api/auth/signup-profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(fields), + }) + .then((r) => r.json()) + .catch(() => null); + if (!check?.ok) { + setFieldErrors(check?.errors ?? {}); + setError(check?.error ?? "Could not check your details. Try again."); + setLoading(false); + return; + } + const p = check.profile as ProfileValues; + const supabase = createClient(); const { data, error } = await supabase.auth.signUp({ email, @@ -49,9 +76,21 @@ export default function RegisterPage() { emailRedirectTo: `${window.location.origin}/auth/callback${ next === "/inbox" ? "" : `?next=${encodeURIComponent(next)}` }`, + // Also saved on /auth/callback if the call below never lands. + data: Object.fromEntries(PROFILE_FIELDS.map((k) => [k, p[k] ?? null])), }, }); + // An already-registered address comes back with no identities: never + // write a profile for it. Best effort; the account exists either way. + if (!error && data.user && (data.user.identities?.length ?? 0) > 0) { + await fetch("/api/auth/signup-profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...fields, user_id: data.user.id }), + }).catch(() => null); + } + if (error) { setError(error.message); setLoading(false); @@ -112,6 +151,14 @@ export default function RegisterPage() { /> + +
+ + {/* Rented numbers */}
diff --git a/src/components/profile-card.tsx b/src/components/profile-card.tsx new file mode 100644 index 00000000..4626bb65 --- /dev/null +++ b/src/components/profile-card.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ProfileFields } from "@profullstack/profile/react"; +import type { ProfileField } from "@profullstack/profile"; + +type Values = Partial>; +const FIELDS: ProfileField[] = ["email", "first_name", "last_name", "company_domain", "job_title", "linkedin_url"]; + +// The shared @profullstack/profile fields, read and written through +// /api/v1/me/profile. +export function ProfileCard() { + const [values, setValues] = useState({}); + const [errors, setErrors] = useState>>({}); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + + useEffect(() => { + fetch("/api/v1/me/profile") + .then((r) => (r.ok ? r.json() : null)) + .then((body) => { + const p = body?.profile ?? {}; + setValues(Object.fromEntries(FIELDS.map((k) => [k, p[k] ?? ""]))); + }) + .catch(() => setMessage("Could not load your profile.")) + .finally(() => setLoaded(true)); + }, []); + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setMessage(null); + setErrors({}); + const r = await fetch("/api/v1/me/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }).catch(() => null); + const body = r ? await r.json().catch(() => ({})) : {}; + if (r?.ok) setMessage("Saved."); + else { + setErrors(body.errors ?? {}); + setMessage(body.error ?? "Could not save."); + } + setSaving(false); + }; + + return ( +
+

Profile

+
+ +
+ + {message && {message}} +
+ +
+ ); +} diff --git a/src/lib/profile.ts b/src/lib/profile.ts new file mode 100644 index 00000000..091fa681 --- /dev/null +++ b/src/lib/profile.ts @@ -0,0 +1,44 @@ +import { + COLUMNS, + createProfileStore, + validateProfile, + type ProfileStore, +} from "@profullstack/profile"; +import { createServiceClient } from "@/lib/supabase/server"; + +// The shared @profullstack/profile fields live in public.user_profiles +// (keyed by auth.users.id). Server-only: RLS has no anon/authenticated +// policies, so this always runs on the service-role client. +let cached: ProfileStore | null = null; + +export function profileStore(): ProfileStore { + cached ??= createProfileStore(createServiceClient(), { table: "user_profiles", key: "user_id" }); + return cached; +} + +type Metadata = Record | null | undefined; + +// /register passes the validated fields to supabase.auth.signUp as user +// metadata. When the confirmation link is opened (/auth/callback) they are +// stored here if the person has no profile row yet, so later edits through +// /api/v1/me/profile always win. A failed save never blocks sign-in. +export async function saveProfileFromMetadata( + user: { id: string; email?: string | null; user_metadata?: Metadata }, + store?: Pick, +): Promise { + const meta = user.user_metadata ?? {}; + if (!meta.first_name && !meta.last_name) return false; + const input: Record = Object.fromEntries(COLUMNS.map((c) => [c, meta[c] ?? null])); + input.email = user.email ?? null; + const check = validateProfile(input); + if (!check.ok) return false; + try { + const s = store ?? profileStore(); + if (await s.get(user.id)) return false; + await s.save(user.id, check.profile); + return true; + } catch (e) { + console.error("[profile] save from signup metadata failed", e); + return false; + } +} diff --git a/supabase/migrations/016_user_profiles.sql b/supabase/migrations/016_user_profiles.sql new file mode 100644 index 00000000..51902c66 --- /dev/null +++ b/supabase/migrations/016_user_profiles.sql @@ -0,0 +1,29 @@ +-- The shared @profullstack/profile fields (email, first_name, last_name, +-- company_domain, job_title, linkedin_url) in the package's own table, +-- keyed by auth.users.id. smshub has no per-user profiles table; +-- contacts.company is CRM data about other people and stays as it is. +-- +-- Server-only: RLS on with NO anon/authenticated policies, so only the +-- service role (src/lib/profile.ts) reads or writes it. +-- +-- dev2 self-hosted Supabase has no automatic migration step. Apply by hand: +-- docker exec -i smshub-dev-supabase-db psql -U postgres -d postgres -1 < this file +-- then: notify pgrst, 'reload schema'; +-- Additive only. No backfill: no name columns exist for account holders. + +create table if not exists "user_profiles" ( + "user_id" uuid primary key references auth.users(id) on delete cascade, + "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"); + +alter table public.user_profiles enable row level security; +revoke all on public.user_profiles from anon, authenticated;