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
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi
| `leads` DB migration | ✅ `supabase/migrations/20260223000001_create_leads_table.sql` — RLS: anon insert allowed, auth read/update |
| **Admin: lead queue** | ✅ `app/admin/leads/page.tsx` — review Phase 0 intake records, open WhatsApp, update status, and store private admin notes |
| Supabase clients wired up | ✅ `lib/supabase/client.ts`, `server.ts`, `admin.ts` |
| **Auth: sign up (email/password)** | ✅ `app/auth/sign-up/page.tsx` — display name, email, password, timezone; min 8-char password |
| **Auth: sign up (email/password)** | ✅ `app/auth/sign-up/page.tsx` — display name, email, password, timezone; min 8-char password; browser-side cooldown + generic account errors |
| **Auth: email verification** | ✅ `app/auth/verify/page.tsx` — instructions page; unverified users cannot reach dashboard |
| **Auth: sign in (email/password)** | ✅ `app/auth/sign-in/page.tsx` — generic error message (no email enumeration) |
| **Auth: Google OAuth** | ✅ Sign-in + sign-up pages both have "Sign in with Google" button |
| **Auth: callback handler** | ✅ `app/auth/callback/route.ts` — PKCE code exchange; redirects to profile-setup if profile incomplete |
| **Auth: sign in (email/password)** | ✅ `app/auth/sign-in/page.tsx` — generic error message (no email enumeration) + browser-side attempt cooldown |
| **Auth: Google OAuth** | ✅ Sign-in + sign-up pages both have "Sign in with Google" button and local redirect cooldowns |
| **Auth: callback handler** | ✅ `app/auth/callback/route.ts` — PKCE code exchange; redirects to profile-setup if WhatsApp or timezone is missing |
| **Auth: profile setup** | ✅ `app/auth/profile-setup/page.tsx` — display name, WhatsApp number (auto-normalized), timezone (auto-detected) |
| **Auth: sign out** | ✅ `app/auth/sign-out/route.ts` — POST clears session, redirects to sign-in |
| **Route protection (proxy)** | ✅ `proxy.ts` — unauthenticated → sign-in for `/dashboard`, `/tutor`, `/admin`; authenticated → dashboard for auth pages |
Expand Down Expand Up @@ -219,11 +219,11 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi
| `leads` DB migration | ✅ `supabase/migrations/20260223000001_create_leads_table.sql` — RLS: anon insert allowed, auth read/update |
| **Admin: lead queue** | ✅ `app/admin/leads/page.tsx` — review Phase 0 intake records, open WhatsApp, update status, and store private admin notes |
| Supabase clients wired up | ✅ `lib/supabase/client.ts`, `server.ts`, `admin.ts` |
| **Auth: sign up (email/password)** | ✅ `app/auth/sign-up/page.tsx` — display name, email, password, timezone; min 8-char password |
| **Auth: sign up (email/password)** | ✅ `app/auth/sign-up/page.tsx` — display name, email, password, timezone; min 8-char password; browser-side cooldown + generic account errors |
| **Auth: email verification** | ✅ `app/auth/verify/page.tsx` — instructions page; unverified users cannot reach dashboard |
| **Auth: sign in (email/password)** | ✅ `app/auth/sign-in/page.tsx` — generic error message (no email enumeration) |
| **Auth: Google OAuth** | ✅ Sign-in + sign-up pages both have "Sign in with Google" button |
| **Auth: callback handler** | ✅ `app/auth/callback/route.ts` — PKCE code exchange; redirects to profile-setup if profile incomplete |
| **Auth: sign in (email/password)** | ✅ `app/auth/sign-in/page.tsx` — generic error message (no email enumeration) + browser-side attempt cooldown |
| **Auth: Google OAuth** | ✅ Sign-in + sign-up pages both have "Sign in with Google" button and local redirect cooldowns |
| **Auth: callback handler** | ✅ `app/auth/callback/route.ts` — PKCE code exchange; redirects to profile-setup if WhatsApp or timezone is missing |
| **Auth: profile setup** | ✅ `app/auth/profile-setup/page.tsx` — display name, WhatsApp number (auto-normalized), timezone (auto-detected) |
| **Auth: sign out** | ✅ `app/auth/sign-out/route.ts` — POST clears session, redirects to sign-in |
| **Route protection (proxy)** | ✅ `proxy.ts` — unauthenticated → sign-in for `/dashboard`, `/tutor`, `/admin`; authenticated → dashboard for auth pages |
Expand Down Expand Up @@ -347,6 +347,7 @@ Recommended workflow

> **Supabase Dashboard settings required for auth** (after running migrations):
>
> - **Auth rate limits / SMTP**: review Auth rate-limit values for signup/recovery/resend flows and configure custom SMTP before production; Supabase's built-in sender is intended for low-volume/demo use.
> - **Auth → Settings**: enable email confirmations; set Site URL to your domain; add `http://localhost:3000/auth/callback` to Redirect URLs.
> - **Auth → Providers → Google**: enable Google OAuth with credentials from [Google Cloud Console](https://console.cloud.google.com). Authorized redirect URI: `https://<your-supabase-ref>.supabase.co/auth/v1/callback`.
> - **Storage → New Bucket**: create a bucket named `payment-proofs` with **Public: No** (private). This is required for payment proof uploads in E5.
Expand Down
8 changes: 4 additions & 4 deletions app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { safeNext, shouldPromoteOAuthParentSignup } from '@/lib/auth/utils'
import { requiresProfileSetup, safeNext, shouldPromoteOAuthParentSignup } from '@/lib/auth/utils'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -46,7 +46,7 @@ export async function GET(request: NextRequest) {
const [{ data: profile }, { data: roleRows }] = await Promise.all([
supabase
.from('user_profiles')
.select('primary_role, whatsapp_number, created_at')
.select('primary_role, whatsapp_number, timezone, created_at')
.eq('user_id', user.id)
.single(),
supabase.from('user_roles').select('role').eq('user_id', user.id),
Expand Down Expand Up @@ -76,8 +76,8 @@ export async function GET(request: NextRequest) {
}
}

// New users who haven't set their WhatsApp number yet go to profile-setup
if (!profile?.whatsapp_number) {
// OAuth users must finish business profile fields before dashboard access.
if (requiresProfileSetup(profile)) {
return NextResponse.redirect(`${origin}/auth/profile-setup`)
}
}
Expand Down
19 changes: 18 additions & 1 deletion app/auth/forgot-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
import { useState } from 'react'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import {
AUTH_THROTTLE_MESSAGE,
checkClientAuthThrottle,
getFriendlyAuthErrorMessage,
} from '@/lib/auth/throttle'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
Expand All @@ -17,14 +22,26 @@ export default function ForgotPasswordPage() {
setStatus('loading')
setErrorMsg('')

const throttle = checkClientAuthThrottle('password_reset', window.localStorage)
if (!throttle.allowed) {
setStatus('error')
setErrorMsg(AUTH_THROTTLE_MESSAGE)
return
}

const supabase = createClient()
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
})

if (error) {
setStatus('error')
setErrorMsg(error.message)
setErrorMsg(
getFriendlyAuthErrorMessage(
error.message,
'Could not send reset instructions. Please wait and try again.',
),
)
} else {
setStatus('sent')
}
Expand Down
38 changes: 36 additions & 2 deletions app/auth/sign-in/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { buildAuthCallbackUrl, safeNext } from '@/lib/auth/utils'
import {
AUTH_THROTTLE_MESSAGE,
checkClientAuthThrottle,
clearClientAuthThrottle,
getFriendlyAuthErrorMessage,
} from '@/lib/auth/throttle'
import {
BauhausLogo,
BauhausLabel,
Expand Down Expand Up @@ -43,27 +49,55 @@ export function SignInForm() {

async function onSubmit(data: SignInData) {
setServerError(null)
const throttle = checkClientAuthThrottle('sign_in', window.localStorage)
if (!throttle.allowed) {
setServerError(AUTH_THROTTLE_MESSAGE)
return
}

const supabase = createClient()
const { error } = await supabase.auth.signInWithPassword({
email: data.email,
password: data.password,
})
if (error) {
setServerError('Invalid email or password. Please try again.')
setServerError(
getFriendlyAuthErrorMessage(
error.message,
'Invalid email or password. Please try again.',
),
)
return
}
clearClientAuthThrottle('sign_in', window.localStorage)
router.push(next)
}

async function signInWithGoogle() {
setServerError(null)
const throttle = checkClientAuthThrottle('oauth', window.localStorage)
if (!throttle.allowed) {
setServerError(AUTH_THROTTLE_MESSAGE)
return
}

setGoogleLoading(true)
const supabase = createClient()
await supabase.auth.signInWithOAuth({
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: buildAuthCallbackUrl(window.location.origin, { next }),
},
})
if (error) {
setGoogleLoading(false)
setServerError(
getFriendlyAuthErrorMessage(
error.message,
'Could not start Google sign-in. Please try again.',
),
)
}
}

const busy = isSubmitting || googleLoading
Expand Down
36 changes: 34 additions & 2 deletions app/auth/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { buildAuthCallbackUrl } from '@/lib/auth/utils'
import {
AUTH_THROTTLE_MESSAGE,
checkClientAuthThrottle,
getFriendlyAuthErrorMessage,
} from '@/lib/auth/throttle'
import {
BauhausLogo,
BauhausLabel,
Expand Down Expand Up @@ -75,6 +80,12 @@ export default function SignUpPage() {

async function onSubmit(data: SignUpData) {
setServerError(null)
const throttle = checkClientAuthThrottle('sign_up', window.localStorage)
if (!throttle.allowed) {
setServerError(AUTH_THROTTLE_MESSAGE)
return
}

const supabase = createClient()
const { error } = await supabase.auth.signUp({
email: data.email,
Expand All @@ -90,16 +101,28 @@ export default function SignUpPage() {
},
})
if (error) {
setServerError(error.message)
setServerError(
getFriendlyAuthErrorMessage(
error.message,
'Could not create the account. Check the details and try again.',
),
)
return
}
router.push('/auth/verify')
}

async function signInWithGoogle() {
setServerError(null)
const throttle = checkClientAuthThrottle('oauth', window.localStorage)
if (!throttle.allowed) {
setServerError(AUTH_THROTTLE_MESSAGE)
return
}

setGoogleLoading(true)
const supabase = createClient()
await supabase.auth.signInWithOAuth({
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: buildAuthCallbackUrl(window.location.origin, {
Expand All @@ -108,6 +131,15 @@ export default function SignUpPage() {
}),
},
})
if (error) {
setGoogleLoading(false)
setServerError(
getFriendlyAuthErrorMessage(
error.message,
'Could not start Google sign-up. Please try again.',
),
)
}
}

const busy = isSubmitting || googleLoading
Expand Down
18 changes: 17 additions & 1 deletion app/auth/sign-up/tutor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { zodResolver } from '@hookform/resolvers/zod'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import {
AUTH_THROTTLE_MESSAGE,
checkClientAuthThrottle,
getFriendlyAuthErrorMessage,
} from '@/lib/auth/throttle'
import { tutorSignUpSchema, type TutorSignUpData } from '@/lib/validators/tutor-sign-up'
import {
BauhausLogo,
Expand Down Expand Up @@ -66,6 +71,12 @@ export default function TutorSignUpPage() {

async function onSubmit(data: TutorSignUpData) {
setServerError(null)
const throttle = checkClientAuthThrottle('sign_up', window.localStorage)
if (!throttle.allowed) {
setServerError(AUTH_THROTTLE_MESSAGE)
return
}

const supabase = createClient()
const { error } = await supabase.auth.signUp({
email: data.email,
Expand All @@ -84,7 +95,12 @@ export default function TutorSignUpPage() {
},
})
if (error) {
setServerError(error.message)
setServerError(
getFriendlyAuthErrorMessage(
error.message,
'Could not submit the tutor application. Check the details and try again.',
),
)
return
}
router.push('/auth/verify')
Expand Down
10 changes: 7 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1132,11 +1132,15 @@ Sentry for Next.js

ensure PII is not logged (WhatsApp numbers should be treated as sensitive)

12.3 rate limiting (optional)
12.3 rate limiting

Vercel edge middleware or a lightweight rate limiter on auth endpoints and intake form
Current launch protections:

not required for MVP, but useful if spam becomes an issue
- `app/api/leads/route.ts` uses `lib/rate-limit.ts` for a 10 req/min per-IP lead intake limit.
- Supabase Auth enforces provider-side rate limits for email-sending Auth flows such as signup, recovery, resend/verify, and user email updates. Confirm project-specific values in Supabase Dashboard -> Authentication -> Rate Limits before launch.
- `lib/auth/throttle.ts` adds browser-side cooldowns for sign-in, sign-up, password reset, and Google OAuth buttons, with generic user-facing errors so auth failures do not expose account existence.

For production scale, replace in-memory/browser-only app throttles with Vercel Edge middleware plus durable storage such as Upstash Redis.

13) future-proofing notes (post-MVP)

Expand Down
2 changes: 1 addition & 1 deletion docs/GAP_ANALYSIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
| | |
|---|---|
| **Severity** | Important |
| **Status** | **RESOLVED** — `lib/rate-limit.ts` provides in-memory sliding-window rate limiter. Applied to `app/api/leads/route.ts` (10 req/min per IP). For production scale, upgrade to `@upstash/ratelimit` + Redis. |
| **Status** | **RESOLVED** — `lib/rate-limit.ts` provides an in-memory sliding-window limiter for API routes, and `app/api/leads/route.ts` keeps its 10 req/min per-IP protection. Supabase Auth also enforces provider-side Auth limits for email-sending flows such as signup/recovery/resend, configurable in Supabase Dashboard -> Authentication -> Rate Limits; production should use custom SMTP instead of the low-limit built-in sender. CorvEd now adds browser-side cooldowns for sign-in, sign-up, password reset, and Google OAuth in `lib/auth/throttle.ts`, and auth forms show generic throttled/account-error messages to avoid email enumeration. For production scale, upgrade app-side throttles to `@upstash/ratelimit` + Redis or equivalent edge/server-side storage. |

### C5. ~~Sign-out uses POST without CSRF token~~ ✅ DONE

Expand Down
8 changes: 5 additions & 3 deletions docs/plan-CorvEd.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,11 @@ Bauhaus design system fully applied: Outfit font, CSS custom properties, landing

### Step 4: Admin Ops Tooling — PARTIALLY DONE

Renewal alerts in analytics, Zod validation, rate limiting done. WhatsApp button comprehensive coverage and text search still TODO.
Renewal alerts in analytics, Zod validation, request text search, and rate limiting are done. WhatsApp button comprehensive coverage still TODO.

### Step 5: Testing — ✅ DONE

Vitest infrastructure established. 15 unit tests (11 scheduling, 4 rate-limit), all passing. DB trigger tests and E2E smoke test still recommended.
Vitest infrastructure established with unit coverage for scheduling, rate limiting, auth callback/profile-gate helpers, auth throttling, validators, WhatsApp utilities, request search, currency formatting, availability overlap, and dashboard components. DB trigger tests and authenticated E2E smoke tests are still recommended before launch.

### Step 6: Pre-launch Checklist — TODO

Expand All @@ -238,6 +238,7 @@ Run through `docs/MVP.md` section 14 (launch checklist) item by item. Also:
- Verify payment proof bucket RLS using `node scripts/with-local-supabase-env.mjs npm test -- supabase/__tests__/payment-session-integrity.integration.test.ts`
- Run manual E2E scenario
- 375px mobile responsiveness audit
- Confirm Supabase Auth production settings: custom SMTP configured, Auth rate-limit values reviewed, Google OAuth redirect URLs set for the production domain

---

Expand All @@ -247,7 +248,7 @@ Run through `docs/MVP.md` section 14 (launch checklist) item by item. Also:
2. **Email notifications** — MVP.md marks transactional email (payment received, tutor assigned) as optional. Should this be implemented before launch or deferred?
3. **Renewal flow** — is renewal triggered by the admin generating a new package, or does the student select and pay again? The student UI currently shows a renewal alert but there is no explicit renewal CTA or new package flow tied to an existing match.
4. **Public tutor profiles** — does the student see the tutor's bio and name before being matched, or only after? Currently the match detail shows the tutor name to the student on the dashboard.
5. **Google OAuth profile setup** — when a user signs up via Google, do they still go through the profile setup page to capture WhatsApp number and timezone? Confirm the callback route handles this correctly.
5. ~~**Google OAuth profile setup**~~resolved. `/auth/callback` now unit-covers and uses `requiresProfileSetup()` so OAuth users missing either WhatsApp number or timezone are sent to `/auth/profile-setup`; parent Google sign-up role promotion remains covered by `shouldPromoteOAuthParentSignup()`.

---

Expand All @@ -267,6 +268,7 @@ Run through `docs/MVP.md` section 14 (launch checklist) item by item. Also:
| --- | --- |
| `lib/supabase/database.types.ts` | Generated TypeScript types for all tables, enums, functions |
| `lib/rate-limit.ts` | In-memory sliding-window rate limiter |
| `lib/auth/throttle.ts` | Browser-side cooldowns and generic Auth error mapping for sign-in, sign-up, password reset, and OAuth flows |
| `app/auth/actions.ts` | Server Action for sign-out (CSRF-protected) |
| `app/api/cron/expire-packages/route.ts` | Daily cron to expire packages past end_date |
| `app/dashboard/packages/actions.ts` | Signed URL + rejected payment re-upload server actions |
Expand Down
Loading
Loading