Skip to content

Commit 4ecd9d8

Browse files
committed
feat: implement API utilities for success and error responses
- Add successResponse and errorResponse functions to handle API responses. - Implement handleApiError function to manage error handling with Zod validation. - Create tests for API utilities to ensure correct behavior. feat: add Supabase client and middleware for session management - Create Supabase client for browser and server environments. - Implement middleware to manage user sessions and authentication redirects. test: add validation schemas and tests for user input - Define validation schemas for signup, login, password reset, and session management. - Add comprehensive tests for each validation schema to ensure input correctness. feat: create database migrations for profiles and sessions - Create profiles table to store user profile information with RLS policies. - Implement sessions table with join codes and session management functions. - Add session participants table to manage user participation in sessions. feat: implement RPC functions for session management - Create RPC functions for creating, joining, and managing sessions. - Implement control state updates and participant management functions. chore: add RLS policies for secure data access - Define RLS policies for profiles, sessions, and session participants to enforce data security. - Grant necessary permissions for authenticated and anonymous users.
1 parent 59ee58e commit 4ecd9d8

54 files changed

Lines changed: 5048 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@
7878
"Bash(node scripts/test-stats-api.mjs:*)",
7979
"Bash(npm run lint:*)",
8080
"Bash(xargs cat:*)",
81-
"Bash(pnpm --filter @pairux/shared-types build:*)"
81+
"Bash(pnpm --filter @pairux/shared-types build:*)",
82+
"Bash(pnpm lint:fix:*)",
83+
"Bash(pnpm test)"
8284
],
8385
"deny": [
8486
"Bash(npm *)",

apps/web/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@
2323
"next": "^15.1.0",
2424
"react": "^19.0.0",
2525
"react-dom": "^19.0.0",
26-
"tailwind-merge": "^2.6.0"
26+
"tailwind-merge": "^2.6.0",
27+
"zod": "^3.24.0"
2728
},
2829
"devDependencies": {
2930
"@pairux/shared-types": "workspace:*",
3031
"@tailwindcss/postcss": "^4.0.0",
3132
"@tailwindcss/typography": "^0.5.15",
33+
"@testing-library/jest-dom": "^6.6.0",
34+
"@testing-library/react": "^16.1.0",
35+
"@testing-library/user-event": "^14.5.0",
3236
"@types/node": "^22.10.0",
3337
"@types/react": "^19.0.0",
3438
"@types/react-dom": "^19.0.0",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { ForgotPasswordForm } from '@/components/auth';
2+
3+
export const metadata = {
4+
title: 'Forgot Password - PairUX',
5+
description: 'Reset your PairUX account password',
6+
};
7+
8+
export default function ForgotPasswordPage() {
9+
return (
10+
<div className="rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
11+
<div className="mb-8 text-center">
12+
<h1 className="text-2xl font-bold text-gray-900">Forgot your password?</h1>
13+
<p className="mt-2 text-sm text-gray-600">
14+
Enter your email and we&apos;ll send you a reset link
15+
</p>
16+
</div>
17+
<ForgotPasswordForm />
18+
</div>
19+
);
20+
}

apps/web/src/app/(auth)/layout.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Link from 'next/link';
2+
3+
export default function AuthLayout({
4+
children,
5+
}: {
6+
children: React.ReactNode;
7+
}) {
8+
return (
9+
<div className="flex min-h-screen flex-col">
10+
{/* Simple header for auth pages */}
11+
<header className="border-b border-gray-200 bg-white">
12+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
13+
<div className="flex h-16 items-center justify-between">
14+
<Link href="/" className="flex items-center gap-2">
15+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600 text-white font-bold text-sm">
16+
P
17+
</div>
18+
<span className="text-xl font-bold text-gray-900">PairUX</span>
19+
</Link>
20+
</div>
21+
</div>
22+
</header>
23+
24+
{/* Auth content */}
25+
<main className="flex flex-1 items-center justify-center bg-gray-50 px-4 py-12">
26+
<div className="w-full max-w-md">
27+
{children}
28+
</div>
29+
</main>
30+
</div>
31+
);
32+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Suspense } from 'react';
2+
import { LoginForm } from '@/components/auth';
3+
4+
export const metadata = {
5+
title: 'Sign In - PairUX',
6+
description: 'Sign in to your PairUX account',
7+
};
8+
9+
export default function LoginPage() {
10+
return (
11+
<div className="rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
12+
<div className="mb-8 text-center">
13+
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
14+
<p className="mt-2 text-sm text-gray-600">Sign in to your account to continue</p>
15+
</div>
16+
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-100 rounded-lg" />}>
17+
<LoginForm />
18+
</Suspense>
19+
</div>
20+
);
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ResetPasswordForm } from '@/components/auth';
2+
3+
export const metadata = {
4+
title: 'Reset Password - PairUX',
5+
description: 'Set a new password for your PairUX account',
6+
};
7+
8+
export default function ResetPasswordPage() {
9+
return (
10+
<div className="rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
11+
<div className="mb-8 text-center">
12+
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1>
13+
<p className="mt-2 text-sm text-gray-600">Enter your new password below</p>
14+
</div>
15+
<ResetPasswordForm />
16+
</div>
17+
);
18+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { SignupForm } from '@/components/auth';
2+
3+
export const metadata = {
4+
title: 'Sign Up - PairUX',
5+
description: 'Create a PairUX account to start sharing your screen',
6+
};
7+
8+
export default function SignupPage() {
9+
return (
10+
<div className="rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
11+
<div className="mb-8 text-center">
12+
<h1 className="text-2xl font-bold text-gray-900">Create an account</h1>
13+
<p className="mt-2 text-sm text-gray-600">
14+
Sign up to start sharing your screen and collaborating
15+
</p>
16+
</div>
17+
<SignupForm />
18+
</div>
19+
);
20+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { POST } from './route';
3+
import { createMockSupabaseClient } from '@/test/mocks/supabase';
4+
5+
vi.mock('@/lib/supabase/server', () => ({
6+
createClient: vi.fn(),
7+
}));
8+
9+
// Override the global headers mock for this test file
10+
vi.mock('next/headers', () => ({
11+
headers: vi.fn(() => Promise.resolve({
12+
get: (name: string) => name === 'origin' ? 'http://localhost:3000' : null,
13+
})),
14+
}));
15+
16+
import { createClient } from '@/lib/supabase/server';
17+
18+
describe('POST /api/auth/forgot-password', () => {
19+
beforeEach(() => {
20+
vi.clearAllMocks();
21+
});
22+
23+
it('sends reset email successfully', async () => {
24+
const mockSupabase = createMockSupabaseClient({
25+
auth: {
26+
resetPasswordForEmail: vi.fn().mockResolvedValue({ error: null }),
27+
},
28+
});
29+
vi.mocked(createClient).mockResolvedValue(mockSupabase as never);
30+
31+
const request = new Request('http://localhost/api/auth/forgot-password', {
32+
method: 'POST',
33+
headers: { 'Content-Type': 'application/json' },
34+
body: JSON.stringify({ email: 'user@example.com' }),
35+
});
36+
37+
const response = await POST(request);
38+
const body = await response.json();
39+
40+
expect(response.status).toBe(200);
41+
expect(body.data.message).toContain('If an account exists');
42+
expect(mockSupabase.auth.resetPasswordForEmail).toHaveBeenCalledWith(
43+
'user@example.com',
44+
expect.objectContaining({
45+
redirectTo: expect.stringContaining('/reset-password'),
46+
})
47+
);
48+
});
49+
50+
it('returns success even for non-existent email (prevents enumeration)', async () => {
51+
const mockSupabase = createMockSupabaseClient({
52+
auth: {
53+
resetPasswordForEmail: vi.fn().mockResolvedValue({ error: null }),
54+
},
55+
});
56+
vi.mocked(createClient).mockResolvedValue(mockSupabase as never);
57+
58+
const request = new Request('http://localhost/api/auth/forgot-password', {
59+
method: 'POST',
60+
headers: { 'Content-Type': 'application/json' },
61+
body: JSON.stringify({ email: 'nonexistent@example.com' }),
62+
});
63+
64+
const response = await POST(request);
65+
const body = await response.json();
66+
67+
// Should return success to prevent email enumeration
68+
expect(response.status).toBe(200);
69+
expect(body.data.message).toContain('If an account exists');
70+
});
71+
72+
it('returns 400 for invalid email format', async () => {
73+
const request = new Request('http://localhost/api/auth/forgot-password', {
74+
method: 'POST',
75+
headers: { 'Content-Type': 'application/json' },
76+
body: JSON.stringify({ email: 'not-an-email' }),
77+
});
78+
79+
const response = await POST(request);
80+
const body = await response.json();
81+
82+
expect(response.status).toBe(400);
83+
expect(body.error).toContain('email');
84+
});
85+
86+
it('returns 400 when supabase returns error', async () => {
87+
const mockSupabase = createMockSupabaseClient({
88+
auth: {
89+
resetPasswordForEmail: vi.fn().mockResolvedValue({
90+
error: { message: 'Rate limit exceeded' },
91+
}),
92+
},
93+
});
94+
vi.mocked(createClient).mockResolvedValue(mockSupabase as never);
95+
96+
const request = new Request('http://localhost/api/auth/forgot-password', {
97+
method: 'POST',
98+
headers: { 'Content-Type': 'application/json' },
99+
body: JSON.stringify({ email: 'user@example.com' }),
100+
});
101+
102+
const response = await POST(request);
103+
const body = await response.json();
104+
105+
expect(response.status).toBe(400);
106+
expect(body.error).toBe('Rate limit exceeded');
107+
});
108+
109+
it('returns 400 for missing email', async () => {
110+
const request = new Request('http://localhost/api/auth/forgot-password', {
111+
method: 'POST',
112+
headers: { 'Content-Type': 'application/json' },
113+
body: JSON.stringify({}),
114+
});
115+
116+
const response = await POST(request);
117+
expect(response.status).toBe(400);
118+
});
119+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/no-unnecessary-condition, @typescript-eslint/restrict-template-expressions */
2+
import { createClient } from '@/lib/supabase/server';
3+
import { forgotPasswordSchema } from '@/lib/validations';
4+
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
5+
import { headers } from 'next/headers';
6+
7+
export async function POST(request: Request) {
8+
try {
9+
const body = await request.json();
10+
const { email } = forgotPasswordSchema.parse(body);
11+
12+
const supabase = await createClient();
13+
const headersList = await headers();
14+
const origin = headersList.get('origin') || process.env.NEXT_PUBLIC_APP_URL;
15+
16+
const { error } = await supabase.auth.resetPasswordForEmail(email, {
17+
redirectTo: `${origin}/reset-password`,
18+
});
19+
20+
if (error) {
21+
return errorResponse(error.message, 400);
22+
}
23+
24+
// Always return success to prevent email enumeration
25+
return successResponse({
26+
message: 'If an account exists with that email, you will receive a password reset link',
27+
});
28+
} catch (error) {
29+
return handleApiError(error);
30+
}
31+
}

0 commit comments

Comments
 (0)