diff --git a/README.md b/README.md index a946d00..3e16891 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 | @@ -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://.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. diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 851f317..84de7d2 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -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' @@ -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), @@ -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`) } } diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx index 05a6847..91c10b2 100644 --- a/app/auth/forgot-password/page.tsx +++ b/app/auth/forgot-password/page.tsx @@ -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' @@ -17,6 +22,13 @@ 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`, @@ -24,7 +36,12 @@ export default function ForgotPasswordPage() { if (error) { setStatus('error') - setErrorMsg(error.message) + setErrorMsg( + getFriendlyAuthErrorMessage( + error.message, + 'Could not send reset instructions. Please wait and try again.', + ), + ) } else { setStatus('sent') } diff --git a/app/auth/sign-in/SignInForm.tsx b/app/auth/sign-in/SignInForm.tsx index 6923eb7..0475f47 100644 --- a/app/auth/sign-in/SignInForm.tsx +++ b/app/auth/sign-in/SignInForm.tsx @@ -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, @@ -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 diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index 4955bb9..137e5fe 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -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, @@ -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, @@ -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, { @@ -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 diff --git a/app/auth/sign-up/tutor/page.tsx b/app/auth/sign-up/tutor/page.tsx index 0f78b54..dcf1b26 100644 --- a/app/auth/sign-up/tutor/page.tsx +++ b/app/auth/sign-up/tutor/page.tsx @@ -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, @@ -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, @@ -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') diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cb31814..cc87fea 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) diff --git a/docs/GAP_ANALYSIS.md b/docs/GAP_ANALYSIS.md index eb1947e..5c9ad82 100644 --- a/docs/GAP_ANALYSIS.md +++ b/docs/GAP_ANALYSIS.md @@ -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 diff --git a/docs/plan-CorvEd.md b/docs/plan-CorvEd.md index b650c68..30050fc 100644 --- a/docs/plan-CorvEd.md +++ b/docs/plan-CorvEd.md @@ -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 @@ -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 --- @@ -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()`. --- @@ -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 | diff --git a/lib/__tests__/auth-throttle.test.ts b/lib/__tests__/auth-throttle.test.ts new file mode 100644 index 0000000..22b2a1c --- /dev/null +++ b/lib/__tests__/auth-throttle.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest' + +import { + checkClientAuthThrottle, + getFriendlyAuthErrorMessage, +} from '@/lib/auth/throttle' + +function createStorage() { + const store = new Map() + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + } +} + +describe('checkClientAuthThrottle', () => { + test('allows attempts up to the per-action limit and returns remaining count', () => { + const storage = createStorage() + + const first = checkClientAuthThrottle('password_reset', storage, 1_000) + expect(first).toEqual({ allowed: true, remaining: 0 }) + + const second = checkClientAuthThrottle('password_reset', storage, 1_500) + if (second.allowed) throw new Error('Expected password reset throttle to block') + expect(second.retryAfterSeconds).toBe(60) + }) + + test('resets the counter after the action window expires', () => { + const storage = createStorage() + + expect(checkClientAuthThrottle('oauth', storage, 1_000).allowed).toBe(true) + expect(checkClientAuthThrottle('oauth', storage, 1_500).allowed).toBe(false) + + const afterWindow = checkClientAuthThrottle('oauth', storage, 62_000) + expect(afterWindow).toEqual({ allowed: true, remaining: 0 }) + }) + + test('fails open when browser storage is unavailable', () => { + const storage = { + getItem: () => { + throw new Error('storage disabled') + }, + setItem: () => { + throw new Error('storage disabled') + }, + removeItem: () => { + throw new Error('storage disabled') + }, + } + + expect(checkClientAuthThrottle('sign_in', storage, 1_000)).toEqual({ + allowed: true, + remaining: 4, + }) + }) +}) + +describe('getFriendlyAuthErrorMessage', () => { + test('maps provider rate-limit errors to a non-enumerating retry message', () => { + expect( + getFriendlyAuthErrorMessage( + 'Email rate limit exceeded', + 'Could not complete the request.', + ), + ).toBe('Too many auth attempts. Please wait a minute and try again.') + }) + + test('uses the supplied fallback for account-specific provider errors', () => { + expect( + getFriendlyAuthErrorMessage( + 'User already registered', + 'Could not create the account. Check the details and try again.', + ), + ).toBe('Could not create the account. Check the details and try again.') + }) +}) diff --git a/lib/__tests__/auth-utils.test.ts b/lib/__tests__/auth-utils.test.ts index fa6977b..0932bad 100644 --- a/lib/__tests__/auth-utils.test.ts +++ b/lib/__tests__/auth-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest' import { buildAuthCallbackUrl, + requiresProfileSetup, safeNext, shouldPromoteOAuthParentSignup, } from '@/lib/auth/utils' @@ -78,3 +79,20 @@ describe('shouldPromoteOAuthParentSignup', () => { ).toBe(false) }) }) + +describe('requiresProfileSetup', () => { + test('requires setup when an OAuth profile is missing WhatsApp or timezone', () => { + expect(requiresProfileSetup(null)).toBe(true) + expect(requiresProfileSetup({ whatsapp_number: null, timezone: 'Asia/Karachi' })).toBe(true) + expect(requiresProfileSetup({ whatsapp_number: '+923001234567', timezone: '' })).toBe(true) + }) + + test('allows completed profiles to continue to the requested destination', () => { + expect( + requiresProfileSetup({ + whatsapp_number: '+923001234567', + timezone: 'America/New_York', + }), + ).toBe(false) + }) +}) diff --git a/lib/auth/throttle.ts b/lib/auth/throttle.ts new file mode 100644 index 0000000..8611961 --- /dev/null +++ b/lib/auth/throttle.ts @@ -0,0 +1,78 @@ +export type AuthThrottleAction = 'sign_in' | 'sign_up' | 'password_reset' | 'oauth' + +type AuthThrottleStorage = Pick + +type AuthThrottleResult = + | { allowed: true; remaining: number } + | { allowed: false; remaining: 0; retryAfterSeconds: number } + +const AUTH_THROTTLE_RULES: Record = { + sign_in: { limit: 5, windowMs: 60 * 1000 }, + sign_up: { limit: 3, windowMs: 60 * 1000 }, + password_reset: { limit: 1, windowMs: 60 * 1000 }, + oauth: { limit: 1, windowMs: 60 * 1000 }, +} + +const STORAGE_PREFIX = 'corved:auth-throttle:' + +export const AUTH_THROTTLE_MESSAGE = + 'Too many auth attempts. Please wait a minute and try again.' + +export function checkClientAuthThrottle( + action: AuthThrottleAction, + storage: AuthThrottleStorage, + now = Date.now(), +): AuthThrottleResult { + const rule = AUTH_THROTTLE_RULES[action] + const key = `${STORAGE_PREFIX}${action}` + + try { + const raw = storage.getItem(key) + const record = raw ? JSON.parse(raw) as { count?: unknown; resetAt?: unknown } : null + const count = typeof record?.count === 'number' ? record.count : 0 + const resetAt = typeof record?.resetAt === 'number' ? record.resetAt : 0 + + if (!record || now >= resetAt) { + storage.setItem(key, JSON.stringify({ count: 1, resetAt: now + rule.windowMs })) + return { allowed: true, remaining: rule.limit - 1 } + } + + if (count >= rule.limit) { + return { + allowed: false, + remaining: 0, + retryAfterSeconds: Math.max(1, Math.ceil((resetAt - now) / 1000)), + } + } + + const nextCount = count + 1 + storage.setItem(key, JSON.stringify({ count: nextCount, resetAt })) + return { allowed: true, remaining: Math.max(0, rule.limit - nextCount) } + } catch { + return { allowed: true, remaining: rule.limit - 1 } + } +} + +export function clearClientAuthThrottle( + action: AuthThrottleAction, + storage: AuthThrottleStorage, +): void { + try { + storage.removeItem(`${STORAGE_PREFIX}${action}`) + } catch { + // Browser storage can be blocked. Supabase Auth still enforces provider limits. + } +} + +export function getFriendlyAuthErrorMessage(message: string, fallback: string): string { + const lower = message.toLowerCase() + if ( + lower.includes('rate limit') || + lower.includes('too many') || + lower.includes('security purposes') + ) { + return AUTH_THROTTLE_MESSAGE + } + + return fallback +} diff --git a/lib/auth/utils.ts b/lib/auth/utils.ts index 06f324b..07de79c 100644 --- a/lib/auth/utils.ts +++ b/lib/auth/utils.ts @@ -66,3 +66,12 @@ export function shouldPromoteOAuthParentSignup({ return now.getTime() - createdAtMs <= FRESH_OAUTH_SIGNUP_WINDOW_MS } + +export function requiresProfileSetup( + profile: { whatsapp_number: string | null; timezone: string | null } | null, +) { + if (!profile) return true + if (!profile.whatsapp_number?.trim()) return true + if (!profile.timezone?.trim()) return true + return false +}