Files
learning-platform/src/store/AppContext.jsx
RaymondVerhoef 3af105bccd feat: Azure (Entra ID) login as user login (#16)
Replaces the client-side PIN login with real authentication against Azure
Entra ID via PocketBase's built-in OAuth2 (OIDC) flow. Every Azure user is
auto-provisioned as a team_member on first login.

- pb_migrations: team_members becomes a PocketBase auth collection (OIDC
  provider configured from env); all collections require @request.auth.id
- pb_hooks: provisioning of role (ENTRA_ADMIN_EMAILS allow-list) and
  enrollment_status, with admin-role re-sync on every login
- frontend: "Sign in with Microsoft" login, pb.authStore-based session,
  TeamManager manages roster/roles (no PIN/manual create)
- infra: Entra env wiring + pb_hooks mount for dev (Labs) and prod
- src/lib/azureAuth.js + tests for the canonical role/allow-list logic
- docs/auth-spec.md

Knowledge base, generated tests and micro-learnings are left untouched.
Existing PIN users are dropped (no migration required, per sign-off).

Closes #16

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:41:08 +02:00

116 lines
3.7 KiB
JavaScript

import { createContext, useContext, useReducer, useEffect } from 'react';
import * as db from '../lib/db';
import { pb } from '../lib/pb';
import { OIDC_PROVIDER } from '../lib/azureAuth';
import { getPersonalWeekNumber } from '../lib/curriculumService';
const AppContext = createContext();
const initialState = {
currentUser: null,
users: [],
weekNumber: 0,
isLoading: true
};
function computeWeekNumber(user) {
if (!user || !user.curriculum_started_at || user.enrollment_status !== 'active') return 0;
return getPersonalWeekNumber(user.curriculum_started_at);
}
function appReducer(state, action) {
switch (action.type) {
case 'INIT_APP':
return {
...state,
users: action.payload.users,
isLoading: false
};
case 'LOGIN':
return {
...state,
currentUser: action.payload,
weekNumber: computeWeekNumber(action.payload),
};
case 'UPDATE_CURRENT_USER':
return {
...state,
currentUser: action.payload,
weekNumber: computeWeekNumber(action.payload),
users: state.users.map(u => u.id === action.payload.id ? action.payload : u),
};
case 'LOGOUT':
return { ...state, currentUser: null, weekNumber: 0 };
default:
return state;
}
}
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
useEffect(() => {
const loadState = async () => {
// Restore the session from PocketBase's persistent auth store. The token
// is kept in localStorage by the SDK; authRefresh verifies it is still
// valid (and that the Entra account hasn't been revoked) and returns the
// up-to-date record.
if (pb.authStore.isValid && pb.authStore.record?.collectionName === 'team_members') {
try {
const { record } = await pb.collection('team_members').authRefresh();
dispatch({ type: 'LOGIN', payload: record });
} catch {
// Token rejected (expired / account revoked) — drop the session.
pb.authStore.clear();
}
}
// The team list (leaderboard, dashboard) requires authentication now, so
// only load it once we have a valid session.
const members = pb.authStore.isValid ? await db.getTeamMembers() : [];
dispatch({ type: 'INIT_APP', payload: { users: members } });
};
loadState().catch(console.error);
}, []);
// Start the Entra (OIDC) login flow. PocketBase opens the provider popup,
// handles the token exchange server-side, and auto-provisions the record on
// first login (see pb_hooks/team_members.pb.js).
const loginWithAzure = async () => {
const authData = await pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER });
dispatch({ type: 'LOGIN', payload: authData.record });
return authData.record;
};
const logout = () => {
pb.authStore.clear();
dispatch({ type: 'LOGOUT' });
};
/**
* Start the curriculum for the currently logged-in user.
* Records the start timestamp and flips enrollment_status to 'active'.
*/
const enrollCurrentUser = async () => {
if (!state.currentUser) throw new Error('No user is logged in.');
const startedAt = new Date().toISOString();
const updated = await pb.collection('team_members').update(state.currentUser.id, {
curriculum_started_at: startedAt,
enrollment_status: 'active',
});
dispatch({ type: 'UPDATE_CURRENT_USER', payload: updated });
return updated;
};
return (
<AppContext.Provider value={{ state, dispatch, loginWithAzure, logout, enrollCurrentUser }}>
{children}
</AppContext.Provider>
);
}
export function useApp() {
return useContext(AppContext);
}