255 lines
8.4 KiB
TypeScript
255 lines
8.4 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
import type { Session, User } from '@supabase/supabase-js';
|
|
import { getBrowserSupabase } from '@/lib/supabase';
|
|
|
|
export type Tier = 'free' | 'analyst' | 'desk';
|
|
|
|
interface UserProfile {
|
|
tier: Tier;
|
|
scan_count: number;
|
|
scan_reset_date: string;
|
|
founder_pricing: boolean;
|
|
subscription_status: 'none' | 'active' | 'grace_period' | 'expired' | 'canceled';
|
|
subscription_end: string | null;
|
|
mfa_setup_prompted: boolean;
|
|
}
|
|
|
|
interface AuthContextValue {
|
|
user: User | null;
|
|
session: Session | null;
|
|
profile: UserProfile | null;
|
|
tier: Tier;
|
|
scanCount: number;
|
|
scansRemaining: number | null;
|
|
canScan: boolean;
|
|
loading: boolean;
|
|
signUp: (email: string, password: string, ageVerified: boolean) => Promise<{ error?: string }>;
|
|
signIn: (email: string, password: string) => Promise<{ error?: string }>;
|
|
signInWithGoogle: () => Promise<void>;
|
|
// Session 13 — generalized OAuth dispatch. Apple/Twitter call paths
|
|
// exist in the UI; whether the call SUCCEEDS depends on the
|
|
// provider being configured in the Supabase dashboard. Unconfigured
|
|
// providers return an error string the login page surfaces inline.
|
|
signInWithProvider: (provider: 'google' | 'apple' | 'twitter') => Promise<{ error?: string }>;
|
|
signOut: () => Promise<void>;
|
|
refresh: () => Promise<void>;
|
|
bumpScanCount: () => void;
|
|
// Marks the MFA setup nag as seen so we don't ask the same user again.
|
|
// Independent of whether they actually enabled MFA.
|
|
markMFAPrompted: () => Promise<void>;
|
|
}
|
|
|
|
const FREE_LIMIT = 5; // reads per calendar month
|
|
const monthKey = () => new Date().toISOString().slice(0, 7) + '-01'; // YYYY-MM-01
|
|
const isSameMonth = (date: string | null | undefined) =>
|
|
!!date && date.slice(0, 7) === new Date().toISOString().slice(0, 7);
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
export default function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
const supabase = getBrowserSupabase();
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const loadProfile = useCallback(
|
|
async (currentUser: User | null) => {
|
|
if (!supabase || !currentUser) {
|
|
setProfile(null);
|
|
return;
|
|
}
|
|
const { data, error } = await supabase
|
|
.from('user_profiles')
|
|
.select('tier, scan_count, scan_reset_date, founder_pricing, subscription_status, subscription_end, mfa_setup_prompted')
|
|
.eq('id', currentUser.id)
|
|
.single();
|
|
|
|
if (error || !data) {
|
|
setProfile({
|
|
tier: 'free',
|
|
scan_count: 0,
|
|
scan_reset_date: monthKey(),
|
|
founder_pricing: false,
|
|
subscription_status: 'none',
|
|
subscription_end: null,
|
|
mfa_setup_prompted: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const thisMonth = monthKey();
|
|
const needsReset = !isSameMonth(data.scan_reset_date);
|
|
setProfile({
|
|
tier: (data.tier as Tier) || 'free',
|
|
scan_count: needsReset ? 0 : data.scan_count || 0,
|
|
scan_reset_date: thisMonth,
|
|
founder_pricing: !!data.founder_pricing,
|
|
subscription_status: data.subscription_status || 'none',
|
|
subscription_end: data.subscription_end,
|
|
mfa_setup_prompted: !!data.mfa_setup_prompted,
|
|
});
|
|
},
|
|
[supabase],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!supabase) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
let mounted = true;
|
|
supabase.auth.getSession().then(({ data }) => {
|
|
if (!mounted) return;
|
|
setSession(data.session);
|
|
setUser(data.session?.user ?? null);
|
|
loadProfile(data.session?.user ?? null).finally(() => {
|
|
if (mounted) setLoading(false);
|
|
});
|
|
});
|
|
|
|
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
|
setSession(newSession);
|
|
setUser(newSession?.user ?? null);
|
|
void loadProfile(newSession?.user ?? null);
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
sub.subscription.unsubscribe();
|
|
};
|
|
}, [supabase, loadProfile]);
|
|
|
|
const refresh = useCallback(async () => {
|
|
await loadProfile(user);
|
|
}, [loadProfile, user]);
|
|
|
|
const bumpScanCount = useCallback(() => {
|
|
setProfile((p) => (p ? { ...p, scan_count: p.scan_count + 1 } : p));
|
|
}, []);
|
|
|
|
const signUp = useCallback<AuthContextValue['signUp']>(
|
|
async (email, password, ageVerified) => {
|
|
if (!ageVerified) return { error: 'You must confirm you are 21 or older.' };
|
|
if (!supabase) return { error: 'Auth is not configured. Set Supabase env vars.' };
|
|
const { error } = await supabase.auth.signUp({
|
|
email,
|
|
password,
|
|
options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
|
|
});
|
|
if (error) return { error: error.message };
|
|
return {};
|
|
},
|
|
[supabase],
|
|
);
|
|
|
|
const signIn = useCallback<AuthContextValue['signIn']>(
|
|
async (email, password) => {
|
|
if (!supabase) return { error: 'Auth is not configured. Set Supabase env vars.' };
|
|
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
|
if (error) return { error: error.message };
|
|
return {};
|
|
},
|
|
[supabase],
|
|
);
|
|
|
|
// Session 13 — generic OAuth dispatcher. Supabase returns an error
|
|
// object when the provider isn't configured in the dashboard
|
|
// (Apple needs a Service ID + private key; Twitter/X needs an
|
|
// OAuth 2.0 client). We translate the upstream error into a flat
|
|
// `{ error: string }` shape so the login UI can show a friendly
|
|
// line without inspecting Supabase internals.
|
|
const signInWithProvider = useCallback<AuthContextValue['signInWithProvider']>(
|
|
async (provider) => {
|
|
if (!supabase) return { error: 'Auth not initialized' };
|
|
try {
|
|
const { error } = await supabase.auth.signInWithOAuth({
|
|
provider,
|
|
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
|
});
|
|
if (error) {
|
|
return { error: `${provider} login isn't available yet. Use email or another method.` };
|
|
}
|
|
return {};
|
|
} catch {
|
|
return { error: 'Login failed. Try another method.' };
|
|
}
|
|
},
|
|
[supabase],
|
|
);
|
|
|
|
// Kept as a thin alias so legacy callers (signup/login pages) keep
|
|
// working without churn. New code should call signInWithProvider.
|
|
const signInWithGoogle = useCallback(async () => {
|
|
await signInWithProvider('google');
|
|
}, [signInWithProvider]);
|
|
|
|
const signOut = useCallback(async () => {
|
|
if (!supabase) return;
|
|
await supabase.auth.signOut();
|
|
setProfile(null);
|
|
}, [supabase]);
|
|
|
|
const markMFAPrompted = useCallback(async () => {
|
|
if (!supabase || !user) return;
|
|
setProfile((p) => (p ? { ...p, mfa_setup_prompted: true } : p));
|
|
await supabase.from('user_profiles').update({ mfa_setup_prompted: true }).eq('id', user.id);
|
|
}, [supabase, user]);
|
|
|
|
const value = useMemo<AuthContextValue>(() => {
|
|
const tier = profile?.tier ?? 'free';
|
|
const scanCount = profile?.scan_count ?? 0;
|
|
const scansRemaining = tier === 'free' ? Math.max(0, FREE_LIMIT - scanCount) : null;
|
|
const canScan = tier !== 'free' || scansRemaining === null || (scansRemaining ?? 0) > 0;
|
|
|
|
return {
|
|
user,
|
|
session,
|
|
profile,
|
|
tier,
|
|
scanCount,
|
|
scansRemaining,
|
|
canScan,
|
|
loading,
|
|
signUp,
|
|
signIn,
|
|
signInWithGoogle,
|
|
signInWithProvider,
|
|
signOut,
|
|
refresh,
|
|
bumpScanCount,
|
|
markMFAPrompted,
|
|
};
|
|
}, [user, session, profile, loading, signUp, signIn, signInWithGoogle, signInWithProvider, signOut, refresh, bumpScanCount, markMFAPrompted]);
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
export function useAuth(): AuthContextValue {
|
|
const ctx = useContext(AuthContext);
|
|
if (!ctx) {
|
|
return {
|
|
user: null,
|
|
session: null,
|
|
profile: null,
|
|
tier: 'free',
|
|
scanCount: 0,
|
|
scansRemaining: 5,
|
|
canScan: true,
|
|
loading: false,
|
|
signUp: async () => ({ error: 'Auth not initialized' }),
|
|
signIn: async () => ({ error: 'Auth not initialized' }),
|
|
signInWithGoogle: async () => {},
|
|
signInWithProvider: async () => ({ error: 'Auth not initialized' }),
|
|
signOut: async () => {},
|
|
refresh: async () => {},
|
|
bumpScanCount: () => {},
|
|
markMFAPrompted: async () => {},
|
|
};
|
|
}
|
|
return ctx;
|
|
}
|