From a8829792f7907baa812fb08daef250265b142aa6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 10:53:00 +0000 Subject: [PATCH] feat(profile): shared @profullstack/profile fields on signup, settings and /api/v1/me/profile Signup asks for first/last name (required) plus company domain, job title and LinkedIn (optional) through the package's ProfileFields; the signup route validates them before creating the user and saves them on public.profiles (owner-only RLS) after. GET/PUT/PATCH /api/v1/me/profile and a Profile card on Settings. Migration adds the columns and backfills email and first/last name from auth user metadata. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 1 + .../web/src/app/api/auth/signup/route.test.ts | 102 ++++++++++++++++++ apps/web/src/app/api/auth/signup/route.ts | 42 ++++++++ apps/web/src/app/api/v1/me/profile/route.ts | 21 ++++ apps/web/src/app/globals.css | 43 ++++++++ .../web/src/app/settings/settings-content.tsx | 3 + .../src/components/auth/SignupForm.test.tsx | 4 + apps/web/src/components/auth/SignupForm.tsx | 80 ++++++-------- .../src/components/profile/ProfileCard.tsx | 90 ++++++++++++++++ apps/web/src/lib/profile.ts | 12 +++ pnpm-lock.yaml | 23 ++++ .../20261026120000_profile_fields.sql | 29 +++++ 12 files changed, 402 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/app/api/v1/me/profile/route.ts create mode 100644 apps/web/src/components/profile/ProfileCard.tsx create mode 100644 apps/web/src/lib/profile.ts create mode 100644 supabase/migrations/20261026120000_profile_fields.sql diff --git a/apps/web/package.json b/apps/web/package.json index 2a668d48..33709f4d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@profullstack/live-translate": "^0.2.1", "@profullstack/notifications": "0.1.0", "@profullstack/pairux-embed": "workspace:*", + "@profullstack/profile": "0.1.3", "@profullstack/stack": "^0.1.3", "@profullstack/x402-gateway": "^0.11.0", "@serwist/next": "^9.5.0", diff --git a/apps/web/src/app/api/auth/signup/route.test.ts b/apps/web/src/app/api/auth/signup/route.test.ts index 9e74b50b..a44bdf40 100644 --- a/apps/web/src/app/api/auth/signup/route.test.ts +++ b/apps/web/src/app/api/auth/signup/route.test.ts @@ -6,6 +6,19 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), })); +const upsert = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/supabase/service', () => ({ + serviceClient: () => ({ + auth: {}, + from: (table: string) => ({ + upsert: (row: Record, opts: unknown) => { + upsert(table, row, opts); + return { select: () => ({ single: async () => ({ data: row, error: null }) }) }; + }, + }), + }), +})); + import { createClient } from '@/lib/supabase/server'; describe('POST /api/auth/signup', () => { @@ -219,4 +232,93 @@ describe('POST /api/auth/signup', () => { expect(response.status).toBe(500); expect(body.error).toBe('Failed to create user'); }); + + it('stores the profile fields on public.profiles for a new account', async () => { + const newUser = { ...mockUser, identities: [{ id: 'identity' }] }; + const signUp = vi + .fn() + .mockResolvedValue({ data: { user: newUser, session: null }, error: null }); + vi.mocked(createClient).mockResolvedValue( + createMockSupabaseClient({ auth: { signUp } }) as never + ); + + const response = await POST( + new Request('http://localhost/api/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'test@example.com', + password: 'Password123', + confirmPassword: 'Password123', + firstName: 'Ada', + lastName: 'Lovelace', + company_domain: 'https://www.Acme.com/about', + job_title: 'CTO', + linkedin_url: 'in/ada-lovelace', + }), + }) + ); + + expect(response.status).toBe(201); + expect( + (signUp.mock.calls[0] as [{ options: { data: unknown } }])[0].options.data + ).toMatchObject({ + company_domain: 'acme.com', + job_title: 'CTO', + }); + expect(upsert).toHaveBeenCalledTimes(1); + const [table, row, opts] = upsert.mock.calls[0] as [string, unknown, unknown]; + expect(table).toBe('profiles'); + expect(opts).toEqual({ onConflict: 'id' }); + expect(row).toMatchObject({ + id: 'test-user-id', + email: 'test@example.com', + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'acme.com', + job_title: 'CTO', + linkedin_url: 'https://www.linkedin.com/in/ada-lovelace', + }); + }); + + it('does not write a profile for an already-registered address', async () => { + upsert.mockClear(); + const mockSupabase = createMockSupabaseClient(); + vi.mocked(createClient).mockResolvedValue(mockSupabase as never); + const response = await POST( + new Request('http://localhost/api/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'test@example.com', + password: 'Password123', + confirmPassword: 'Password123', + firstName: 'Ada', + lastName: 'Lovelace', + }), + }) + ); + expect(response.status).toBe(201); + expect(upsert).not.toHaveBeenCalled(); + }); + + it('rejects a link in a name field with a field error', async () => { + vi.mocked(createClient).mockResolvedValue(createMockSupabaseClient() as never); + const response = await POST( + new Request('http://localhost/api/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'test@example.com', + password: 'Password123', + confirmPassword: 'Password123', + firstName: 'http://spam.example', + lastName: 'Lovelace', + }), + }) + ); + const body = await response.json(); + expect(response.status).toBe(400); + expect(body.fieldErrors.first_name).toBeTruthy(); + }); }); diff --git a/apps/web/src/app/api/auth/signup/route.ts b/apps/web/src/app/api/auth/signup/route.ts index 5926a1c3..330cf436 100644 --- a/apps/web/src/app/api/auth/signup/route.ts +++ b/apps/web/src/app/api/auth/signup/route.ts @@ -1,12 +1,38 @@ import { createClient } from '@/lib/supabase/server'; import { signupSchema } from '@/lib/validations'; import { successResponse, errorResponse, handleApiError } from '@/lib/api'; +import { NextResponse } from 'next/server'; +import { validateProfile, saveSignupProfile } from '@profullstack/profile'; +import { profileStore } from '@/lib/profile'; + +function optional(body: unknown, key: string): string { + const v = (body as Record | null)?.[key]; + return typeof v === 'string' ? v : ''; +} export async function POST(request: Request) { try { const body: unknown = await request.json(); const { email, password, firstName, lastName } = signupSchema.parse(body); + // The shared @profullstack/profile fields: first/last name required, + // company domain, job title and LinkedIn optional. Validated before the + // user exists, stored on public.profiles right after. + const fields = { + email, + first_name: firstName, + last_name: lastName, + company_domain: optional(body, 'company_domain'), + job_title: optional(body, 'job_title'), + linkedin_url: optional(body, 'linkedin_url'), + }; + const check = validateProfile(fields); + if (!check.ok) { + const first = Object.values(check.errors)[0] ?? 'Some fields need fixing.'; + return NextResponse.json({ error: first, fieldErrors: check.errors }, { status: 400 }); + } + const p = check.profile; + const supabase = await createClient(); const { data, error } = await supabase.auth.signUp({ @@ -17,6 +43,9 @@ export async function POST(request: Request) { first_name: firstName, last_name: lastName, display_name: `${firstName} ${lastName}`, + company_domain: p.company_domain ?? null, + job_title: p.job_title ?? null, + linkedin_url: p.linkedin_url ?? null, }, // Skip email confirmation for now - remove this line once SMTP is working emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL ?? 'https://pairux.com'}/auth/callback`, @@ -37,6 +66,19 @@ export async function POST(request: Request) { return errorResponse('Failed to create user', 500); } + // An address that is already registered comes back as a stand-in user with + // no identities; never write a profile for it. A failed save never fails + // the signup: the fields are also in user metadata and on Settings. + if ((data.user.identities?.length ?? 0) > 0) { + try { + await saveSignupProfile(profileStore(), data.user.id, fields, { + email: data.user.email ?? null, + }); + } catch (e) { + console.error('[signup] profile save failed', e); + } + } + // Check if user needs email confirmation or is already confirmed const needsConfirmation = !data.user.confirmed_at && !data.session; diff --git a/apps/web/src/app/api/v1/me/profile/route.ts b/apps/web/src/app/api/v1/me/profile/route.ts new file mode 100644 index 00000000..cfcfd3cd --- /dev/null +++ b/apps/web/src/app/api/v1/me/profile/route.ts @@ -0,0 +1,21 @@ +import { createProfileHandlers } from '@profullstack/profile'; +import { createClient, getAuthenticatedUser } 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 profile fields. +const handlers = () => + createProfileHandlers({ + store: profileStore(), + getUser: async () => { + const supabase = await createClient(); + const { user } = await getAuthenticatedUser(supabase); + return user ? { id: user.id, email: 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/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 41caa5e2..ef70b4a3 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -173,3 +173,46 @@ .prose blockquote { @apply border-primary-500 my-6 rounded-r-lg border-l-4 bg-gray-50 py-4 pr-4 pl-6 text-gray-700 italic; } + +/* @profullstack/profile fields: same look as the signup inputs. */ +.pfs-profile { + border: 0; + margin: 0; + padding: 0; + min-width: 0; + display: grid; + gap: 1.25rem; +} +.pfs-profile__row label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-gray-700); +} +.pfs-profile__row input { + display: block; + width: 100%; + margin-top: 0.25rem; + border: 1px solid var(--color-gray-300); + border-radius: 0.5rem; + padding: 0.625rem 0.75rem; + color: var(--color-gray-900); + background: #fff; +} +.pfs-profile__row input::placeholder { + color: var(--color-gray-400); +} +.pfs-profile__row input:focus { + outline: none; + border-color: var(--color-primary-500); + box-shadow: 0 0 0 1px var(--color-primary-500); +} +.pfs-profile__optional { + color: var(--color-gray-400); + font-weight: 400; +} +.pfs-profile__error { + margin-top: 0.25rem; + font-size: 0.875rem; + color: var(--color-red-600); +} diff --git a/apps/web/src/app/settings/settings-content.tsx b/apps/web/src/app/settings/settings-content.tsx index f3a42f76..1bb82af9 100644 --- a/apps/web/src/app/settings/settings-content.tsx +++ b/apps/web/src/app/settings/settings-content.tsx @@ -8,6 +8,7 @@ import { HeaderClient } from '@/components/header-client'; import { LanguageSwitcher } from '@/components/LanguageSwitcher'; import { NotificationPreferences } from '@/components/notifications/NotificationPreferences'; import { YouTubeConnect } from './youtube-connect'; +import { ProfileCard } from '@/components/profile/ProfileCard'; import type { UserData } from '@/components/header'; import type { RecordingQuality } from '@/hooks/useRecording'; import type { CaptureQuality } from '@/hooks/useScreenCapture'; @@ -168,6 +169,8 @@ export function SettingsContent({ + {user && } + {/* Recording Settings */}
diff --git a/apps/web/src/components/auth/SignupForm.test.tsx b/apps/web/src/components/auth/SignupForm.test.tsx index 82a1e581..e034c5c2 100644 --- a/apps/web/src/components/auth/SignupForm.test.tsx +++ b/apps/web/src/components/auth/SignupForm.test.tsx @@ -21,6 +21,7 @@ describe('SignupForm', () => { expect(screen.getByLabelText(/first name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/last name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/job title/i)).toBeInTheDocument(); expect(screen.getByLabelText(/email address/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument(); @@ -54,6 +55,9 @@ describe('SignupForm', () => { confirmPassword: 'Password123', firstName: 'Test', lastName: 'User', + company_domain: '', + job_title: '', + linkedin_url: '', }), }); }); diff --git a/apps/web/src/components/auth/SignupForm.tsx b/apps/web/src/components/auth/SignupForm.tsx index 99182e00..ca53de62 100644 --- a/apps/web/src/components/auth/SignupForm.tsx +++ b/apps/web/src/components/auth/SignupForm.tsx @@ -3,17 +3,28 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Mail, Lock, Eye, EyeOff, Loader2, AlertCircle, User, CheckCircle } from 'lucide-react'; +import { Mail, Lock, Eye, EyeOff, Loader2, AlertCircle, CheckCircle } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { ProfileFields } from '@profullstack/profile/react'; +import type { ProfileField } from '@profullstack/profile'; import { trackSignup } from '@/lib/analytics'; +// Email has its own input below; the shared package renders the other five. +const PROFILE_FIELDS: ProfileField[] = [ + 'first_name', + 'last_name', + 'company_domain', + 'job_title', + 'linkedin_url', +]; + export function SignupForm() { const t = useTranslations('auth'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); + const [profile, setProfile] = useState>>({}); + const [fieldErrors, setFieldErrors] = useState>>({}); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); @@ -22,13 +33,23 @@ export function SignupForm() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(''); + setFieldErrors({}); setLoading(true); try { const res = await fetch('/api/auth/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, confirmPassword, firstName, lastName }), + body: JSON.stringify({ + email, + password, + confirmPassword, + firstName: profile.first_name ?? '', + lastName: profile.last_name ?? '', + company_domain: profile.company_domain ?? '', + job_title: profile.job_title ?? '', + linkedin_url: profile.linkedin_url ?? '', + }), }); const data = await res.json(); @@ -79,50 +100,13 @@ export function SignupForm() {
)} -
-
- -
-
- -
- { - setFirstName(e.target.value); - }} - required - autoComplete="given-name" - className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-lg border border-gray-300 py-2.5 pr-3 pl-10 text-gray-900 placeholder:text-gray-400 focus:ring-1 focus:outline-none" - placeholder={t('signup.firstNamePlaceholder')} - /> -
-
- -
- -
- { - setLastName(e.target.value); - }} - required - autoComplete="family-name" - className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-lg border border-gray-300 px-3 py-2.5 text-gray-900 placeholder:text-gray-400 focus:ring-1 focus:outline-none" - placeholder={t('signup.lastNamePlaceholder')} - /> -
-
-
+