Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions apps/web/src/app/api/auth/signup/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, 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', () => {
Expand Down Expand Up @@ -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();
});
});
42 changes: 42 additions & 0 deletions apps/web/src/app/api/auth/signup/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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({
Expand All @@ -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`,
Expand All @@ -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;

Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/app/api/v1/me/profile/route.ts
Original file line number Diff line number Diff line change
@@ -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);
43 changes: 43 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
3 changes: 3 additions & 0 deletions apps/web/src/app/settings/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -168,6 +169,8 @@ export function SettingsContent({
</div>
</div>

{user && <ProfileCard />}

{/* Recording Settings */}
<div className="rounded-xl border border-gray-200 bg-white">
<div className="border-b border-gray-100 p-6">
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/auth/SignupForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -54,6 +55,9 @@ describe('SignupForm', () => {
confirmPassword: 'Password123',
firstName: 'Test',
lastName: 'User',
company_domain: '',
job_title: '',
linkedin_url: '',
}),
});
});
Expand Down
Loading
Loading