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

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