From 65ce1bede866b184c57f9085fbfc85f8527cb128 Mon Sep 17 00:00:00 2001 From: kcar Date: Thu, 28 May 2026 19:07:07 +0100 Subject: [PATCH] feat: migrate app state to bootstrap endpoint 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 --- src/contexts/AuthContext.tsx | 37 ++++-- src/pages/Header.tsx | 38 +----- src/pages/timetable/ClassDetailPage.tsx | 17 +-- src/pages/user/dashboardPage.tsx | 88 ++++++++++++- src/services/bootstrap/bootstrapService.ts | 123 ++++++++++++++++++ .../shared/navigation/CCGraphNavPanel.tsx | 48 ++++--- 6 files changed, 270 insertions(+), 81 deletions(-) create mode 100644 src/services/bootstrap/bootstrapService.ts diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index bbf2a6b..971fe65 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -5,6 +5,8 @@ import { CCUser, CCUserMetadata, authService } from '../services/auth/authServic import { logger } from '../debugConfig'; import { supabase } from '../supabaseClient'; import { storageService, StorageKeys } from '../services/auth/localStorageService'; +import { fetchBootstrapData, BootstrapResponse, invalidateBootstrapCache } from '../services/bootstrap/bootstrapService'; + export interface AuthContextType { user: CCUser | null; @@ -15,6 +17,7 @@ export interface AuthContextType { signIn: (email: string, password: string) => Promise; signOut: () => Promise; clearError: () => void; + bootstrapData: BootstrapResponse | null; } export const AuthContext = createContext({ @@ -25,7 +28,8 @@ export const AuthContext = createContext({ error: null, signIn: async () => {}, signOut: async () => {}, - clearError: () => {} + clearError: () => {}, + bootstrapData: null }); export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -35,8 +39,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [accessToken, setAccessToken] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [bootstrapData, setBootstrapData] = useState(null); - const apiBase = import.meta.env.VITE_API_BASE as string; const persistSession = useCallback((session: Session | null) => { if (session) { @@ -71,15 +75,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return { user: resolvedUser, role: resolvedRole }; }, []); - const triggerUserInit = useCallback((token: string) => { - fetch(`${apiBase}/user/init`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }) - .then(r => r.json()) - .then(data => logger.debug('auth-context', '✅ User init', data)) - .catch(err => logger.warn('auth-context', '⚠️ User init failed', { err })); - }, [apiBase]); + const loadBootstrap = useCallback(async (token: string | null | undefined) => { + if (!token) { + setBootstrapData(null); + return null; + } + const bootstrap = await fetchBootstrapData(token); + setBootstrapData(bootstrap); + return bootstrap; + }, []); useEffect(() => { const { data: { subscription } } = supabase.auth.onAuthStateChange( @@ -94,7 +98,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(resolvedUser); setUserRole(role); setAccessToken(session.access_token ?? null); - triggerUserInit(session.access_token); + await loadBootstrap(session.access_token); } catch (buildError) { logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); setUser(null); @@ -119,6 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(resolvedUser); setUserRole(role); setAccessToken(session.access_token ?? null); + await loadBootstrap(session.access_token); } catch (buildError) { logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); setUser(null); @@ -140,13 +145,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(null); setUserRole(null); setAccessToken(null); + setBootstrapData(null); + invalidateBootstrapCache(); setLoading(false); } } ); return () => subscription.unsubscribe(); - }, [buildUserFromSupabase, persistSession, triggerUserInit]); + }, [buildUserFromSupabase, persistSession, loadBootstrap]); const signIn = async (email: string, password: string) => { try { @@ -167,6 +174,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(resolvedUser); setUserRole(role); setAccessToken(data.session?.access_token ?? null); + await loadBootstrap(data.session?.access_token ?? null); } } catch (error) { logger.error('auth-context', '❌ Sign in failed', { error }); @@ -205,7 +213,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { error, signIn, signOut, - clearError + clearError, + bootstrapData }} > {children} diff --git a/src/pages/Header.tsx b/src/pages/Header.tsx index c0a7e5f..2d0e9c8 100644 --- a/src/pages/Header.tsx +++ b/src/pages/Header.tsx @@ -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 { useAuth } from '../contexts/AuthContext'; import { @@ -38,54 +38,24 @@ import { HEADER_HEIGHT } from './Layout'; import { logger } from '../debugConfig'; import { GraphNavigator } from '../components/navigation/GraphNavigator'; -const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000'; const Header: React.FC = () => { const theme = useTheme(); const navigate = useNavigate(); const location = useLocation(); - const { user, signOut, accessToken } = useAuth(); + const { user, signOut, bootstrapData } = useAuth(); const [anchorEl, setAnchorEl] = useState(null); - const [isPlatformAdmin, setIsPlatformAdmin] = useState(false); - const [schoolRole, setSchoolRole] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(!!user); 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'; useEffect(() => { setIsAuthenticated(!!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) => { setAnchorEl(event.currentTarget); diff --git a/src/pages/timetable/ClassDetailPage.tsx b/src/pages/timetable/ClassDetailPage.tsx index b7a4300..1eb2c1b 100644 --- a/src/pages/timetable/ClassDetailPage.tsx +++ b/src/pages/timetable/ClassDetailPage.tsx @@ -116,7 +116,7 @@ function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: Ad const ClassDetailPage: React.FC = () => { const { classId } = useParams<{ classId: string }>(); const navigate = useNavigate(); - const { accessToken, user } = useAuth(); + const { accessToken, user, bootstrapData } = useAuth(); const [cls, setCls] = useState(null); const [loading, setLoading] = useState(true); @@ -131,24 +131,19 @@ const ClassDetailPage: React.FC = () => { setLoading(true); setError(null); try { - const [clsRes, roleRes] = await Promise.all([ - fetch(`${API_BASE}/classes/${classId}`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }).then(r => r.json()), - fetch(`${API_BASE}/school/status`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }).then(r => r.json()), - ]); + const clsRes = await fetch(`${API_BASE}/classes/${classId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }).then(r => r.json()); if (clsRes.id) setCls(clsRes); 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'); } catch (e: any) { setError(e.message); } finally { setLoading(false); } - }, [accessToken, classId]); + }, [accessToken, classId, bootstrapData]); useEffect(() => { load(); }, [load]); diff --git a/src/pages/user/dashboardPage.tsx b/src/pages/user/dashboardPage.tsx index cc34e1e..366a518 100644 --- a/src/pages/user/dashboardPage.tsx +++ b/src/pages/user/dashboardPage.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { + Alert, Box, Button, + Chip, Container, Grid, Paper, @@ -10,17 +12,28 @@ import { Typography } from '@mui/material'; import { useAuth } from '../../contexts/AuthContext'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import WarningIcon from '@mui/icons-material/Warning'; import { useUser } from '../../contexts/UserContext'; const DashboardPage: React.FC = () => { const navigate = useNavigate(); - const { user: authUser } = useAuth(); + const { user: authUser, bootstrapData } = useAuth(); const { profile, loading } = useUser(); const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member'; const emailAddress = profile?.email || authUser?.email || ''; 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 ( @@ -88,6 +101,79 @@ const DashboardPage: React.FC = () => { + {/* Bootstrap Status Section */} + {bootstrapData && ( + + + Your school + + + + + + Institute + + + + {schoolName || 'No school assigned'} + + {activeInstitute?.membership_role && ( + + )} + + Status: {bootstrapData.school_status} + + + + + + + + + System status + + + + {calendarOk ? : } + + Calendar: {calendarOk ? 'Ready' : 'Needs setup'} + + + + {timetableOk ? : } + + Timetable: {timetableOk ? 'Ready' : 'Needs setup'} + + + + {bootstrapData?.graph_status?.available ? : } + + Graph projection: {bootstrapData?.graph_status?.projection_state || 'unknown'} + + + + + + + + {onboarding && onboarding.next_step !== 'complete' && ( + + + {onboarding.message} + + + Next step: {onboarding.next_step.replace(/_/g, ' ')} + + + )} + + )} + ); diff --git a/src/services/bootstrap/bootstrapService.ts b/src/services/bootstrap/bootstrapService.ts new file mode 100644 index 0000000..cea2ba5 --- /dev/null +++ b/src/services/bootstrap/bootstrapService.ts @@ -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 { + 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; +} diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx index 1b86e2a..bc31cd1 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx @@ -483,11 +483,10 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) { // ─── Main Panel ─────────────────────────────────────────────────────────────── export function CCGraphNavPanel() { - const { accessToken } = useAuth(); + const { accessToken, bootstrapData } = useAuth(); const navigate = useNavigate(); const { navigateToNeoNode, context } = useNavigationStore(); const [tree, setTree] = useState(null); - const [schoolStatus, setSchoolStatus] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -526,19 +525,31 @@ export function CCGraphNavPanel() { } }, [accessToken, apiBase]); - const fetchSchoolStatus = useCallback(async () => { - if (!accessToken) return; - try { - const res = await fetch(`${apiBase}/school/status`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!res.ok) return; - const data = await res.json(); - setSchoolStatus(data); - } catch { - // non-fatal — panel still works without school status - } - }, [accessToken, apiBase]); + const schoolStatus = useMemo(() => { + if (!bootstrapData) return null; + const membershipRole = bootstrapData.active_institute?.membership_role ?? undefined; + const activeMembership = bootstrapData.memberships?.find( + (m) => m.institute_id === bootstrapData.active_institute?.id && m.is_active + ); + return { + status: bootstrapData.school_status, + user_role: membershipRole, + school_id: bootstrapData.active_institute?.id ?? undefined, + school_has_calendar: bootstrapData.calendar_status.available && !bootstrapData.calendar_status.needs_setup, + teacher_has_timetable: bootstrapData.timetable_status.available && !bootstrapData.timetable_status.needs_setup, + 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(() => { if (accessToken && !tree) fetchTree(); @@ -553,9 +564,6 @@ export function CCGraphNavPanel() { } }, [tree]); - useEffect(() => { - if (accessToken && !schoolStatus) fetchSchoolStatus(); - }, [accessToken, schoolStatus, fetchSchoolStatus]); // Fetch timetable term nodes when switching to term view useEffect(() => { @@ -637,7 +645,6 @@ export function CCGraphNavPanel() { const refreshAll = useCallback(() => { setTree(null); - setSchoolStatus(null); setAcademicCalendarStatus('idle'); setAcademicTerms([]); }, []); @@ -726,9 +733,8 @@ export function CCGraphNavPanel() { onClose={() => setOnboardingWizardOpen(false)} onComplete={() => { setOnboardingWizardOpen(false); - // Reload tree + school status after successful onboarding + // Reload tree after successful onboarding; bootstrap state refreshes from AuthContext setTree(null); - setSchoolStatus(null); }} apiBase={apiBase} />