geocrop-platform./apps/nextgen/.harness/changelogs/2026-07-22-user-roles.md

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.