--- phase: 04-rbac plan: "02" subsystem: auth tags: [nextjs, nextauth, jwt, rbac, typescript, react, tanstack-query, shadcn, base-ui] requires: - phase: 04-rbac-01 provides: JWT auth verify endpoint returning role+tenant_ids, invitation accept endpoint, RBAC guards, portal auth module provides: - Auth.js JWT carries role + tenant_ids + active_tenant_id (replaces is_admin) - Proxy (proxy.ts) enforces role-based redirects — operators silently redirected from restricted paths - Invite acceptance page at /invite/[token] (outside dashboard layout, no auth required) - Role-filtered sidebar nav — restricted items hidden not disabled - Tenant switcher updates JWT active_tenant_id without page reload - Impersonation banner with exit button when platform admin views as tenant - Per-tenant user management page with invite dialog and resend capability - Platform admin global user management page with cross-tenant table and filters affects: [04-rbac-03] tech-stack: added: [] patterns: - "base-ui DialogTrigger uses render prop not asChild" - "base-ui Select onValueChange receives string | null (not string)" - "Controller from react-hook-form wraps base-ui Select for form integration" - "zod v4 z.enum() does not accept required_error param" - "Next.js 15 params is a Promise — unwrap with use() in client components" key-files: created: - packages/portal/lib/auth-types.ts - packages/portal/components/tenant-switcher.tsx - packages/portal/components/impersonation-banner.tsx - packages/portal/app/invite/[token]/page.tsx - packages/portal/app/(dashboard)/users/page.tsx - packages/portal/app/(dashboard)/admin/users/page.tsx modified: - packages/portal/lib/auth.ts - packages/portal/proxy.ts - packages/portal/components/nav.tsx - packages/portal/app/(dashboard)/layout.tsx key-decisions: - "base-ui DialogTrigger uses render prop pattern, not asChild — fixes TS error 'asChild does not exist'" - "base-ui Select onValueChange typed as (string | null) — filter handlers use ?? '' to coerce null" - "Invite page placed at app/invite/[token]/page.tsx (outside (dashboard) group) so unauthenticated users can access it" - "zod v4 enum validation drops required_error from params — just z.enum([...]) with no options object" patterns-established: - "Role-based nav filtering: navItems carry allowedRoles array, filtered at render time via useSession" - "Tenant switcher calls Auth.js update() which triggers jwt callback with trigger=update" - "Impersonation stored as impersonating_tenant_id in JWT — cleared via update({impersonating_tenant_id: null})" - "Page-level TanStack Query hooks: useQuery/useMutation defined inline above the page component" requirements-completed: - RBAC-05 - RBAC-04 - RBAC-01 duration: 5min completed: 2026-03-24 --- # Phase 4 Plan 02: Portal RBAC Integration Summary **Role-based portal with Auth.js JWT carrying role+tenants, operator path restrictions, tenant switcher, impersonation banner, invite acceptance page, and user management pages** ## Performance - **Duration:** ~5 min - **Started:** 2026-03-24T23:02:53Z - **Completed:** 2026-03-24T23:07:17Z - **Tasks:** 3 - **Files modified:** 9 (4 modified, 5 created from scratch + 3 new pages) ## Accomplishments - JWT now carries role + tenant_ids + active_tenant_id; proxy enforces role-based redirects silently - Tenant switcher updates active_tenant_id in JWT without page reload, invalidates TanStack Query cache - Three new pages: invite acceptance (public), per-tenant users, platform admin global users ## Task Commits 1. **Tasks 1+2: Auth.js JWT + nav + tenant switcher + impersonation banner** - `fcb1166` (feat) 2. **Task 3: Invite acceptance page + user management pages** - `744cf79` (feat) ## Files Created/Modified - `packages/portal/lib/auth-types.ts` - Module augmentation: role, tenant_ids, active_tenant_id in JWT/Session/User types - `packages/portal/lib/auth.ts` - JWT callbacks carry role+tenants, trigger=update for tenant switch and impersonation clear - `packages/portal/proxy.ts` - /invite public, customer_operator restricted from billing/users/admin, role-based landing page - `packages/portal/components/nav.tsx` - Role-filtered nav with Users and Platform admin items - `packages/portal/components/tenant-switcher.tsx` - Multi-tenant dropdown, Auth.js update() + queryClient invalidation - `packages/portal/components/impersonation-banner.tsx` - Fixed amber banner with exit button - `packages/portal/app/(dashboard)/layout.tsx` - Integrates ImpersonationBanner and TenantSwitcher - `packages/portal/app/invite/[token]/page.tsx` - Password form, POST /api/portal/invitations/accept, redirect to /login - `packages/portal/app/(dashboard)/users/page.tsx` - Tenant user list, invite dialog, resend pending invitations - `packages/portal/app/(dashboard)/admin/users/page.tsx` - Cross-tenant user table, tenant+role filters, invite with tenant selector ## Decisions Made - `base-ui DialogTrigger` uses `render` prop not `asChild` — the shadcn components are base-ui based, not Radix - `base-ui Select onValueChange` typed as `(string | null)` — filter state setters use `?? ""` to coerce null - `zod v4` `z.enum()` does not accept `required_error` option — removed from both user pages - Next.js 15 `params` is a Promise in page components — unwrap with `use(params)` per decision from Phase 3 ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Fixed base-ui component API incompatibilities** - **Found during:** Task 3 (TypeScript compilation) - **Issue:** Code written using Radix/shadcn API conventions (`asChild`, `onValueChange: (string) => void`, `required_error` in zod enum). The portal uses base-ui primitives which have different APIs. - **Fix:** Replaced `DialogTrigger asChild` with `DialogTrigger render={