User Types
The five user types and how getUserDestination sends each one to the right hub after login.
Once a user logs in, the very next question the app has to answer is: where do I send them? A student belongs in the student hub, a platform admin in the platform console, a member of staff somewhere in the staff hub. The Education Hub answers that question from a single field on the user — me.user.type — and one tiny function that turns that field into a route.
There are exactly five user types, and you'll see them everywhere:
guest— an unauthenticated or visitor-level account; lands on a plain guest landing page.student— a learner enrolled at a campus.guardian— a parent or carer linked to one or more students.staff— anyone who works at a school: teachers, admins, campus leads.platform_admin— the top-level operator who manages the whole platform.
Every signed-in user is exactly one of these. The type is the coarse answer to "who is this?" — Permissions handle the fine-grained "what may they do?" — but the type alone is enough to pick a starting destination.
Type is the routing key, not the authorization
me.user.type decides which hub a user belongs to. It does not decide what they can see inside that hub — that's the job of permissions and scopes. Keep the two ideas separate: type routes, permissions authorize.
One switch, one destination
The whole "where do I go?" decision lives in a single function. It takes the me response and returns a Destination — a route to, plus optional params for routes that need them:
type Destination = {
to: string
params?: Record<string, string>
}
export function getUserDestination(me: AuthMeResponseDto): Destination {
switch (me.user.type) {
// ... one case per user type
}
}Read it as one decision, made once, right after login. Four of the five types are trivial — the type is the destination. Staff is the only one with real branching, so we'll save it for last.
The simple four
For platform_admin, guest, student, and guardian, the switch is a flat lookup — no permissions to check, no campus to resolve:
case 'platform_admin':
return { to: '/platform/hub' }
case 'guest':
return { to: '/guest' }
case 'student':
return { to: '/student/hub' }
case 'guardian':
return { to: '/guardian/hub' }Notice the default at the bottom returns { to: '/' } — a safe fallback if the API ever hands back a type the frontend doesn't know about. You should never hit it, but it means an unknown type degrades to the home page instead of crashing the router.
Student has a second gate
getUserDestination sends every student to /student/hub — but that's not the last word. The student route guard checks whether the student's profile actually has a campusId, and bounces them to /student/no-campus if it doesn't. We cover that just below.
The staff branch
Staff is where it gets interesting, because "staff" spans everyone from a single-campus teacher to a school-wide administrator. The function walks three rungs, from most to least access.
Rung one — school-wide. If a staff member can read the school_dashboard (or has blanket manage/all), they get the school-level hub that spans every campus:
const hasSchoolDashPermissions = me.permissions.some(
(perm) =>
(perm.action === 'read' && perm.resource === 'school_dashboard') ||
(perm.action === 'manage' && perm.resource === 'all'),
)
if (hasSchoolDashPermissions) return { to: '/staff-hub/school/hub' }Rung two — a specific campus. No school-wide access? Then find the campus this person belongs to. The function prefers the campusId baked into their profile, and falls back to the first campus in their scope if the profile doesn't carry one:
const profileCampusId =
'campusId' in me.user.profile ? me.user.profile.campusId : undefined
const campusId = profileCampusId ?? me.scope.campuses[0]?.id
if (campusId) {
return { to: '/staff-hub/campus/$campusId/hub', params: { campusId } }
}This is the one case that uses params — the campus hub route is /staff-hub/campus/$campusId/hub, so the resolved campusId is passed through to fill the $campusId slot.
Rung three — nowhere yet. If they have no school dashboard and no campus at all, there's nothing to show them. They land on a holding page that explains the situation:
return { to: '/staff-hub/no-campus' }Staff routing reads permissions, not just type
The staff branch is the one place getUserDestination peeks at me.permissions and me.scope — because "staff" alone isn't specific enough. School-wide staff and a single-campus teacher share a type but need different homes. The scopes model on the API side is what fills me.scope.campuses.
The route also guards itself
getUserDestination picks a good first destination — but it isn't a lock. What stops a guardian from typing /student/hub into the URL bar? The route itself. Each authenticated route runs requirePermission({ userType }) in its beforeLoad, and a mismatch redirects to /403:
beforeLoad: async ({ location }) => {
await requirePermission({ userType: 'student' })()
const me = await queryClient.ensureQueryData(AuthOptions.getMe())
const campusId =
me && 'campusId' in me.user.profile ? me.user.profile.campusId : undefined
if (!campusId && location.pathname !== '/student/no-campus') {
throw redirect({ to: '/student/no-campus' })
}
}Two things happen here, and it's worth seeing them as a pair. First, requirePermission({ userType: 'student' }) enforces that only students reach this subtree — anyone else is sent to /403. Second, the guard re-checks the campusId rule: a student with no campus is redirected to /student/no-campus. That's the second gate the earlier callout warned about — getUserDestination aims them at /student/hub, and the guard quietly reroutes if their profile isn't ready.
Two layers, on purpose
getUserDestination is a suggestion run once at login; the route guard is enforcement run on every navigation. The first gives users a sensible landing spot; the second makes sure they can never sit somewhere they don't belong. Full detail of the guard lives on the Permissions page.
Where to go next
Permissions
How requirePermission guards routes by user type, action, and resource.
Sessions & Tokens
Where me.user comes from and how the session is kept alive.
The Product
The hubs each user type lands in, and what they're for.
RBAC & Scopes
The API side that fills me.scope.campuses and the permission list.