# Permissions & view-as mode

**Status:** in production
**Lives at:** `lib/effective-user.ts`, `lib/permissions.ts` · `app/actions/view-as.ts` · `components/view-as-switcher.tsx`, `components/nav.tsx`

## Summary
Permissions are computed from an "effective user" — the real logged-in
user, or a target user a super admin has chosen to view-as. The nav shows
an orange banner while in view-as mode, and owner-only actions are hidden.

## Why
2026-05-21 — "Adi should not be able to transfer, only Rian can transfer.
As a super admin, I want to be able to switch into 'Adi' mode so I can see
and use the app as he sees it."

## Behavior
- `getEffectiveUser(supabase, orgId)` returns the user the app should
  behave as.
  - If a `view_as_user_id` cookie is set AND the real user is a super
    admin, returns the target user's profile + their role in `orgId`.
  - Otherwise returns the real user's profile + their org role.
- A `is_viewing_as` flag and `view_as_label` are included on the return.
- Permission helpers (`canTransfer`, `canManageTeam`,
  `canManageImports`, `canEnterViewAs`) operate on the effective user.
- The nav shows the effective user's name + a role label:
  - "(super admin)" if `is_super_admin && !is_viewing_as`
  - else the org role (owner / manager / member)
- A "View as ▾" dropdown appears in the nav for super admins not
  currently viewing-as. Picking a user sets the cookie (8-hour TTL,
  HttpOnly, SameSite=Lax) and redirects to `/`.
- An orange banner appears across the top while in view-as mode with an
  "Exit view" button that clears the cookie.

## Constraints & edge cases
- View-as is a UI/permission layer only. Data access still happens as
  the real authenticated user; RLS doesn't change. This means a super
  admin viewing as a non-member would still see all data their real
  auth allows.
- View-as does not nest: `canEnterViewAs` returns false while
  `is_viewing_as` is true.
- If the target user has no membership in the current org,
  `org_role = null`. Some permission checks default to false in that
  case.
- Server actions enforce permissions independently — the UI hiding a
  button is not enough; the action must reject if the effective user
  isn't authorized.
- The switcher in the nav requires reading other users' profiles + org
  memberships. That works via the super-admin RLS policies added in
  migration 0003. Two simple queries (memberships + profiles), not a
  PostgREST FK join — there's no FK between `organization_members` and
  `profiles` (both reference `auth.users`).

## Permissions matrix

| Action | super admin (real) | super admin (viewing-as) | owner | manager | member |
|---|---|---|---|---|---|
| Manage team | ✓ | (effective role) | ✓ | ✓ | ✗ |
| Upload import / delete batch | ✓ | (effective role) | ✓ | ✓ | ✗ |
| Edit / delete entry (non-transferred) | ✓ | (effective role) | ✓ | ✓ | ✗ |
| Edit / delete entry (transferred) | ✓ | ✗ unless effective role = owner | ✓ | ✗ | ✗ |
| See Transfer page / nav link | ✓ | ✗ unless effective role = owner | ✓ | ✗ | ✗ |
| Mark as transferred | ✓ | ✗ unless effective role = owner | ✓ | ✗ | ✗ |
| Undo a transfer batch | ✓ | ✗ unless effective role = owner | ✓ | ✗ | ✗ |
| Enter view-as | ✓ | ✗ (no nesting) | ✗ | ✗ | ✗ |

## Open considerations
- View-as could be made *true* impersonation by minting a JWT for the
  target user and using it for queries. Much more complex; not needed
  yet.
- No audit log of view-as enters/exits. Worth adding when there are more
  users.
- Permissions are currently coarse. As features grow (CRM, invoicing) we
  may need fine-grained per-resource permissions.

## Tests
- 🟡 RBAC-001 Adi sees /transfer but no "Mark as transferred" button.
- 🟡 RBAC-002 Rian sees the "View as ▾" switcher.
- 🟡 RBAC-003 view_as cookie surfaces banner + disables owner-only actions.

(Integration only — depend on cookies + Supabase Auth.)

## Changelog
- **2026-05-21** — Initial: effective-user helper, permission helpers,
  view-as cookie, nav switcher + banner. Migration 0003 added the RLS
  policies needed for super admins to read other users' profiles and
  memberships. The recursive policy bug was fixed in
  20260521120001_fix_recursive_rls.
- **2026-05-21** — Added `canEditEntry(eu, entry)` — transferred
  entries can only be edited by owners / super admins. Hidden the
  Transfer nav link and gated `/transfer` server-side for users
  without transfer rights (Adi, etc.).
- **2026-05-23** — View-as now restricts **data**, not just
  permissions. The new `getViewAsImportScope()` helper in
  `lib/effective-user.ts` returns the list of import IDs the effective
  user is allowed to see; it's applied at every read site (`/entries`,
  `/`, `/import`) plus the bulk-mutation path in
  `entries/actions.ts`. Before this fix, a super-admin viewing-as a
  manager could still see everything because RLS keys off the real
  JWT user. See decisions.md #030.
- **2026-05-23** — Extended the view-as data overlay to `/team`. New
  `filterTeamsForViewAs()` helper mirrors the `teams` RLS rule at the
  app layer: when view-as is on a non-owner, the list is narrowed to
  teams owned by the effective user. Adi-as-view sees only Adi's team
  on /team (was: both teams).
- **2026-05-23** — **View-as is now true impersonation, end-to-end.**
  Every per-row mutation gained a guard
  (`lib/view-as-guards.ts` — `guardEntry`, `guardEntryIds`,
  `guardImport`, `guardTeam`, `guardTeamMember`) that fails the action
  if the target is outside the effective user's would-be RLS scope.
  Attribution columns (`imported_by`, `author_id`, `resolved_by`) now
  use the effective user id, not the real user id — so an upload in
  view-as Adi appears in Adi's batch list after exiting view-as, and
  a comment posted in view-as appears as Adi's. See decisions.md #033.
