Authentication
How sign-in works — OAuth and credentials, tokens in a Zustand store, getMe, and routing each user type to its hub.
Authentication in the Education Hub is one short story told end to end. A user proves who they are, the API hands back a pair of tokens, the app stashes those tokens and asks "who am I?", and the answer decides which hub they land in. Five user types share that single path — a student and a platform admin sign in the same way and only diverge at the very last step.
Hold onto this one-breath model and the rest of this section is just detail:
Sign in → get tokens → store tokens →
getMe()→ route to your hub.
The two front doors
There are exactly two ways to prove who you are, and both end at the same place — a token pair in hand.
Credentials. The classic form. You POST an email and password to the API, and AuthQuery.login returns the access and refresh tokens. The same mechanism backs the passwordless one-time-code flow (/api/v1/auth/otp/*) that staff use.
readonly login = async (payload: LoginDto) =>
this.exec(this.op.login(payload))OAuth (Microsoft). Instead of a password, the user bounces out to an identity provider. initMicrosoftAuth kicks off the redirect; the provider sends the browser back to /auth/callback with the tokens already minted in the URL. No password ever touches our code.
One token pair, two flavors
Whichever door a user walks through, the API answers with the same shape — an accessToken and a refreshToken. Everything downstream of sign-in is identical. That is why this section has one storage page, not one per login method.
Where the tokens live
Tokens are kept in a tiny Zustand store that persists itself to localStorage under the key auth-token. That's the whole store — two values and three actions.
export const useAuth = create<AuthState>()(
persist(
(set) => ({
token: null,
refreshToken: null,
setTokens: (token, refreshToken) => set({ token, refreshToken }),
clearTokens: () => set({ token: null, refreshToken: null }),
}),
{ name: tokenKey, storage: createJSONStorage(() => localStorage) },
),
)The axios layer reads the same localStorage entry directly via getStoredAuth / setStoredAuth, so the request interceptor can attach your token without pulling in a React hook. Store and interceptor stay in sync because they share one key. The mechanics — and why there are two readers of the same value — are the subject of Sessions & Tokens.
"Who am I?" — getMe
A token alone tells the app nothing about the person holding it. So right after sign-in, the app calls getMe() to load the user and their permissions. Notice how it treats a 401 not as a crash but as a plain "not signed in":
readonly getMe = async () => {
const { data, error } = await this.op.getMe()
if (error?.status === 401) return null // not signed in — not an error
// ... 5xx rethrows, everything else returns the user
return data
}That me object — the user, their permissions, and their scope — is the fuel for the rest of the app. Permissions reads from it to decide what you can see; the router reads from it to decide where you go.
Routing to the right hub
This is the last step, and the only one that differs by person. getUserDestination takes the me object and returns a route — a student goes to /student/hub, a platform admin to /platform/hub, and staff branch further depending on their permissions.
export function getUserDestination(me: AuthMeResponseDto): Destination {
switch (me.user.type) {
case 'platform_admin': return { to: '/platform/hub' }
case 'student': return { to: '/student/hub' }
case 'guardian': return { to: '/guardian/hub' }
case 'guest': return { to: '/guest' }
// staff branches further on permissions — see User Types
}
}The OAuth /auth/callback route ties the whole story together in a handful of lines — store the tokens, refetch me, then redirect to the destination:
setStoredAuth(accessToken, refreshToken)
queryClient.removeQueries({ queryKey: authQueryKeys.getMe })
const me = await queryClient.fetchQuery(AuthOptions.getMe())
throw redirect({ ...getUserDestination(me), replace: true })Sign-in is the door, not the guard
This flow gets a user to their hub. It does not keep strangers out of protected routes — that job belongs to the _authenticated gate covered in Routing & App Shell. The two work together: the gate blocks, this flow lets in.
Read these next
The three deep pages each take one slice of the story above.
Sessions & Tokens
The Zustand store, the localStorage key, and the login/logout lifecycle.
The Refresh Queue
What happens on a 401: refreshing the access token and the queue that prevents a stampede.
User Types
The five user types and how each one is routed to its own hub.
Permissions
How the me object's permissions decide what each user can see and do.