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>
116 lines
3.7 KiB
JavaScript
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);
|
|
}
|