5.9 KiB
5.9 KiB
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_rolestable (Knex migration2026072200000010_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
getEffectiveRolesForUserhot path
Backend
server/src/utils/rbac.js— new RBAC helper module:getEffectiveRolesForUser(userId)— reads user_roles + users.role, returns the uniongetEffectiveRoles(user)— reads from JWT payload (O(1))hasRole(user, role)— O(1) string checkhasRoleForClass(user, classId, role)— checks class-scoped grants + form-tutor + admingetActiveCovers(classId, date?)— covers query used by the class detail UIrequireRole(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.jsmodifications:- Login now embeds
effective_rolesin the JWT payload and the user response - New
GET /api/auth/refresh-rolesre-signs the JWT with the latest effective roles
- Login now embeds
server/src/controllers/attendance.controller.js:- New
canMarkForClass(req, classId)helper using the new RBAC module POST /api/attendancenow rejects when a teacher tries to mark attendance for a class they don't own (no class-scoped grant)
- New
server/src/services/SyncEngine.js:user_rolesappended totablesToSyncafterusers(FK dependency)
Frontend
client/src/store/auth.ts:Userinterface now haseffective_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 withfetchUserRoles,grantRole,updateGrant,revokeGrant,fetchActiveCoversclient/src/components/RoleBadge.tsx— new component, shows primary role + auxiliary rolesclient/src/components/CoverAssignmentForm.tsx— new modal, supports cover and auxiliary modesclient/src/components/ParentViewToggle.tsx— new component, parent-as-staff toggle in the user menuclient/src/pages/admin/UserRoles.tsx— new admin page for managing grantsclient/src/App.tsx:ProtectedRoutenow checkseffective_roles(falls back touser.rolefor old JWTs)AppLayoutcallsrefreshRoles()once per auth session on mount- New route
/admin/user-rolesregistered forschool_admin+systems_admin
client/src/components/Nav.tsx:- New
Auxiliary Rolesnav entry for admins - Default-nav picker prefers an effective role that has a
NAV_CONFIGentry RoleBadgerendered next to the user nameParentViewTogglemounted above the action buttons
- New
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
cd server && npm install && npm run db:init— applies the new migration- Login as
admin@school.com / admin123— JWT payload now containseffective_roles: ['school_admin'] - Open
/admin/user-roles— pick a user, grant alibrarianauxiliary role. The right-pane list updates. - Open
/libraryin a new tab — the librarian page now loads (previously forbidden for non-librarians) - From
/admin/user-roles, grant a teacher an unscopedlibrariangrant. Sign in as that teacher — the role badge in the user menu shows "Librarian +" - 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.
- 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.rolecheck to the newhasRolehelper. That's the "incremental RBAC migration" follow-up. Onlyattendance.controller.jsadopts 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.roleCHECK constraint — the primary role stays the same.
Risk register
- JWT shape change: existing JWTs without
effective_rolesstill work (fallback touser.roleeverywhere). Refresh on app load picks up the new shape within one client cycle. - Offline path:
refreshRolesis a no-op when offline. The PWA continues to use the last-knowneffective_roles. The SyncEngine will sync user_roles rows so they're available when the user comes back online. - One controller touched: only
attendance.controller.jsadopts the new pattern. All other controllers keep their existingreq.user.rolechecks. This is the deliberately thin slice.