|
import * as React from "react"; |
|
import { Navigate, Outlet, useNavigate } from "react-router-dom"; |
|
import type { Session, SupabaseClient, User } from "@supabase/supabase-js"; |
|
|
|
const TEST_MODE_STORAGE_KEY = "app:test-mode-auth:v1"; |
|
const TEST_MODE_TTL_MS = 60 * 60 * 1000; |
|
|
|
type AuthState = |
|
| { status: "loading" } |
|
| { status: "anonymous" } |
|
| { status: "authenticated"; source: "supabase"; session: Session; user: User } |
|
| { status: "authenticated"; source: "test-mode"; session: TestModeSession }; |
|
|
|
type TestModeSession = { |
|
kind: "test-mode"; |
|
user: { |
|
id: string; |
|
email: string; |
|
role: "student" | "teacher" | "admin"; |
|
}; |
|
createdAt: number; |
|
expiresAt: number; |
|
}; |
|
|
|
type AuthContextValue = { |
|
auth: AuthState; |
|
enterTestMode: () => void; |
|
signOut: () => Promise<void>; |
|
}; |
|
|
|
const AuthContext = React.createContext<AuthContextValue | null>(null); |
|
|
|
export function AuthProvider({ |
|
children, |
|
supabase, |
|
}: { |
|
children: React.ReactNode; |
|
supabase: SupabaseClient; |
|
}) { |
|
const [auth, setAuth] = React.useState<AuthState>({ status: "loading" }); |
|
const navigate = useNavigate(); |
|
|
|
React.useEffect(() => { |
|
let active = true; |
|
|
|
supabase.auth.getSession().then(({ data, error }) => { |
|
if (!active) return; |
|
|
|
if (error) { |
|
setAuth(readValidTestModeSession() ?? { status: "anonymous" }); |
|
return; |
|
} |
|
|
|
const session = data.session; |
|
if (session?.user) { |
|
clearTestModeSession(); |
|
setAuth({ status: "authenticated", source: "supabase", session, user: session.user }); |
|
return; |
|
} |
|
|
|
setAuth(readValidTestModeSession() ?? { status: "anonymous" }); |
|
}); |
|
|
|
const { |
|
data: { subscription }, |
|
} = supabase.auth.onAuthStateChange((event, session) => { |
|
if (!active) return; |
|
|
|
if (session?.user) { |
|
clearTestModeSession(); |
|
setAuth({ status: "authenticated", source: "supabase", session, user: session.user }); |
|
return; |
|
} |
|
|
|
if (event === "SIGNED_OUT") { |
|
setAuth({ status: "anonymous" }); |
|
return; |
|
} |
|
|
|
if (event === "INITIAL_SESSION") { |
|
setAuth(readValidTestModeSession() ?? { status: "anonymous" }); |
|
} |
|
}); |
|
|
|
return () => { |
|
active = false; |
|
subscription.unsubscribe(); |
|
}; |
|
}, [supabase]); |
|
|
|
const enterTestMode = React.useCallback(() => { |
|
const session = createTestModeSession(); |
|
writeTestModeSession(session); |
|
setAuth({ status: "authenticated", source: "test-mode", session }); |
|
navigate("/dashboard", { replace: true }); |
|
}, [navigate]); |
|
|
|
const signOut = React.useCallback(async () => { |
|
clearTestModeSession(); |
|
await supabase.auth.signOut(); |
|
setAuth({ status: "anonymous" }); |
|
navigate("/auth", { replace: true }); |
|
}, [navigate, supabase]); |
|
|
|
return <AuthContext.Provider value={{ auth, enterTestMode, signOut }}>{children}</AuthContext.Provider>; |
|
} |
|
|
|
export function useAuth() { |
|
const context = React.useContext(AuthContext); |
|
if (!context) { |
|
throw new Error("useAuth must be used inside AuthProvider"); |
|
} |
|
return context; |
|
} |
|
|
|
export function ProtectedRoute() { |
|
const { auth } = useAuth(); |
|
|
|
if (auth.status === "loading") { |
|
return null; |
|
} |
|
|
|
if (auth.status === "authenticated") { |
|
return <Outlet />; |
|
} |
|
|
|
return <Navigate to="/auth" replace />; |
|
} |
|
|
|
function createTestModeSession(): TestModeSession { |
|
assertTestModeEnabled(); |
|
|
|
const now = Date.now(); |
|
return { |
|
kind: "test-mode", |
|
user: { |
|
id: "test-user", |
|
email: "test-user@example.invalid", |
|
role: "student", |
|
}, |
|
createdAt: now, |
|
expiresAt: now + TEST_MODE_TTL_MS, |
|
}; |
|
} |
|
|
|
function readValidTestModeSession(): AuthState | null { |
|
if (!isTestModeEnabled()) return null; |
|
|
|
const raw = window.localStorage.getItem(TEST_MODE_STORAGE_KEY); |
|
if (!raw) return null; |
|
|
|
try { |
|
const session = JSON.parse(raw) as TestModeSession; |
|
if (session.kind !== "test-mode" || session.expiresAt <= Date.now()) { |
|
clearTestModeSession(); |
|
return null; |
|
} |
|
|
|
return { status: "authenticated", source: "test-mode", session }; |
|
} catch { |
|
clearTestModeSession(); |
|
return null; |
|
} |
|
} |
|
|
|
function writeTestModeSession(session: TestModeSession) { |
|
window.localStorage.setItem(TEST_MODE_STORAGE_KEY, JSON.stringify(session)); |
|
} |
|
|
|
function clearTestModeSession() { |
|
window.localStorage.removeItem(TEST_MODE_STORAGE_KEY); |
|
} |
|
|
|
function assertTestModeEnabled() { |
|
if (!isTestModeEnabled()) { |
|
throw new Error("Test mode auth is disabled in this build."); |
|
} |
|
} |
|
|
|
function isTestModeEnabled() { |
|
return import.meta.env.MODE !== "production" && import.meta.env.VITE_ENABLE_TEST_MODE === "true"; |
|
} |