diff --git a/AGENTS.md b/AGENTS.md index 328c8c0..e4cbc31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,6 +345,9 @@ Username-based login (no email login). Users log in via Payload admin with `user - **Return-URL flow**: the `LandingPage` login CTA is `LoginLink` (`src/components/frontend/auth/LoginLink.tsx` — a client component using `usePathname()`), which links to `/login?returnTo=`. The login page awaits `searchParams`, sanitizes `returnTo` via `safeReturnTo` (must start with `/`, must not start with `//` — open-redirect guard), and passes it to `LoginForm`. Already-authed users hitting `/login?returnTo=X` are redirected straight to `X`. The path is attached client-side because server components cannot reliably know the current path (see "Next.js 16 hard rules"). Full design story: `docs/case-studies/login-return-url.md`. - Logout lives in `NavUser` (sidebar) → `/api/users/logout`. - Tip: a corrupted `payload-token` cookie causes an infinite login loop (`Unexpected end of JSON input` on `/api/users/me`) — clear cookies/use incognito. Dev user `dev` / `Test123`. +- **Admin panel access is two separate gates, do not collapse them**: (1) panel ENTRY is the Users collection `access.admin` (`src/collections/users/Users.ts`) and uses `canAccessAdminPanel` (superuser OR any `admin::manage`); (2) per-page sidebar visibility is `requireAdminPageAccess` (applied centrally in `payload.config.ts`), requiring BOTH `:read` AND `admin::manage` per collection. +- **Permission tiers**: `system:admin-access` (registry label "Superuser Tier (user management, notification oversight, final approval states)") is the functional superuser tier: final approval states (`approvalStatusFieldAccess` in `src/utils/access-control/divisionAccess.ts`, wired to the `approvalStatus` field on Assets/Resources/Vehicles/Technologies), the Users admin page, and read-all notifications. Only the built-in `admin` role and superuser roles hold it. NEVER grant it to division roles; they get panel access through `admin::manage`. The dev DB once had it hand-granted to Logistics/Intelligence Officer, which silently made officers approval-superusers. +- **Impersonation**: `/api/impersonation/start` (POST `{ userId }`, operator must be a DYNAMIC superuser: `roleDocs` → Roles collection with `isSuperuser: true`; the dev user's legacy `role: developer` does NOT pass and gets 403). Swaps `payload-token` for a JWT signed as the target (a fresh session `sid` is added to the target's `sessions` first, or `auth()` returns null) and stashes the original token in the `ptf-impersonation-original` cookie. Stop via `/api/impersonation/stop`. ## Database @@ -426,6 +429,10 @@ Whenever creating a new component or refactoring an existing component's styling - Payload admin layout and importMap are auto-generated — do not edit by hand. - `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility. - `bun run db push` fails with `must be owner of table spatial_ref_sys` (a PostGIS table owned by the DB superuser) — drizzle-kit push does a full-schema diff and trips on it. Workaround: apply the needed `ALTER TABLE` directly (via node + `pg` reading `DATABASE_URI` from `.env`) or use Payload's migration runner (`bun run payload migrate`). +- **Payload 3 field-level access never rejects an update request**: when a field's `access` fn returns `false`, Payload silently deletes that field from the incoming data (`node_modules/payload/dist/fields/hooks/beforeValidate/promise.js`) and the save SUCCEEDS with the previous value. No 403, "Updated successfully." toast, other fields still save. It is enforcement without feedback; if you need a loud rejection or hidden dropdown options, that requires a collection `beforeChange` hook or a custom admin field component. +- **Admin-panel-visible access bugs that contradict passing API tests**: check the actual role permission rows in the DB first, then the field-strip semantics above. The dev DB once had `system:admin-access` hand-granted to division roles (tests build clean roles, so tests pass while the panel misbehaves). The 30s `loadUserPermissions` cache is per-user-id and safe, but remember it exists when editing role data. +- PATCHing a document that is open in someone's admin edit tab returns **423 Locked** (Payload document locks). Test with a different document or wait for the lock to clear. +- For replaying admin-panel requests from the terminal: `/api/access` returns the caller's resolved permission map (collection `read` results are either a bare boolean like `true` or `{ permission, where }`; handle both shapes), and an officer-grade JWT can be crafted exactly like the impersonation route does (add a session `sid` to the target user, then `jwtSign` with `getFieldsToSign`). ## Server Actions convention diff --git a/src/collections/AGENTS.md b/src/collections/AGENTS.md index ec66fd9..6755452 100644 --- a/src/collections/AGENTS.md +++ b/src/collections/AGENTS.md @@ -60,6 +60,11 @@ Collections define `access` at field + document level using helpers from `src/ut Admin group access: `developer` only for destructive operations, `admin` for read/write. +Permission tiers and panel gates: +- `system:admin-access` ("Superuser Tier" label) = functional superuser tier: final approval states (`approvalStatusFieldAccess`), Users admin page, read-all notifications. Only built-in `admin` + superuser roles hold it; NEVER grant to division roles. +- Admin panel ENTRY: Users collection `access.admin` uses `canAccessAdminPanel` (superuser OR any `admin::manage`). Per-page sidebar visibility: `requireAdminPageAccess` (`:read` + `admin::manage`). Two gates, do not collapse them. +- Payload 3 field-level `access` returning `false` on update silently drops the field (no 403, success toast); the document saves with the previous value. Loud rejection or hidden options require a `beforeChange` hook or custom admin field component. + ## Hooks with side effects - `Structures` `beforeChange`: emits `structure:resize`; blueprint "Staffing Defaults" group via `staffingFields.ts` diff --git a/src/utils/AGENTS.md b/src/utils/AGENTS.md index d9d5d09..8d48d12 100644 --- a/src/utils/AGENTS.md +++ b/src/utils/AGENTS.md @@ -28,6 +28,12 @@ Lightweight role checks. Used for quick conditional rendering (e.g., `isRole("ad ### 3. `hasLogisticsQualification()` / `hasIntelligenceQualification()` Queries `Profiles.progression.qualifications` for specific qualification strings (case-insensitive). Admin/developer always pass. Used to gate logistics-only and intelligence-only UI sections. +### Permission tiers and admin panel gates +- `system:admin-access` (registry label "Superuser Tier (user management, notification oversight, final approval states)") is the functional superuser tier: final approval states (`approvalStatusFieldAccess` in `divisionAccess.ts`), the Users admin page, read-all notifications. Held ONLY by the built-in `admin` role and superuser roles. NEVER grant it to division roles; a hand grant once made officers approval-superusers. +- Admin panel ENTRY is Users collection `access.admin` and uses `canAccessAdminPanel` (superuser OR any `admin::manage`). Per-page sidebar visibility is `requireAdminPageAccess` (`:read` + `admin::manage`). Two gates, do not collapse them. +- Payload 3 field-level access never rejects an update: a `false` return silently strips the field (`node_modules/payload/dist/fields/hooks/beforeValidate/promise.js`) and the save succeeds with the old value. No 403, success toast, other fields save. +- Access bugs visible in the admin panel but contradicted by passing API tests: inspect the real role permission rows in the DB first (hand-granted data has diverged from the registry before), then the field-strip semantics. Tests build clean roles, so they cannot catch stray grants. + ## Event log system ### Emitter: `emitGameEvent(payload, { type, message, ... })`