Files
konstruct/.planning/phases/04-rbac/04-02-PLAN.md

319 lines
17 KiB
Markdown

---
phase: 04-rbac
plan: 02
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- packages/portal/lib/auth.ts
- packages/portal/lib/auth-types.ts
- packages/portal/proxy.ts
- packages/portal/components/nav.tsx
- 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
- packages/portal/app/(dashboard)/layout.tsx
autonomous: true
requirements:
- RBAC-05
- RBAC-04
- RBAC-01
must_haves:
truths:
- "JWT token contains role, tenant_ids, and active_tenant_id after login"
- "Customer operator navigating to /billing is silently redirected to /agents"
- "Customer operator does not see Billing, API Keys, or User Management in sidebar"
- "Customer admin sees tenant dashboard after login"
- "Platform admin sees platform overview with tenant picker after login"
- "Multi-tenant user can switch active tenant without logging out"
- "Impersonation shows a visible banner with exit button"
- "Invite acceptance page accepts token, lets user set password, creates account"
- "User management page lists users for tenant with invite button"
- "Platform admin global user page shows all users across all tenants"
artifacts:
- path: "packages/portal/lib/auth-types.ts"
provides: "TypeScript module augmentation for Auth.js types with role + tenant fields"
contains: "declare module"
- path: "packages/portal/lib/auth.ts"
provides: "Updated Auth.js config with role + tenant_ids in JWT"
contains: "token.role"
- path: "packages/portal/proxy.ts"
provides: "Role-based redirects for unauthorized paths"
contains: "customer_operator"
- path: "packages/portal/components/nav.tsx"
provides: "Role-filtered sidebar navigation"
contains: "useSession"
- path: "packages/portal/components/tenant-switcher.tsx"
provides: "Tenant switcher dropdown component"
min_lines: 30
- path: "packages/portal/components/impersonation-banner.tsx"
provides: "Impersonation indicator banner"
min_lines: 15
- path: "packages/portal/app/invite/[token]/page.tsx"
provides: "Invite acceptance page with password form (outside dashboard layout — no auth required)"
min_lines: 40
- path: "packages/portal/app/(dashboard)/users/page.tsx"
provides: "Per-tenant user management page"
min_lines: 40
- path: "packages/portal/app/(dashboard)/admin/users/page.tsx"
provides: "Platform admin global user management page"
min_lines: 40
key_links:
- from: "packages/portal/lib/auth.ts"
to: "/api/portal/auth/verify"
via: "fetch in authorize(), receives role + tenant_ids"
pattern: "role.*tenant_ids"
- from: "packages/portal/proxy.ts"
to: "packages/portal/lib/auth.ts"
via: "reads session.user.role for redirect logic"
pattern: "session.*user.*role"
- from: "packages/portal/components/nav.tsx"
to: "next-auth/react"
via: "useSession() to read role for nav filtering"
pattern: "useSession"
- from: "packages/portal/components/tenant-switcher.tsx"
to: "next-auth/react"
via: "update() to change active_tenant_id in JWT"
pattern: "update.*active_tenant_id"
---
<objective>
Portal RBAC integration: Auth.js JWT updates, role-based proxy redirects, role-filtered navigation, tenant switcher, impersonation banner, invite acceptance page, and user management pages.
Purpose: The portal must adapt its UI and routing based on user role — hiding restricted items for operators, redirecting unauthorized URL access, and providing user management for admins.
Output: Updated auth config, proxy, nav, plus new components (tenant switcher, impersonation banner) and pages (invite acceptance, user management).
</objective>
<execution_context>
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-rbac/04-CONTEXT.md
@.planning/phases/04-rbac/04-RESEARCH.md
@.planning/phases/04-rbac/04-01-SUMMARY.md
<interfaces>
<!-- From Plan 01 outputs — executor needs these contracts -->
From packages/shared/shared/api/portal.py (updated in Plan 01):
```python
class AuthVerifyResponse(BaseModel):
id: str
email: str
name: str
role: str # "platform_admin" | "customer_admin" | "customer_operator"
tenant_ids: list[str] # All tenant UUIDs this user has membership in
active_tenant_id: str | None # First tenant or None
```
From packages/shared/shared/api/invitations.py (created in Plan 01):
```python
invitations_router = APIRouter(prefix="/api/portal/invitations", tags=["invitations"])
# POST /api/portal/invitations/accept — accepts {token: str, password: str}
# Returns user details on success, 400 on invalid/expired token
```
From packages/shared/shared/api/rbac.py (created in Plan 01):
```python
class PortalCaller:
user_id: uuid.UUID
role: str # "platform_admin" | "customer_admin" | "customer_operator"
tenant_id: uuid.UUID | None
# Headers passed from portal proxy to FastAPI:
# X-Portal-User-Id, X-Portal-User-Role, X-Portal-Tenant-Id
```
From packages/portal/lib/auth.ts (current — to be updated):
```typescript
// Currently passes is_admin in JWT. Must change to role + tenant_ids + active_tenant_id
export const { handlers, auth, signIn, signOut } = NextAuth({ ... });
```
From packages/portal/proxy.ts (current — to be extended):
```typescript
// Currently only checks session existence. Must add role-based redirects.
export async function proxy(request: NextRequest): Promise<NextResponse> { ... }
```
From packages/portal/components/nav.tsx (current — to be updated):
```typescript
// Currently shows all nav items to all users. Must filter by role.
const navItems = [ /* dashboard, tenants, agents, usage, billing, api-keys */ ];
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Auth.js JWT update + type augmentation + proxy role redirects</name>
<files>
packages/portal/lib/auth-types.ts,
packages/portal/lib/auth.ts,
packages/portal/proxy.ts
</files>
<action>
Create `packages/portal/lib/auth-types.ts`:
- Module augmentation for "next-auth" and "next-auth/jwt"
- Extend `User` interface: `role?: string; tenant_ids?: string[]; active_tenant_id?: string | null;`
- Extend `Session.user`: `role: string; tenant_ids: string[]; active_tenant_id: string | null;`
- Extend `JWT`: `role?: string; tenant_ids?: string[]; active_tenant_id?: string | null;`
- This file MUST be imported in auth.ts to ensure TypeScript picks up the augmentation
Update `packages/portal/lib/auth.ts`:
- Import `./auth-types` at the top (side-effect import for type augmentation)
- Update `authorize()` response type: replace `is_admin: boolean` with `role: string; tenant_ids: string[]; active_tenant_id: string | null`
- Update `jwt` callback: store `token.role = u.role`, `token.tenant_ids = u.tenant_ids`, `token.active_tenant_id = u.tenant_ids[0] ?? null`
- Add `trigger: "update"` handling in jwt callback: `if (trigger === "update" && session?.active_tenant_id) { token.active_tenant_id = session.active_tenant_id; }` — this enables the tenant switcher to update the JWT mid-session
- Update `session` callback: pass `role`, `tenant_ids`, `active_tenant_id` to session.user
- Remove all `is_admin` references
Update `packages/portal/proxy.ts`:
- Add invite acceptance path `/invite` to public paths (no auth required — the invite page must be accessible to unauthenticated users accepting an invite)
- After session check, extract `role` from session.user
- Define restricted path lists:
- PLATFORM_ADMIN_ONLY = ["/admin"]
- CUSTOMER_ADMIN_ONLY = ["/billing", "/settings/api-keys", "/users"]
- Role-based redirect logic (per locked decision — silent redirect, no 403 page):
- customer_operator trying restricted paths -> redirect to "/agents"
- customer_admin trying platform admin paths -> redirect to "/dashboard"
- Role-based landing page after login (replace current hardcoded "/dashboard"):
- platform_admin -> "/dashboard"
- customer_admin -> "/dashboard"
- customer_operator -> "/agents"
- Pass role headers to API routes: When the request is forwarded to backend API routes, the proxy should add X-Portal-User-Id, X-Portal-User-Role, and X-Portal-Tenant-Id headers. NOTE: This may be handled in the Next.js API route layer or server actions rather than proxy.ts — check how existing portal API calls work. If the portal uses direct fetch() to the gateway from API routes, the headers should be added there. If proxy.ts doesn't handle API forwarding, skip this and document that API route handlers must add these headers.
</action>
<verify>
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<done>Auth.js JWT contains role + tenant_ids + active_tenant_id. Proxy redirects operators away from restricted paths. Invite page is publicly accessible. TypeScript compiles without errors.</done>
</task>
<task type="auto">
<name>Task 2: Role-filtered nav + tenant switcher + impersonation banner</name>
<files>
packages/portal/components/nav.tsx,
packages/portal/components/tenant-switcher.tsx,
packages/portal/components/impersonation-banner.tsx,
packages/portal/app/(dashboard)/layout.tsx
</files>
<action>
Update `packages/portal/components/nav.tsx`:
- Import `useSession` from "next-auth/react"
- Get session via `const { data: session } = useSession();`
- Extract role from `session?.user?.role`
- Add role visibility metadata to each nav item:
- Dashboard: all roles
- Tenants: platform_admin only
- Employees (agents): all roles
- Usage: all roles
- Billing: platform_admin, customer_admin
- API Keys: platform_admin, customer_admin
- NEW — Users: platform_admin, customer_admin (href: "/users")
- NEW — Platform (admin): platform_admin only (href: "/admin/users")
- Filter navItems to only show items where current role is in the allowed list
- Per locked decision: restricted items are HIDDEN, not disabled/grayed
Create `packages/portal/components/tenant-switcher.tsx`:
- "use client" component
- Uses `useSession()` to read `session.user.tenant_ids` and `session.user.active_tenant_id`
- Only renders if `tenant_ids.length > 1` (single-tenant users see nothing)
- Dropdown showing tenant names (fetch from /api/portal/tenants or pass as prop)
- On selection change, calls `update({ active_tenant_id: selectedId })` from useSession() — this triggers the JWT callback with `trigger: "update"`, updating the cookie without page reload
- Per specifics: "should feel instant — no page reload, just context switch"
- Use shadcn/ui Select or DropdownMenu component for the dropdown
- After switching, invalidate TanStack Query cache to refetch data for new tenant context
Create `packages/portal/components/impersonation-banner.tsx`:
- "use client" component
- Reads a custom JWT claim `impersonating_tenant_id` from session (or a query param/cookie set by platform admin)
- If impersonating, shows a fixed banner at top of viewport: "Viewing as [Tenant Name] — Exit" with a distinct background color (e.g., amber/yellow)
- Exit button clears impersonation state (calls update() to remove impersonating_tenant_id from JWT)
- Per specifics: "clear visual indicator so the admin knows they're viewing as a customer (and can exit easily)"
Update `packages/portal/app/(dashboard)/layout.tsx`:
- Add TenantSwitcher component to the sidebar area (after the brand section, before nav items)
- Add ImpersonationBanner component above the main content area
</action>
<verify>
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<done>Nav filters items by role. Tenant switcher renders for multi-tenant users and switches context without reload. Impersonation banner shows when active. Layout integrates both components. TypeScript compiles clean.</done>
</task>
<task type="auto">
<name>Task 3: Invite acceptance page + user management pages</name>
<files>
packages/portal/app/invite/[token]/page.tsx,
packages/portal/app/(dashboard)/users/page.tsx,
packages/portal/app/(dashboard)/admin/users/page.tsx
</files>
<action>
Create `packages/portal/app/invite/[token]/page.tsx`:
- IMPORTANT: This page is created OUTSIDE the (dashboard) route group because the (dashboard) layout enforces authentication. Invite acceptance must be accessible to unauthenticated users who are creating their account.
- Reads `token` from URL params
- On load, validates token client-side by calling a GET endpoint or just displays the form (server validates on submit)
- Form fields: Password (min 8 chars), Confirm Password
- On submit, POST to `/api/portal/invitations/accept` with `{ token, password }`
- Success: show "Account created successfully" message, redirect to /login after 2 seconds
- Error states: "This invitation has expired" (with note to contact admin for a new one), "Invalid invitation link", generic error
- Per specifics: should feel professional — show tenant name and invitee name from the invite data if available
- Use existing form patterns (standardSchemaResolver + zod v4 per Phase 1 decision)
Create `packages/portal/app/(dashboard)/users/page.tsx`:
- Per-tenant user management page (customer_admin + platform_admin access)
- Fetches users for the active tenant via API call (GET /api/portal/tenants/{tenant_id}/users — this endpoint needs to exist; if not created in Plan 01, add a note that Plan 03 must create it, or add a simple users list endpoint to invitations.py)
- Table showing: Name, Email, Role, Status (active/pending), Invited date
- "Invite User" button opens a form/dialog: name, email, role selector (admin/operator)
- For pending invitations: show "Resend" button (calls POST /api/portal/invitations/{id}/resend)
- Use TanStack Query for data fetching (established pattern)
- Use shadcn/ui Table, Button, Dialog components
Create `packages/portal/app/(dashboard)/admin/users/page.tsx`:
- Platform admin global user management page
- Fetches ALL users across all tenants (GET /api/portal/admin/users — platform_admin only endpoint; may need to be added to invitations.py or a new admin.py router)
- Table showing: Name, Email, Role, Tenant(s), Status, Created date
- Filter controls: by tenant (dropdown), by role (dropdown)
- "Invite User" button — same as per-tenant but with tenant selector added
- If the backend endpoints for user listing don't exist yet, create stub API calls that Plan 03 will wire up. Use TanStack Query with the expected endpoint paths.
</action>
<verify>
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<done>Invite acceptance page renders password form and submits to accept endpoint. Page is outside (dashboard) group so unauthenticated users can access it. Per-tenant users page lists users with invite/resend capability. Platform admin users page shows cross-tenant user list with filters. All pages compile without TypeScript errors.</done>
</task>
</tasks>
<verification>
- `cd packages/portal && npx tsc --noEmit` — zero TypeScript errors
- `cd packages/portal && npx next build` — build succeeds
- Login as platform_admin: JWT contains role="platform_admin", sees all nav items
- Login as customer_operator: does not see Billing/API Keys/Users in nav, /billing redirects to /agents
- Visit /invite/{token} while logged out: page renders without auth redirect
</verification>
<success_criteria>
- Auth.js JWT carries role + tenant_ids + active_tenant_id (not is_admin)
- Proxy silently redirects operators away from restricted paths
- Nav hides restricted items based on role
- Tenant switcher works for multi-tenant users (no page reload)
- Impersonation banner renders when impersonating
- Invite acceptance page at /invite/[token] (outside dashboard layout) accepts token and creates account without requiring auth
- User management pages exist for tenant admin and platform admin
- Portal builds and TypeScript compiles clean
</success_criteria>
<output>
After completion, create `.planning/phases/04-rbac/04-02-SUMMARY.md`
</output>