feat: migrate app state to bootstrap endpoint
app-ci-deploy / test-build-deploy (push) Has been cancelled
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:
+4
-34
@@ -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 | HTMLElement>(null);
|
||||
const [isPlatformAdmin, setIsPlatformAdmin] = useState(false);
|
||||
const [schoolRole, setSchoolRole] = useState<string | null>(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<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
|
||||
@@ -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<ClassDetail | null>(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]);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<Stack spacing={6}>
|
||||
@@ -88,6 +101,79 @@ const DashboardPage: React.FC = () => {
|
||||
</Paper>
|
||||
</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>
|
||||
</Container>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user