# 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.
