2026-03-20 21:41:38 +01:00
|
|
|
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
2026-03-21 13:56:48 +01:00
|
|
|
import type { User, Session, AuthError, Factor } from '@supabase/supabase-js'
|
2026-03-20 21:41:38 +01:00
|
|
|
import { supabase } from '../lib/supabase'
|
feat: auth-aware UI and role-based access control (#67)
## Summary
- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints
## Test plan
- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: https://gitea.nerdboden.de/pokemon/nuzlocke-tracker/pulls/67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-03-21 11:44:05 +01:00
|
|
|
import { api } from '../api/client'
|
|
|
|
|
|
|
|
|
|
interface UserProfile {
|
|
|
|
|
id: string
|
|
|
|
|
email: string
|
|
|
|
|
displayName: string | null
|
|
|
|
|
isAdmin: boolean
|
|
|
|
|
}
|
2026-03-20 21:41:38 +01:00
|
|
|
|
2026-03-21 13:56:48 +01:00
|
|
|
interface MfaState {
|
|
|
|
|
requiresMfa: boolean
|
|
|
|
|
factorId: string | null
|
|
|
|
|
enrolledFactors: Factor[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 21:41:38 +01:00
|
|
|
interface AuthState {
|
|
|
|
|
user: User | null
|
|
|
|
|
session: Session | null
|
|
|
|
|
loading: boolean
|
feat: auth-aware UI and role-based access control (#67)
## Summary
- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints
## Test plan
- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: https://gitea.nerdboden.de/pokemon/nuzlocke-tracker/pulls/67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-03-21 11:44:05 +01:00
|
|
|
isAdmin: boolean
|
2026-03-21 13:56:48 +01:00
|
|
|
mfa: MfaState
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MfaEnrollResult {
|
|
|
|
|
factorId: string
|
|
|
|
|
qrCode: string
|
|
|
|
|
secret: string
|
|
|
|
|
recoveryCodes?: string[]
|
2026-03-20 21:41:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AuthContextValue extends AuthState {
|
2026-03-21 13:56:48 +01:00
|
|
|
signInWithEmail: (
|
|
|
|
|
email: string,
|
|
|
|
|
password: string
|
|
|
|
|
) => Promise<{ error: AuthError | null; requiresMfa?: boolean }>
|
2026-03-20 21:41:38 +01:00
|
|
|
signUpWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
|
|
|
|
signInWithGoogle: () => Promise<{ error: AuthError | null }>
|
|
|
|
|
signInWithDiscord: () => Promise<{ error: AuthError | null }>
|
|
|
|
|
signOut: () => Promise<void>
|
2026-03-21 13:56:48 +01:00
|
|
|
verifyMfa: (code: string) => Promise<{ error: AuthError | null }>
|
|
|
|
|
enrollMfa: () => Promise<{ data: MfaEnrollResult | null; error: AuthError | null }>
|
|
|
|
|
verifyMfaEnrollment: (factorId: string, code: string) => Promise<{ error: AuthError | null }>
|
|
|
|
|
unenrollMfa: (factorId: string) => Promise<{ error: AuthError | null }>
|
|
|
|
|
isOAuthUser: boolean
|
|
|
|
|
refreshMfaState: () => Promise<void>
|
2026-03-20 21:41:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
|
|
|
|
|
feat: auth-aware UI and role-based access control (#67)
## Summary
- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints
## Test plan
- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: https://gitea.nerdboden.de/pokemon/nuzlocke-tracker/pulls/67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-03-21 11:44:05 +01:00
|
|
|
async function syncUserProfile(session: Session | null): Promise<boolean> {
|
|
|
|
|
if (!session) return false
|
|
|
|
|
try {
|
|
|
|
|
const profile = await api.post<UserProfile>('/users/me', {})
|
|
|
|
|
return profile.isAdmin
|
|
|
|
|
} catch {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 13:56:48 +01:00
|
|
|
async function getMfaState(): Promise<MfaState> {
|
|
|
|
|
const defaultState: MfaState = { requiresMfa: false, factorId: null, enrolledFactors: [] }
|
|
|
|
|
try {
|
|
|
|
|
const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
|
|
|
|
if (!aalData) return defaultState
|
|
|
|
|
|
|
|
|
|
const { data: factorsData } = await supabase.auth.mfa.listFactors()
|
|
|
|
|
const verifiedFactors = factorsData?.totp?.filter((f) => f.status === 'verified') ?? []
|
|
|
|
|
|
|
|
|
|
const requiresMfa = aalData.currentLevel === 'aal1' && aalData.nextLevel === 'aal2'
|
|
|
|
|
const factorId = requiresMfa ? (verifiedFactors[0]?.id ?? null) : null
|
|
|
|
|
|
|
|
|
|
return { requiresMfa, factorId, enrolledFactors: verifiedFactors }
|
|
|
|
|
} catch {
|
|
|
|
|
return defaultState
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 21:41:38 +01:00
|
|
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
|
|
|
const [state, setState] = useState<AuthState>({
|
|
|
|
|
user: null,
|
|
|
|
|
session: null,
|
|
|
|
|
loading: true,
|
feat: auth-aware UI and role-based access control (#67)
## Summary
- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints
## Test plan
- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: https://gitea.nerdboden.de/pokemon/nuzlocke-tracker/pulls/67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-03-21 11:44:05 +01:00
|
|
|
isAdmin: false,
|
2026-03-21 13:56:48 +01:00
|
|
|
mfa: { requiresMfa: false, factorId: null, enrolledFactors: [] },
|
2026-03-20 21:41:38 +01:00
|
|
|
})
|
|
|
|
|
|
2026-03-21 13:56:48 +01:00
|
|
|
const refreshMfaState = useCallback(async () => {
|
|
|
|
|
const mfa = await getMfaState()
|
|
|
|
|
setState((prev) => ({ ...prev, mfa }))
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-03-20 21:41:38 +01:00
|
|
|
useEffect(() => {
|
feat: auth-aware UI and role-based access control (#67)
## Summary
- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints
## Test plan
- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: https://gitea.nerdboden.de/pokemon/nuzlocke-tracker/pulls/67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-03-21 11:44:05 +01:00
|
|
|
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
2026-03-21 13:56:48 +01:00
|
|
|
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
|
|
|
|
|
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
|
2026-03-20 21:41:38 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
data: { subscription },
|
feat: auth-aware UI and role-based access control (#67)
## Summary
- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints
## Test plan
- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: https://gitea.nerdboden.de/pokemon/nuzlocke-tracker/pulls/67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-03-21 11:44:05 +01:00
|
|
|
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
2026-03-21 13:56:48 +01:00
|
|
|
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
|
|
|
|
|
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
|
2026-03-20 21:41:38 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return () => subscription.unsubscribe()
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const signInWithEmail = useCallback(async (email: string, password: string) => {
|
|
|
|
|
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
2026-03-21 13:56:48 +01:00
|
|
|
if (error) return { error }
|
|
|
|
|
|
|
|
|
|
const mfa = await getMfaState()
|
|
|
|
|
setState((prev) => ({ ...prev, mfa }))
|
|
|
|
|
return { error: null, requiresMfa: mfa.requiresMfa }
|
2026-03-20 21:41:38 +01:00
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const signUpWithEmail = useCallback(async (email: string, password: string) => {
|
|
|
|
|
const { error } = await supabase.auth.signUp({ email, password })
|
|
|
|
|
return { error }
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const signInWithGoogle = useCallback(async () => {
|
|
|
|
|
const { error } = await supabase.auth.signInWithOAuth({
|
|
|
|
|
provider: 'google',
|
|
|
|
|
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
|
|
|
|
})
|
|
|
|
|
return { error }
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const signInWithDiscord = useCallback(async () => {
|
|
|
|
|
const { error } = await supabase.auth.signInWithOAuth({
|
|
|
|
|
provider: 'discord',
|
|
|
|
|
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
|
|
|
|
})
|
|
|
|
|
return { error }
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const signOut = useCallback(async () => {
|
|
|
|
|
await supabase.auth.signOut()
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-03-21 13:56:48 +01:00
|
|
|
const verifyMfa = useCallback(
|
|
|
|
|
async (code: string) => {
|
|
|
|
|
const factorId = state.mfa.factorId
|
|
|
|
|
if (!factorId) {
|
|
|
|
|
return { error: { message: 'No MFA factor found' } as AuthError }
|
|
|
|
|
}
|
|
|
|
|
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
|
|
|
|
factorId,
|
|
|
|
|
})
|
|
|
|
|
if (challengeError) return { error: challengeError }
|
|
|
|
|
|
|
|
|
|
const { error } = await supabase.auth.mfa.verify({
|
|
|
|
|
factorId,
|
|
|
|
|
challengeId: challengeData.id,
|
|
|
|
|
code,
|
|
|
|
|
})
|
|
|
|
|
if (!error) {
|
|
|
|
|
const mfa = await getMfaState()
|
|
|
|
|
setState((prev) => ({ ...prev, mfa }))
|
|
|
|
|
}
|
|
|
|
|
return { error }
|
|
|
|
|
},
|
|
|
|
|
[state.mfa.factorId]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const enrollMfa = useCallback(async () => {
|
|
|
|
|
const { data, error } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
|
|
|
|
|
if (error || !data) {
|
|
|
|
|
return { data: null, error }
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
data: {
|
|
|
|
|
factorId: data.id,
|
|
|
|
|
qrCode: data.totp.qr_code,
|
|
|
|
|
secret: data.totp.secret,
|
|
|
|
|
},
|
|
|
|
|
error: null,
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const verifyMfaEnrollment = useCallback(async (factorId: string, code: string) => {
|
|
|
|
|
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
|
|
|
|
factorId,
|
|
|
|
|
})
|
|
|
|
|
if (challengeError) return { error: challengeError }
|
|
|
|
|
|
|
|
|
|
const { error } = await supabase.auth.mfa.verify({
|
|
|
|
|
factorId,
|
|
|
|
|
challengeId: challengeData.id,
|
|
|
|
|
code,
|
|
|
|
|
})
|
|
|
|
|
if (!error) {
|
|
|
|
|
const mfa = await getMfaState()
|
|
|
|
|
setState((prev) => ({ ...prev, mfa }))
|
|
|
|
|
}
|
|
|
|
|
return { error }
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const unenrollMfa = useCallback(async (factorId: string) => {
|
|
|
|
|
const { error } = await supabase.auth.mfa.unenroll({ factorId })
|
|
|
|
|
if (!error) {
|
|
|
|
|
const mfa = await getMfaState()
|
|
|
|
|
setState((prev) => ({ ...prev, mfa }))
|
|
|
|
|
}
|
|
|
|
|
return { error }
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const isOAuthUser = useMemo(() => {
|
|
|
|
|
if (!state.user) return false
|
|
|
|
|
const provider = state.user.app_metadata?.['provider']
|
|
|
|
|
return provider === 'google' || provider === 'discord'
|
|
|
|
|
}, [state.user])
|
|
|
|
|
|
2026-03-20 21:41:38 +01:00
|
|
|
const value = useMemo(
|
|
|
|
|
() => ({
|
|
|
|
|
...state,
|
|
|
|
|
signInWithEmail,
|
|
|
|
|
signUpWithEmail,
|
|
|
|
|
signInWithGoogle,
|
|
|
|
|
signInWithDiscord,
|
|
|
|
|
signOut,
|
2026-03-21 13:56:48 +01:00
|
|
|
verifyMfa,
|
|
|
|
|
enrollMfa,
|
|
|
|
|
verifyMfaEnrollment,
|
|
|
|
|
unenrollMfa,
|
|
|
|
|
isOAuthUser,
|
|
|
|
|
refreshMfaState,
|
2026-03-20 21:41:38 +01:00
|
|
|
}),
|
2026-03-21 13:56:48 +01:00
|
|
|
[
|
|
|
|
|
state,
|
|
|
|
|
signInWithEmail,
|
|
|
|
|
signUpWithEmail,
|
|
|
|
|
signInWithGoogle,
|
|
|
|
|
signInWithDiscord,
|
|
|
|
|
signOut,
|
|
|
|
|
verifyMfa,
|
|
|
|
|
enrollMfa,
|
|
|
|
|
verifyMfaEnrollment,
|
|
|
|
|
unenrollMfa,
|
|
|
|
|
isOAuthUser,
|
|
|
|
|
refreshMfaState,
|
|
|
|
|
]
|
2026-03-20 21:41:38 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useAuth() {
|
|
|
|
|
const context = useContext(AuthContext)
|
|
|
|
|
if (!context) {
|
|
|
|
|
throw new Error('useAuth must be used within an AuthProvider')
|
|
|
|
|
}
|
|
|
|
|
return context
|
|
|
|
|
}
|