96 lines
5.9 KiB
Markdown
96 lines
5.9 KiB
Markdown
# 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.
|