# 2026-07-22 — Phase 1 PR 1: auxiliary roles (thin slice) **Branch:** `feature/admin-cohorts-2026-07-22` **Owner:** fchin **Scope:** auxiliary role grants + class covers + parent-as-staff UI ## What landed ### Schema - New `user_roles` table (Knex migration `2026072200000010_user_roles.js`): - 19 supported role values - Optional scope (`scope_class_id`, `scope_subject_id`, `scope_cohort_id`) - Time-bounded grants (`starts_at`, `expires_at`) - Revocation audit (`revoked_at`, `revoked_by`, `revoke_reason`) - Standard sync columns - Active-grant unique partial index - Covering index for the `getEffectiveRolesForUser` hot path ### Backend - `server/src/utils/rbac.js` — new RBAC helper module: - `getEffectiveRolesForUser(userId)` — reads user_roles + users.role, returns the union - `getEffectiveRoles(user)` — reads from JWT payload (O(1)) - `hasRole(user, role)` — O(1) string check - `hasRoleForClass(user, classId, role)` — checks class-scoped grants + form-tutor + admin - `getActiveCovers(classId, date?)` — covers query used by the class detail UI - `requireRole(role)`, `requireRoleForClass(role, fromPath)` — express middleware - `server/src/controllers/userRoles.controller.js` — new controller: - `GET /api/users/:id/roles` — list grants (active + revoked) - `POST /api/users/:id/roles` — grant (admin only, audit-logged) - `PUT /api/user-roles/:id` — update expires_at / reason (admin only) - `DELETE /api/user-roles/:id` — soft-revoke (admin only, audit-logged) - `GET /api/user-roles/active-covers?class_id=&date=` — covers query - `server/src/index.js` modifications: - Login now embeds `effective_roles` in the JWT payload and the user response - New `GET /api/auth/refresh-roles` re-signs the JWT with the latest effective roles - `server/src/controllers/attendance.controller.js`: - New `canMarkForClass(req, classId)` helper using the new RBAC module - `POST /api/attendance` now rejects when a teacher tries to mark attendance for a class they don't own (no class-scoped grant) - `server/src/services/SyncEngine.js`: - `user_roles` appended to `tablesToSync` after `users` (FK dependency) ### Frontend - `client/src/store/auth.ts`: - `User` interface now has `effective_roles: string[]` - New `refreshRoles()` action calls `/api/auth/refresh-roles` - New `hasEffectiveRole(user, role)` helper for route gating - `client/src/store/userRoles.ts` — new Zustand store with `fetchUserRoles`, `grantRole`, `updateGrant`, `revokeGrant`, `fetchActiveCovers` - `client/src/components/RoleBadge.tsx` — new component, shows primary role + auxiliary roles - `client/src/components/CoverAssignmentForm.tsx` — new modal, supports cover and auxiliary modes - `client/src/components/ParentViewToggle.tsx` — new component, parent-as-staff toggle in the user menu - `client/src/pages/admin/UserRoles.tsx` — new admin page for managing grants - `client/src/App.tsx`: - `ProtectedRoute` now checks `effective_roles` (falls back to `user.role` for old JWTs) - `AppLayout` calls `refreshRoles()` once per auth session on mount - New route `/admin/user-roles` registered for `school_admin` + `systems_admin` - `client/src/components/Nav.tsx`: - New `Auxiliary Roles` nav entry for admins - Default-nav picker prefers an effective role that has a `NAV_CONFIG` entry - `RoleBadge` rendered next to the user name - `ParentViewToggle` mounted above the action buttons ### Docs - `AGENTS.md` — updated team section (fchin / Arthur / Craig) and Knex framework note (was stale) - `.harness/plans/2026-07-22-phase1-cohorts-assignments.md` — full Phase 1 plan - `.harness/plans/2026-07-22-phase2-analytics.md` — Phase 2 plan (deferred) - `.harness/plans/2026-07-22-phase3-offboarding.md` — Phase 3 plan (deferred) - `.harness/plans/2026-07-22-cohorts-assignments-analytics-offboarding.md` — superseded 7-feature draft, kept for paper trail ## How to verify 1. `cd server && npm install && npm run db:init` — applies the new migration 2. Login as `admin@school.com / admin123` — JWT payload now contains `effective_roles: ['school_admin']` 3. Open `/admin/user-roles` — pick a user, grant a `librarian` auxiliary role. The right-pane list updates. 4. Open `/library` in a new tab — the librarian page now loads (previously forbidden for non-librarians) 5. From `/admin/user-roles`, grant a teacher an unscoped `librarian` grant. Sign in as that teacher — the role badge in the user menu shows "Librarian +" 6. Grant a class cover: pick a teacher, click "Class Cover", pick a class, set expires_at to today + 1 day. The cover appears in the list. 7. Sign in as the covering teacher. The class detail page (when PR 3 lands) will show "currently covering" — for now, the change is visible in the JWT. ## What it does NOT do (deferred to follow-ups) - Does NOT migrate any other controller's `req.user.role` check to the new `hasRole` helper. That's the "incremental RBAC migration" follow-up. Only `attendance.controller.js` adopts the new pattern this PR. - Does NOT add a "former staff" or "alumni" page (those are Phase 3). - Does NOT add CSV export of grants (manual JSON dump for now). - Does NOT auto-prune expired user_roles rows. The RBAC check filters them out at read time; a daily cleanup is a follow-up. - Does NOT change the existing `users.role` CHECK constraint — the primary role stays the same. ## Risk register - **JWT shape change**: existing JWTs without `effective_roles` still work (fallback to `user.role` everywhere). Refresh on app load picks up the new shape within one client cycle. - **Offline path**: `refreshRoles` is a no-op when offline. The PWA continues to use the last-known `effective_roles`. The SyncEngine will sync user_roles rows so they're available when the user comes back online. - **One controller touched**: only `attendance.controller.js` adopts the new pattern. All other controllers keep their existing `req.user.role` checks. This is the deliberately thin slice.