feat: migrate app state to bootstrap endpoint
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled

Use GET /me/bootstrap as the primary authenticated state source per ADR-0003. AuthContext now fetches and exposes typed bootstrapData, Header and class/detail admin checks use bootstrap permissions/roles, dashboard surfaces onboarding/calendar/timetable/graph status, and graph navigation derives school setup state from bootstrap instead of /school/status.

Refs: t_44353587
This commit is contained in:
kcar 2026-05-28 19:07:07 +01:00
parent 67e47fc47f
commit 65ce1bede8
6 changed files with 270 additions and 81 deletions

View File

@ -5,6 +5,8 @@ import { CCUser, CCUserMetadata, authService } from '../services/auth/authServic
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { supabase } from '../supabaseClient'; import { supabase } from '../supabaseClient';
import { storageService, StorageKeys } from '../services/auth/localStorageService'; import { storageService, StorageKeys } from '../services/auth/localStorageService';
import { fetchBootstrapData, BootstrapResponse, invalidateBootstrapCache } from '../services/bootstrap/bootstrapService';
export interface AuthContextType { export interface AuthContextType {
user: CCUser | null; user: CCUser | null;
@ -15,6 +17,7 @@ export interface AuthContextType {
signIn: (email: string, password: string) => Promise<void>; signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
clearError: () => void; clearError: () => void;
bootstrapData: BootstrapResponse | null;
} }
export const AuthContext = createContext<AuthContextType>({ export const AuthContext = createContext<AuthContextType>({
@ -25,7 +28,8 @@ export const AuthContext = createContext<AuthContextType>({
error: null, error: null,
signIn: async () => {}, signIn: async () => {},
signOut: async () => {}, signOut: async () => {},
clearError: () => {} clearError: () => {},
bootstrapData: null
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
@ -35,8 +39,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [accessToken, setAccessToken] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
const apiBase = import.meta.env.VITE_API_BASE as string;
const persistSession = useCallback((session: Session | null) => { const persistSession = useCallback((session: Session | null) => {
if (session) { if (session) {
@ -71,15 +75,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return { user: resolvedUser, role: resolvedRole }; return { user: resolvedUser, role: resolvedRole };
}, []); }, []);
const triggerUserInit = useCallback((token: string) => { const loadBootstrap = useCallback(async (token: string | null | undefined) => {
fetch(`${apiBase}/user/init`, { if (!token) {
method: 'POST', setBootstrapData(null);
headers: { Authorization: `Bearer ${token}` }, return null;
}) }
.then(r => r.json()) const bootstrap = await fetchBootstrapData(token);
.then(data => logger.debug('auth-context', '✅ User init', data)) setBootstrapData(bootstrap);
.catch(err => logger.warn('auth-context', '⚠️ User init failed', { err })); return bootstrap;
}, [apiBase]); }, []);
useEffect(() => { useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange( const { data: { subscription } } = supabase.auth.onAuthStateChange(
@ -94,7 +98,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(resolvedUser); setUser(resolvedUser);
setUserRole(role); setUserRole(role);
setAccessToken(session.access_token ?? null); setAccessToken(session.access_token ?? null);
triggerUserInit(session.access_token); await loadBootstrap(session.access_token);
} catch (buildError) { } catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null); setUser(null);
@ -119,6 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(resolvedUser); setUser(resolvedUser);
setUserRole(role); setUserRole(role);
setAccessToken(session.access_token ?? null); setAccessToken(session.access_token ?? null);
await loadBootstrap(session.access_token);
} catch (buildError) { } catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null); setUser(null);
@ -140,13 +145,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(null); setUser(null);
setUserRole(null); setUserRole(null);
setAccessToken(null); setAccessToken(null);
setBootstrapData(null);
invalidateBootstrapCache();
setLoading(false); setLoading(false);
} }
} }
); );
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [buildUserFromSupabase, persistSession, triggerUserInit]); }, [buildUserFromSupabase, persistSession, loadBootstrap]);
const signIn = async (email: string, password: string) => { const signIn = async (email: string, password: string) => {
try { try {
@ -167,6 +174,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(resolvedUser); setUser(resolvedUser);
setUserRole(role); setUserRole(role);
setAccessToken(data.session?.access_token ?? null); setAccessToken(data.session?.access_token ?? null);
await loadBootstrap(data.session?.access_token ?? null);
} }
} catch (error) { } catch (error) {
logger.error('auth-context', '❌ Sign in failed', { error }); logger.error('auth-context', '❌ Sign in failed', { error });
@ -205,7 +213,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
error, error,
signIn, signIn,
signOut, signOut,
clearError clearError,
bootstrapData
}} }}
> >
{children} {children}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { import {
@ -38,54 +38,24 @@ import { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { GraphNavigator } from '../components/navigation/GraphNavigator'; import { GraphNavigator } from '../components/navigation/GraphNavigator';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const Header: React.FC = () => { const Header: React.FC = () => {
const theme = useTheme(); const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, signOut, accessToken } = useAuth(); const { user, signOut, bootstrapData } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [isPlatformAdmin, setIsPlatformAdmin] = useState(false);
const [schoolRole, setSchoolRole] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(!!user); const [isAuthenticated, setIsAuthenticated] = useState(!!user);
const showGraphNavigation = location.pathname === '/single-player'; const showGraphNavigation = location.pathname === '/single-player';
const isPlatformAdmin = bootstrapData?.permissions?.platform_admin ?? false;
const schoolRole = bootstrapData?.active_institute?.membership_role ?? null;
const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head'; const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head';
useEffect(() => { useEffect(() => {
setIsAuthenticated(!!user); setIsAuthenticated(!!user);
}, [user]); }, [user]);
// Check platform admin status and school role once on login
const checkAdminStatus = useCallback(async () => {
if (!accessToken) return;
// Platform admin check
try {
const res = await fetch(`${API_BASE}/admin/stats`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
setIsPlatformAdmin(res.ok);
} catch {
setIsPlatformAdmin(false);
}
// School role check
try {
const res = await fetch(`${API_BASE}/school/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.ok) {
const data = await res.json();
setSchoolRole(data.user_role || null);
}
} catch {
setSchoolRole(null);
}
}, [accessToken]);
useEffect(() => {
if (accessToken) checkAdminStatus();
else { setIsPlatformAdmin(false); setSchoolRole(null); }
}, [accessToken, checkAdminStatus]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);

View File

@ -116,7 +116,7 @@ function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: Ad
const ClassDetailPage: React.FC = () => { const ClassDetailPage: React.FC = () => {
const { classId } = useParams<{ classId: string }>(); const { classId } = useParams<{ classId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { accessToken, user } = useAuth(); const { accessToken, user, bootstrapData } = useAuth();
const [cls, setCls] = useState<ClassDetail | null>(null); const [cls, setCls] = useState<ClassDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -131,24 +131,19 @@ const ClassDetailPage: React.FC = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const [clsRes, roleRes] = await Promise.all([ const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
fetch(`${API_BASE}/classes/${classId}`, { headers: { Authorization: `Bearer ${accessToken}` },
headers: { Authorization: `Bearer ${accessToken}` }, }).then(r => r.json());
}).then(r => r.json()),
fetch(`${API_BASE}/school/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json()),
]);
if (clsRes.id) setCls(clsRes); if (clsRes.id) setCls(clsRes);
else setError(clsRes.detail || 'Class not found'); else setError(clsRes.detail || 'Class not found');
const role = roleRes.user_role || ''; const role = bootstrapData?.active_institute?.membership_role || '';
setIsAdmin(role === 'school_admin' || role === 'department_head'); setIsAdmin(role === 'school_admin' || role === 'department_head');
} catch (e: any) { } catch (e: any) {
setError(e.message); setError(e.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [accessToken, classId]); }, [accessToken, classId, bootstrapData]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);

View File

@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Alert,
Box, Box,
Button, Button,
Chip,
Container, Container,
Grid, Grid,
Paper, Paper,
@ -10,17 +12,28 @@ import {
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningIcon from '@mui/icons-material/Warning';
import { useUser } from '../../contexts/UserContext'; import { useUser } from '../../contexts/UserContext';
const DashboardPage: React.FC = () => { const DashboardPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user: authUser } = useAuth(); const { user: authUser, bootstrapData } = useAuth();
const { profile, loading } = useUser(); const { profile, loading } = useUser();
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member'; const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
const emailAddress = profile?.email || authUser?.email || ''; const emailAddress = profile?.email || authUser?.email || '';
const userType = profile?.user_type || authUser?.user_type || ''; const userType = profile?.user_type || authUser?.user_type || '';
const activeInstitute = bootstrapData?.active_institute;
const activeMembership = bootstrapData?.memberships?.find(
(m) => m.institute_id === activeInstitute?.id && m.is_active
);
const schoolName = activeMembership?.institute?.name;
const onboarding = bootstrapData?.onboarding;
const calendarOk = bootstrapData?.calendar_status?.available && !bootstrapData?.calendar_status?.needs_setup;
const timetableOk = bootstrapData?.timetable_status?.available && !bootstrapData?.timetable_status?.needs_setup;
return ( return (
<Container maxWidth="lg" sx={{ py: 6 }}> <Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={6}> <Stack spacing={6}>
@ -88,6 +101,79 @@ const DashboardPage: React.FC = () => {
</Paper> </Paper>
</Grid> </Grid>
</Grid> </Grid>
{/* Bootstrap Status Section */}
{bootstrapData && (
<Stack spacing={3}>
<Typography variant="h5" component="h2">
Your school
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper elevation={2} sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Institute
</Typography>
<Stack spacing={1}>
<Typography variant="body1" fontWeight={600}>
{schoolName || 'No school assigned'}
</Typography>
{activeInstitute?.membership_role && (
<Chip
label={activeInstitute.membership_role.replace(/_/g, ' ')}
size="small"
color="primary"
variant="outlined"
/>
)}
<Typography variant="body2" color="text.secondary">
Status: {bootstrapData.school_status}
</Typography>
</Stack>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper elevation={2} sx={{ p: 3, height: '100%' }}>
<Typography variant="h6" gutterBottom>
System status
</Typography>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{calendarOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
<Typography variant="body2">
Calendar: {calendarOk ? 'Ready' : 'Needs setup'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{timetableOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
<Typography variant="body2">
Timetable: {timetableOk ? 'Ready' : 'Needs setup'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{bootstrapData?.graph_status?.available ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
<Typography variant="body2">
Graph projection: {bootstrapData?.graph_status?.projection_state || 'unknown'}
</Typography>
</Box>
</Stack>
</Paper>
</Grid>
</Grid>
{onboarding && onboarding.next_step !== 'complete' && (
<Alert severity="info" sx={{ mt: 1 }}>
<Typography variant="body2" fontWeight={600}>
{onboarding.message}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
Next step: {onboarding.next_step.replace(/_/g, ' ')}
</Typography>
</Alert>
)}
</Stack>
)}
</Stack> </Stack>
</Container> </Container>
); );

View File

@ -0,0 +1,123 @@
import { logger } from '../../debugConfig';
const apiBase = (import.meta.env.VITE_API_BASE as string) || '';
export interface BootstrapProfile {
id: string;
email: string;
display_name: string;
user_type: string;
school_id: string | null;
}
export interface BootstrapMembership {
institute_id: string;
role: string;
status: string;
is_active: boolean;
institute: {
id: string;
name: string;
urn: string | null;
};
}
export interface BootstrapActiveInstitute {
id: string | null;
source: string;
membership_role: string | null;
}
export interface BootstrapPermissions {
platform_admin: boolean;
platform_super_admin: boolean;
can_create_school: boolean;
can_manage_school: boolean;
can_manage_calendar: boolean;
can_manage_timetable: boolean;
can_invite_staff: boolean;
can_manage_classes: boolean;
can_view_student_data: boolean;
can_use_canvas: boolean;
}
export interface BootstrapOnboarding {
next_step: string;
required: string[];
optional: string[];
message: string;
}
export interface BootstrapCalendarStatus {
available: boolean;
academic_year_count: number;
term_count: number;
current_academic_year_id: string | null;
needs_setup: boolean;
}
export interface BootstrapTimetableStatus {
available: boolean;
teacher_timetable_id: string | null;
slot_count: number;
needs_setup: boolean;
}
export interface BootstrapGraphStatus {
available: boolean;
user_db: string | null;
institute_db: string | null;
projection_state: string;
needs_rebuild: boolean;
last_checked_at: string | null;
error_code: string | null;
}
export interface BootstrapResponse {
profile: BootstrapProfile;
memberships: BootstrapMembership[];
active_institute: BootstrapActiveInstitute;
permissions: BootstrapPermissions;
school_status: string;
onboarding: BootstrapOnboarding;
calendar_status: BootstrapCalendarStatus;
timetable_status: BootstrapTimetableStatus;
graph_status: BootstrapGraphStatus;
}
let cachedBootstrap: BootstrapResponse | null = null;
let cacheTimestamp = 0;
const CACHE_TTL_MS = 60_000; // 60 second cache
export async function fetchBootstrapData(accessToken: string): Promise<BootstrapResponse | null> {
if (!accessToken) return null;
// Return cached data if fresh
if (cachedBootstrap && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
return cachedBootstrap;
}
try {
const res = await fetch(`${apiBase}/me/bootstrap`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
logger.warn('bootstrap-service', `Bootstrap fetch failed: ${res.status}`);
return cachedBootstrap; // Return stale cache on error
}
const data: BootstrapResponse = await res.json();
cachedBootstrap = data;
cacheTimestamp = Date.now();
return data;
} catch (err) {
logger.error('bootstrap-service', 'Bootstrap fetch error', { err });
return cachedBootstrap; // Return stale cache on error
}
}
export function invalidateBootstrapCache(): void {
cachedBootstrap = null;
cacheTimestamp = 0;
}

View File

@ -483,11 +483,10 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
// ─── Main Panel ─────────────────────────────────────────────────────────────── // ─── Main Panel ───────────────────────────────────────────────────────────────
export function CCGraphNavPanel() { export function CCGraphNavPanel() {
const { accessToken } = useAuth(); const { accessToken, bootstrapData } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { navigateToNeoNode, context } = useNavigationStore(); const { navigateToNeoNode, context } = useNavigationStore();
const [tree, setTree] = useState<TreeNode | null>(null); const [tree, setTree] = useState<TreeNode | null>(null);
const [schoolStatus, setSchoolStatus] = useState<SchoolStatus | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -526,19 +525,31 @@ export function CCGraphNavPanel() {
} }
}, [accessToken, apiBase]); }, [accessToken, apiBase]);
const fetchSchoolStatus = useCallback(async () => { const schoolStatus = useMemo<SchoolStatus | null>(() => {
if (!accessToken) return; if (!bootstrapData) return null;
try { const membershipRole = bootstrapData.active_institute?.membership_role ?? undefined;
const res = await fetch(`${apiBase}/school/status`, { const activeMembership = bootstrapData.memberships?.find(
headers: { Authorization: `Bearer ${accessToken}` }, (m) => m.institute_id === bootstrapData.active_institute?.id && m.is_active
}); );
if (!res.ok) return; return {
const data = await res.json(); status: bootstrapData.school_status,
setSchoolStatus(data); user_role: membershipRole,
} catch { school_id: bootstrapData.active_institute?.id ?? undefined,
// non-fatal — panel still works without school status school_has_calendar: bootstrapData.calendar_status.available && !bootstrapData.calendar_status.needs_setup,
} teacher_has_timetable: bootstrapData.timetable_status.available && !bootstrapData.timetable_status.needs_setup,
}, [accessToken, apiBase]); timetable_id: bootstrapData.timetable_status.teacher_timetable_id,
periods_template: [],
school_info: activeMembership?.institute ? {
name: activeMembership.institute.name,
urn: activeMembership.institute.urn ?? '',
website: '',
address: {},
headteacher: '',
term_dates_url: '',
staff_list_url: '',
} : undefined,
};
}, [bootstrapData]);
useEffect(() => { useEffect(() => {
if (accessToken && !tree) fetchTree(); if (accessToken && !tree) fetchTree();
@ -553,9 +564,6 @@ export function CCGraphNavPanel() {
} }
}, [tree]); }, [tree]);
useEffect(() => {
if (accessToken && !schoolStatus) fetchSchoolStatus();
}, [accessToken, schoolStatus, fetchSchoolStatus]);
// Fetch timetable term nodes when switching to term view // Fetch timetable term nodes when switching to term view
useEffect(() => { useEffect(() => {
@ -637,7 +645,6 @@ export function CCGraphNavPanel() {
const refreshAll = useCallback(() => { const refreshAll = useCallback(() => {
setTree(null); setTree(null);
setSchoolStatus(null);
setAcademicCalendarStatus('idle'); setAcademicCalendarStatus('idle');
setAcademicTerms([]); setAcademicTerms([]);
}, []); }, []);
@ -726,9 +733,8 @@ export function CCGraphNavPanel() {
onClose={() => setOnboardingWizardOpen(false)} onClose={() => setOnboardingWizardOpen(false)}
onComplete={() => { onComplete={() => {
setOnboardingWizardOpen(false); setOnboardingWizardOpen(false);
// Reload tree + school status after successful onboarding // Reload tree after successful onboarding; bootstrap state refreshes from AuthContext
setTree(null); setTree(null);
setSchoolStatus(null);
}} }}
apiBase={apiBase} apiBase={apiBase}
/> />