1
0
Fork 0

Compare commits

...

8 commits

Author SHA1 Message Date
a8e147e52a docs: add AGENTS.md subdirectories and login case study
Add domain-specific AGENTS.md files for collections, components, lib,
utils, and bot. Add login-return-url case study documenting the
return-URL flow design decisions and debugging lessons learned.
2026-08-19 19:48:35 -04:00
fb74e6b0c2 chore(seed): update seed scripts for RBAC roles
Assign the developer role document to the sysadmin user on seed. Add
seedRoles script to create default RBAC roles.
2026-08-19 19:48:31 -04:00
d3890cc191 feat(discord): integrate RBAC into Discord bot
Replace legacy role-name checks in isStaff with hasPermission(discord:staff).
Auto-assign the 'user' role document on signup. Import hasPermission in
announce command for future permission gating.
2026-08-19 19:48:28 -04:00
1f9f919443 feat(auth): add return-URL flow for login redirects
Add LoginLink client component that captures current path via usePathname()
and passes it as returnTo query param. Update login page to read and
sanitize returnTo (open-redirect guard: must start with /, reject //).
Update LoginForm to redirect to returnTo after successful login. Wire
LandingPage CTA through LoginLink. Add returnTo redirect for already-
authed users hitting /login. Add return-URL to flappy page redirect.
Remove trailing blank line from (frontend) layout.
2026-08-19 19:48:24 -04:00
eef1b11bb1 feat(rbac): migrate server actions and service layers to RBAC
Replace hasRoles calls with hasPermission in all server actions and page
components. Update qualification checks (logistics, intelligence) to use
permission-based checks instead of role name matching. Update staff lookup
in tickets/staff.ts to query roles collection. Use enlistmentDate field
on profile page instead of createdAt.
2026-08-19 19:47:57 -04:00
653caa8064 feat(rbac): migrate collection access controls to RBAC permissions
Replace legacy isDeveloper/isAdmin/hasRoles checks with
hasPermission/requirePermission across all collections. Add roleDocs
relationship to Users, enlistmentDate to Profiles, and field-level
intel_excerpt permission gate on Profiles. Auto-generated payload-types
updated to reflect the new Roles collection and roleDocs field.
2026-08-19 19:47:51 -04:00
80f28ed939 feat(rbac): add dynamic roles collection and permissions system
Introduce a dynamic RBAC system with a new 'roles' collection that grants
granular permissions. Add hasPermission/requirePermission/loadUserPermissions
utilities and a central permissions registry. Register the Roles collection in
payload.config and add roleDocs relationship to Users.
2026-08-19 19:47:36 -04:00
adaedaefde chore: verify forgejo remote 2026-08-18 10:51:55 -04:00
75 changed files with 2661 additions and 340 deletions

View file

@ -1,5 +1,12 @@
# AGENTS.md — Polaris Task Force # AGENTS.md — Polaris Task Force
> **Sub-AGENTS.md files** (read these for domain-specific context):
> - `src/components/frontend/AGENTS.md` — Frontend component patterns, server/client split, Item primitive
> - `src/collections/AGENTS.md` — Payload collection map, RBAC, hooks, relationship graph
> - `src/lib/AGENTS.md` — Shared business logic, domain services, dependency graph
> - `src/utils/AGENTS.md` — Access control layers, event log, utilities
> - `src/bot/AGENTS.md` — Discord bot architecture, commands, services
## What this is ## What this is
Next.js 16 + Payload CMS 3.88.0 app for an Arma 3 unit. PostgreSQL database via `@payloadcms/db-postgres` + Drizzle. Tailwind CSS v4 (no config file — CSS-based). shadcn/ui (new-york style, `lucide` icons). Dark-themed frontend. Next.js 16 + Payload CMS 3.88.0 app for an Arma 3 unit. PostgreSQL database via `@payloadcms/db-postgres` + Drizzle. Tailwind CSS v4 (no config file — CSS-based). shadcn/ui (new-york style, `lucide` icons). Dark-themed frontend.
@ -32,7 +39,7 @@ bun run generate:importmap # regenerates payload admin importMap
npx tsc --noEmit 2>&1 | grep -E "error TS" | grep -v "\.next/" npx tsc --noEmit 2>&1 | grep -E "error TS" | grep -v "\.next/"
``` ```
The typecheck should now pass clean (the two pre-existing errors in `hasLogisticsQualification.ts` and `payload-generated-schema.ts` were resolved by the Payload 3.88.0 upgrade). Any error is yours. Known pre-existing errors (not yours, don't widen scope to fix them): `src/collections/users/Users.ts:57`, `src/tools/seed/backfillProfiles.ts:68`, `src/tools/seed/seedProfiles.ts:19` — all the same Payload profiles-create overload mismatch. Any **other** error introduced by your changes is yours.
## Test details ## Test details
@ -47,6 +54,27 @@ The typecheck should now pass clean (the two pre-existing errors in `hasLogistic
- If the user says **no**, do **not** start a dev server and do **not** attempt to verify via E2E or Playwright — the user will run the app and test themselves, then report back. - If the user says **no**, do **not** start a dev server and do **not** attempt to verify via E2E or Playwright — the user will run the app and test themselves, then report back.
- Stale dev processes: killing the process is not always enough; remove `.next/dev/devserver.lock` before restarting. - Stale dev processes: killing the process is not always enough; remove `.next/dev/devserver.lock` before restarting.
## Agent debugging SOP (read before fixing any bug)
These rules were extracted from a real multi-failure debugging session — the full story, including every wrong turn, is in `docs/case-studies/login-return-url.md`. Follow them literally; each one exists because skipping it produced a wrong fix.
1. **Trace the real render path before writing any fix.** State in your reply: which layout wraps the failing route, what each conditional renders, and which component actually owns the navigation or mutation you're changing. If a layout conditionally replaces `{children}` (the `(frontend)` guest gate renders `<LandingPage />` instead of the page), then code inside those children — including `redirect()` calls — is **dead code for that branch** and never runs.
2. **Treat every framework API as a hypothesis.** Before calling a header, hook, or helper, confirm it exists and returns what you expect — against the running app or current docs. An observed default value (e.g. `?next=%2F` when you expected `%2Fflappy`) means the data source was **empty** and your fallback leaked through — not that the data got mangled.
3. **Two failed fixes = your mental model is wrong.** Do not add a third fallback layer on top of a failing approach. Stop editing, re-read the flow, find the wrong assumption.
4. **Ask "which component actually knows this fact?"** The current URL is known client-side (`usePathname()`), not in server layouts. Attach data where it is known, at the point the navigation happens — not where it is merely convenient to compute.
5. **Match producer and consumer.** If you emit a query param (`next`), confirm the consumer reads that exact name (`returnTo`). Mismatches fail silently.
6. **Verify end-to-end before reporting done.** curl the failing route as a guest, follow the redirect, hit the API, check the authed route (a copy-pasteable matrix is in the case study). "Should work" is not verification.
7. **Restate before acting.** For non-trivial changes, output: assumptions → plan → verification command. Then implement.
## Next.js 16 hard rules
Break these and you get runtime errors or silently dead code:
- **`searchParams` and `params` props are Promises** in pages/layouts. `await` them before any property access. Error if violated: ``Route used `searchParams.x`. `searchParams` is a Promise and must be unwrapped with `await` or `React.use()```.
- **Server components (layouts/pages) cannot mutate cookies.** `cookies()` from `next/headers` is read-only there; calling `.set()` throws `Cookies can only be modified in a Server Action or Route Handler`. Writing cookies is only legal in Server Actions and Route Handlers.
- **`headers.get("x-invoke-path")` does not reliably contain the current pathname** in layouts. Never build redirect logic on it. Reliable sources of the current path: `usePathname()` (client components) and the request object (middleware / Route Handlers).
- **`redirect()` narrows poorly across control flow.** After an `if (!user) redirect(...)` early exit, TypeScript may still see `user` as nullable when the narrowing crosses a closure boundary — keep an explicit truthy branch around later `user` usage.
- **Sanitize user-controllable redirect targets** (open-redirect guard): accept only values starting with `/` and reject `//` (protocol-relative URLs). Working example: `safeReturnTo` in `src/app/login/page.tsx`.
## Generated files — never edit manually ## Generated files — never edit manually
@ -228,8 +256,9 @@ A Discord bot living in `src/bot/`, run as a standalone long-running process via
Username-based login (no email login). Users log in via Payload admin with `username` only. Username-based login (no email login). Users log in via Payload admin with `username` only.
- `src/app/(frontend)/layout.tsx` is the gate: guests render `LandingPage` (no app shell); authed users get sidebar + `GameTickRealtime`. - `src/app/(frontend)/layout.tsx` is the gate: guests render `LandingPage` (no app shell); authed users get sidebar + `GameTickRealtime`. Because the gate replaces `{children}` for guests, page-level `if (!user) redirect(...)` blocks under `(frontend)` are **unreachable dead code for guests** — they only serve as type-guards for the authed render path.
- `/login` (`src/app/login/page.tsx` + `src/components/frontend/auth/LoginForm.tsx`) POSTs `{ username, password }` to `/api/users/login`, then `router.push("/")` + `router.refresh()`. The page redirects already-authed users to `/`. - `/login` (`src/app/login/page.tsx` + `src/components/frontend/auth/LoginForm.tsx`) POSTs `{ username, password }` to `/api/users/login`, then `router.push(returnTo ?? "/")` + `router.refresh()`.
- **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=<current path>`. 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`. - 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`. - 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`.
@ -264,6 +293,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
## Gotchas ## Gotchas
- Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): `searchParams`/`params` are Promises and must be awaited; `cookies().set()` throws outside Server Actions/Route Handlers; `x-invoke-path` does not carry the real pathname in layouts.
- `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.test` as the real reference. - `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.test` as the real reference.
- `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive. - `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive.
- The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior. - The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior.
@ -271,3 +301,17 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
- Payload admin layout and importMap are auto-generated — do not edit by hand. - Payload admin layout and importMap are auto-generated — do not edit by hand.
- `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility. - `.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 start the dev server, whose Payload `push: true` path may skip the offending table. - `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 start the dev server, whose Payload `push: true` path may skip the offending table.
## Server Actions convention
Every `actions.ts` file follows the same pattern (10+ files use it):
1. `"use server"` directive at top
2. `import config from "@payload-config"` + `const payload = await getPayload({ config })`
3. Local `authenticate()` helper — dynamic import of `next/headers`, calls `payload.auth()` and `headers()`
4. Return type `ActionResult<T>`: `{ success: boolean; error?: string; data?: T }`
5. Permission check: `const { user } = await authenticate()` then `await hasPermission(payload, user, "domain:action")`
6. Mutation via `payload.create` / `payload.update` / `payload.delete`
7. Event emission: `await emitGameEvent(payload, { type: EventTypes.xxx, message, ... })`
8. Error handling: `catch (e) { return { success: false, error: e instanceof Error ? e.message : "Unknown error" } }`
The `authenticate()` function is **duplicated in every file** — not extracted to a shared helper. If you add a new server action, copy the pattern from an existing one (e.g., `src/app/(frontend)/logistics/banking/actions.ts`). Do NOT attempt to extract it to a shared helper unless the entire codebase migrates at once.

View file

@ -0,0 +1,238 @@
# Case study: preserving the return URL through login
How a "remember where I was going" feature was implemented (and mis-implemented three times
before that) in this repo. Written for agents — especially smaller local models — as a pattern
to copy and a set of anti-patterns to recognize early. Every wrong turn below was really taken;
the point of writing it down is that each failure had a *signal* that said "stop, your model is
wrong" long before anyone listened to it.
The distilled rules live in `AGENTS.md` → "Agent debugging SOP" and "Next.js 16 hard rules".
This document is the evidence behind them.
## The task
Unauthenticated users who navigate to a protected page (e.g. `/flappy`) should, after logging
in, land back on that page instead of the dashboard.
## The symptom (user report #1)
> I just logged out, went to `/flappy`, found the landing page and was redirected to `/login`
> (no url params!), logged in, and was brought to the dashboard instead of back to Flappy.
## The failure arc
### Attempt 1 — redirect from the page: never runs
**What was done:** the login page and `LoginForm` were taught to read a `returnTo` query
param, and the protected pages (`flappy`, `helpdesk`) got guest blocks like:
```ts
if (!user) {
const pathname = headers.get("x-invoke-path") || "/flappy";
redirect(`/login?next=${encodeURIComponent(pathname)}`);
}
```
**Why it seemed reasonable:** pages own their auth checks; `redirect()` is the canonical
Next.js way to bounce unauthenticated users.
**What actually happened:** no query param appeared at all. The user landed on `/login` bare
and went to `/` after login.
**Why it failed (two independent reasons):**
1. **The page never rendered.** `src/app/(frontend)/layout.tsx` is the real gate, and for
guests it *replaces* `{children}`:
```tsx
{user ? (
<SidebarProvider>… {children} …</SidebarProvider>
) : (
<LandingPage /> // ← guests never reach {children}
)}
```
Every `if (!user) redirect(...)` inside a page under `(frontend)` is **dead code for
guests**. The `redirect()` never executes because the page component never renders.
2. **Producer/consumer mismatch.** The pages emitted `?next=…`; the login page read
`searchParams.returnTo`. Even if the redirect had fired, the param would have been ignored.
Query-param mismatches fail silently — there is no error, just nothing.
**The signal that was missed:** the user said "I found the landing page" — the landing page
rendering *at all* proves the page body (and its redirect) never ran. That was the clue.
### Attempt 2 — move the redirect into the layout: `?next=%2F`
**What was done:** the same `x-invoke-path` redirect logic was moved into the layout, firing
for every guest.
**What actually happened (user report #2):**
> I did see `?next=` appear, but it was actually `?next=%2F` despite expecting `?next=/flappy`.
**Why it failed:** `headers.get("x-invoke-path")` does **not** reliably contain the current
pathname in Next.js 16 layouts — here it returned `/` (or nothing, hitting the `|| "/"`
fallback). Read the symptom the right way: `%2F` is not a mangled `/flappy`; it is the
**default value leaking through**. When you observe a default instead of your data, the data
source was empty. Mangling was never on the table.
**Bonus failure:** this also silently changed product behavior — guests lost the landing page
entirely and got an instant redirect instead. The task never asked for that.
### Attempt 3 — store the path in a cookie: runtime error
**What was done:** since the layout couldn't put the path in the URL, it tried to stash it in
a cookie: `cookies().set("loginRedirect", pathname, …)` in the layout body, then
`redirect("/login")`.
**What actually happened (user report #3):**
```
Error: Cookies can only be modified in a Server Action or Route Handler.
at RootLayout (src/app/(frontend)/layout.tsx:68:16)
```
**Why it failed:** in Next.js App Router, server components (layouts/pages) **cannot mutate
cookies**. `cookies()` from `next/headers` is read-only there. Writes are only legal in Server
Actions and Route Handlers. The framework error message is precise and correct — read it
literally instead of routing around it.
### Attempt 4 — referer fallback: complexity accretion
**What was done:** with `x-invoke-path` proven useless, a `referer`-header fallback was added
on top of the cookie approach (and then a second, duplicated copy of the same fallback block).
**Why it failed:** `referer` is empty on direct navigation (typing a URL), and when present it
points at the *previous* page, not the requested one. It cannot answer this question by
construction. This attempt also demonstrates the worst failure pattern of the whole session:
**when a fix fails, adding a fallback on top of it preserves the wrong assumption and adds
code.** By now there were three mechanisms layered (header → cookie → referer), none of which
could work, and duplicated code on top.
**This is the point where the correct move was:** stop editing. Two-plus failed fixes means
the mental model is wrong, not the implementation.
## The turn: trace the render path
Re-reading the flow instead of patching it produced the key facts:
1. Guests never reach page code — the layout's `LandingPage` branch is the whole guest
experience. So the path can only be captured **where the guest actually is**: inside the
landing page.
2. The landing page's CTA was a plain `<Link href="/login">` — a **static** link with no
knowledge of where the user is standing. *That* is the navigation point the whole feature
hangs on, and it is the thing that should carry the path.
3. Server components cannot reliably know the current path (`x-invoke-path` unreliable, no
request URL in layouts). The current path **is** reliably known by `usePathname()` in
client components — exactly at the point where the user clicks "log in".
Rule of thumb that falls out: **attach data where the fact is known, at the point the
navigation happens — not where it is convenient to compute.**
## The fix (4 small changes)
1. **`src/components/frontend/auth/LoginLink.tsx`** (new, ~8 lines) — a client component that
knows the current path and builds the link:
```tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ComponentProps } from "react";
export function LoginLink(props: Omit<ComponentProps<typeof Link>, "href">) {
const pathname = usePathname();
return <Link {...props} href={`/login?returnTo=${encodeURIComponent(pathname)}`} />;
}
```
2. **`src/components/frontend/LandingPage.tsx`** — the CTA swaps `<Link href="/login">` for
`<LoginLink>`. A guest at `/flappy` now gets `href="/login?returnTo=%2Fflappy"`.
3. **`src/app/login/page.tsx`** — awaits `searchParams` (Next 16: it's a Promise), sanitizes
the target against open redirects, sends already-authed users straight to their target:
```tsx
function safeReturnTo(value: string | undefined): string | undefined {
if (!value?.startsWith("/") || value.startsWith("//")) return undefined;
return value;
}
export default async function LoginPage({ searchParams }: {
searchParams: Promise<{ returnTo?: string }>;
}) {
const { returnTo } = await searchParams;
const target = safeReturnTo(returnTo);
// …
if (user) redirect(target ?? "/");
return <LoginForm returnTo={target} />;
}
```
4. **`src/components/frontend/auth/LoginForm.tsx`** — after a successful login POST,
`router.push(returnTo ?? "/")`. (Also: dropped `returnTo` from the POST body — the API
never read it; the redirect is purely client-side.)
And **reverted** the layout to its original guest flow, plus removed the dead page-level
redirect blocks' reliance on `x-invoke-path`. Net result: less code than the failing versions.
## The verification matrix
Run against a live dev server with the dev user (`dev` / `Test123`). Copy-pasteable:
```bash
# 1. Guest hits the protected route — landing page renders, CTA carries the path
curl -s http://localhost:3000/flappy | grep -o 'href="/login?returnTo=[^"]*"'
# expect: href="/login?returnTo=%2Fflappy"
# 2. Login page passes returnTo through to the form
curl -s "http://localhost:3000/login?returnTo=%2Fflappy" | grep -o '%2Fflappy'
# expect: %2Fflappy (present in the RSC payload)
# 3. Login API sets the auth cookie
curl -s -X POST http://localhost:3000/api/users/login \
-H 'content-type: application/json' \
-d '{"username":"dev","password":"Test123"}' -c /tmp/cookies.txt -o /dev/null -w "%{http_code}\n"
# expect: 200
# 4. Authed user reaches the protected page
curl -s -b /tmp/cookies.txt http://localhost:3000/flappy -o /dev/null -w "%{http_code}\n"
# expect: 200
# 5. Authed user hitting /login?returnTo=... bounces straight to the target
curl -s -b /tmp/cookies.txt -o /dev/null \
-w "%{http_code} -> %{redirect_url}\n" "http://localhost:3000/login?returnTo=%2Fflappy"
# expect: 307 -> http://localhost:3000/flappy
```
All five passed against the running dev server before the work was reported done. Note the
dev-server subtlety encountered along the way: a spawned `bun run dev` detected an
already-running server, exited, and the curls actually hit the *existing* hot-reloaded server —
which is fine (that's the surface the user sees), but know which process you're testing
against. Check the dev log for "Another next dev server is already running" and its PID.
## Signals you are on the wrong path (recognize these early)
- **A default value shows up instead of your data** (`?next=%2F`). The data source is empty.
Find out why it's empty — don't post-process the value.
- **The component you're editing never renders** for the scenario you're fixing (guests and
`{children}` replacement). Verify by asking what the user *saw* — if they saw the landing
page, page code didn't run.
- **A framework error message names a restriction** ("Cookies can only be modified in a Server
Action or Route Handler"). It is stating a rule, not a bug. Restructure to comply; don't
fight it.
- **You're adding a second or third fallback.** Each fallback is an admission the previous
model was wrong — while keeping it. Stop and re-derive instead.
- **The fix changes behavior the task never asked about** (landing page disappears). Scope
creep during a bug fix is a sign the approach is wrong, not a bonus.
## The rules (mirror of the AGENTS.md SOP)
1. Trace the real render path before writing any fix.
2. Treat every framework API as a hypothesis; verify it.
3. Two failed fixes = wrong mental model. Stop, re-read, find the wrong assumption.
4. Attach data where the fact is known (`usePathname()` client-side), at the point of
navigation.
5. Match producer and consumer (`next` vs `returnTo` fails silently).
6. Verify end-to-end (curl matrix above) before reporting done.
7. Restate before acting: assumptions → plan → verification command.

View file

@ -144,11 +144,18 @@ export async function requestDiscordUsernameChange(input: {
return { success: false, error: "That Discord username is already in use." }; return { success: false, error: "That Discord username is already in use." };
} }
const staffRoles = await payload.find({
collection: "roles",
where: { slug: { in: ["admin", "developer"] } },
limit: 2,
depth: 0,
overrideAccess: true,
});
const staffRoleIds = staffRoles.docs.map((d) => d.id);
if (staffRoleIds.length > 0) {
const staffRes = await payload.find({ const staffRes = await payload.find({
collection: "users", collection: "users",
where: { where: { roleDocs: { in: staffRoleIds } },
or: [{ roles: { contains: "admin" } }, { roles: { contains: "developer" } }],
},
limit: 100, limit: 100,
depth: 0, depth: 0,
overrideAccess: true, overrideAccess: true,
@ -165,6 +172,7 @@ export async function requestDiscordUsernameChange(input: {
link: `/admin/collections/users/${userId}`, link: `/admin/collections/users/${userId}`,
}); });
} }
}
await notifyUser(payload, { await notifyUser(payload, {
userId, userId,

View file

@ -18,7 +18,8 @@ export default async function FlappyPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) { if (!user) {
redirect("/login"); const pathname = headers.get("x-invoke-path") || "/flappy";
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
} }
const profileRes = await payload.find({ const profileRes = await payload.find({

View file

@ -4,7 +4,7 @@ import { notFound, redirect } from "next/navigation";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Ticket } from "@/payload-types"; import type { Ticket } from "@/payload-types";
import { TicketDetail } from "@/components/frontend/helpdesk/TicketDetail"; import { TicketDetail } from "@/components/frontend/helpdesk/TicketDetail";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { ArrowLeftIcon, LifeBuoyIcon } from "lucide-react"; import { ArrowLeftIcon, LifeBuoyIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -18,14 +18,16 @@ interface TicketPageProps {
export default async function TicketPage({ params }: TicketPageProps) { export default async function TicketPage({ params }: TicketPageProps) {
const { id } = await params; const { id } = await params;
const headers = await nextHeaders();
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ const { user } = await payload.auth({
headers: await nextHeaders(), headers,
canSetHeaders: false, canSetHeaders: false,
}); });
if (!user) { if (!user) {
redirect("/login"); const pathname = headers.get("x-invoke-path") || `/helpdesk/${id}`;
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
} }
const ticket = await payload const ticket = await payload
@ -41,7 +43,7 @@ export default async function TicketPage({ params }: TicketPageProps) {
} }
const typedTicket = ticket as unknown as Ticket; const typedTicket = ticket as unknown as Ticket;
const isStaff = hasRoles(["admin", "developer"], user); const isStaff = await hasPermission(payload, user, "tickets:staff");
const assigneeOptions = isStaff const assigneeOptions = isStaff
? ( ? (
await payload.find({ await payload.find({

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Ticket } from "@/payload-types"; import type { Ticket } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
@ -144,7 +144,7 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
const userId = user.id as number; const userId = user.id as number;
const ticket = await getTicketOrThrow(payload, ticketId); const ticket = await getTicketOrThrow(payload, ticketId);
const isStaff = hasRoles(["admin", "developer"], user); const isStaff = await hasPermission(payload, user, "tickets:staff");
const reporterId = reporterIdOf(ticket); const reporterId = reporterIdOf(ticket);
if (!isStaff && reporterId !== userId) { if (!isStaff && reporterId !== userId) {
throw new Error("You don't have access to this ticket."); throw new Error("You don't have access to this ticket.");
@ -241,7 +241,7 @@ export async function updateTicketStatus(
const ticket = await getTicketOrThrow(payload, ticketId); const ticket = await getTicketOrThrow(payload, ticketId);
if (status === ticket.status) return { success: true }; if (status === ticket.status) return { success: true };
const isStaff = hasRoles(["admin", "developer"], user); const isStaff = await hasPermission(payload, user, "tickets:staff");
const reporterId = reporterIdOf(ticket); const reporterId = reporterIdOf(ticket);
if (!isStaff) { if (!isStaff) {
const reporterCancel = const reporterCancel =
@ -299,7 +299,7 @@ export async function assignTicket(
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const userId = user.id as number; const userId = user.id as number;
if (!hasRoles(["admin", "developer"], user)) { if (!(await hasPermission(payload, user, "tickets:staff"))) {
throw new Error("Only staff can assign tickets."); throw new Error("Only staff can assign tickets.");
} }

View file

@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Ticket } from "@/payload-types"; import type { Ticket } from "@/payload-types";
import { TicketsView } from "@/components/frontend/helpdesk/TicketsView"; import { TicketsView } from "@/components/frontend/helpdesk/TicketsView";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { LifeBuoyIcon } from "lucide-react"; import { LifeBuoyIcon } from "lucide-react";
export const metadata = { export const metadata = {
@ -12,14 +12,16 @@ export const metadata = {
}; };
export default async function HelpdeskPage() { export default async function HelpdeskPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ const { user } = await payload.auth({
headers: await nextHeaders(), headers,
canSetHeaders: false, canSetHeaders: false,
}); });
if (!user) { if (!user) {
redirect("/login"); const pathname = headers.get("x-invoke-path") || "/helpdesk";
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
} }
const ticketsRes = await payload.find({ const ticketsRes = await payload.find({
@ -29,7 +31,7 @@ export default async function HelpdeskPage() {
depth: 2, depth: 2,
}); });
const tickets = ticketsRes.docs as unknown as Ticket[]; const tickets = ticketsRes.docs as unknown as Ticket[];
const isStaff = hasRoles(["admin", "developer"], user); const isStaff = await hasPermission(payload, user, "tickets:staff");
return ( return (
<div className="flex flex-col gap-6 p-5"> <div className="flex flex-col gap-6 p-5">

View file

@ -40,7 +40,6 @@ export const metadata: Metadata = {
export default async function RootLayout(props: { children: React.ReactNode }) { export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props; const { children } = props;
const headers = await nextHeaders(); const headers = await nextHeaders();
const payloadConfig = await config; const payloadConfig = await config;

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Asset, Loadout, LockerStorage, User } from "@/payload-types"; import type { Asset, Loadout, LockerStorage, User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import {
@ -24,6 +24,7 @@ import {
skinAppliesTo, skinAppliesTo,
toLockerGridItems, toLockerGridItems,
} from "@/lib/locker"; } from "@/lib/locker";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -45,8 +46,9 @@ async function authenticate() {
return { payload, user }; return { payload, user };
} }
function isLockerManager(user: User): boolean { async function isLockerManager(payload: Awaited<ReturnType<typeof getPayload>>, user: User): Promise<boolean> {
return hasRoles(["admin", "developer"], user); if (await hasPermission(payload, user, "locker-storages:update")) return true;
return hasLogisticsQualification(payload, user);
} }
async function getLockerWithItems( async function getLockerWithItems(
@ -114,7 +116,7 @@ export async function addItemToLocker(
): Promise<ActionResult<LockerStorage>> { ): Promise<ActionResult<LockerStorage>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!isLockerManager(user)) { if (!(await hasPermission(payload, user, "locker-storages:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required to add items.", error: "Insufficient permissions. Admin or Developer role required to add items.",
@ -759,7 +761,7 @@ async function getOwnedLoadout(
? (loadout.ownerUser as { id: number }).id ? (loadout.ownerUser as { id: number }).id
: (loadout.ownerUser as number); : (loadout.ownerUser as number);
if (ownerId !== user.id && !isLockerManager(user)) return null; if (ownerId !== user.id && !(await hasPermission(payload, user, "locker-storages:update"))) return null;
return loadout; return loadout;
} }

View file

@ -5,7 +5,7 @@ import type { Asset, Loadout, LockerStorage } from "@/payload-types";
import { LockKeyholeIcon } from "lucide-react"; import { LockKeyholeIcon } from "lucide-react";
import { ensureLockerStorage, getLockerGridDimensions } from "@/lib/locker"; import { ensureLockerStorage, getLockerGridDimensions } from "@/lib/locker";
import { LockerView } from "@/components/frontend/locker/LockerView"; import { LockerView } from "@/components/frontend/locker/LockerView";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
export const metadata = { export const metadata = {
title: "Locker — Polaris Task Force", title: "Locker — Polaris Task Force",
@ -36,7 +36,7 @@ export default async function LockerPage() {
}); });
const loadouts = loadoutsRes.docs as unknown as Loadout[]; const loadouts = loadoutsRes.docs as unknown as Loadout[];
const isManager = user ? hasRoles(["admin", "developer"], user) : false; const isManager = user ? (await hasPermission(payload, user, "locker-storages:update")) : false;
let assetsCatalog: Asset[] = []; let assetsCatalog: Asset[] = [];
if (isManager) { if (isManager) {

View file

@ -4,7 +4,7 @@ import { headers as nextHeaders } from "next/headers";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { BankAccount, LedgerEntry } from "@/payload-types"; import type { BankAccount, LedgerEntry } from "@/payload-types";
import { AccountDetail } from "@/components/frontend/banking/AccountDetail"; import { AccountDetail } from "@/components/frontend/banking/AccountDetail";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
export const metadata = { export const metadata = {
@ -38,7 +38,7 @@ export default async function AccountPage({ params }: AccountPageProps) {
const typedAccount = account as unknown as BankAccount; const typedAccount = account as unknown as BankAccount;
const isManager = user const isManager = user
? hasRoles(["admin", "developer"], user) || ? (await hasPermission(payload, user, "banking:manage")) ||
(await hasLogisticsQualification(payload, user).catch(() => false)) (await hasLogisticsQualification(payload, user).catch(() => false))
: false; : false;

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { User } from "@/payload-types"; import type { User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
@ -42,7 +42,7 @@ async function isBankingManager(
payload: Awaited<ReturnType<typeof getPayload>>, payload: Awaited<ReturnType<typeof getPayload>>,
user: User, user: User,
): Promise<boolean> { ): Promise<boolean> {
if (hasRoles(["admin", "developer"], user)) return true; if (await hasPermission(payload, user, "banking:manage")) return true;
return hasLogisticsQualification(payload, user); return hasLogisticsQualification(payload, user);
} }
@ -188,7 +188,7 @@ async function moveFunds(
): Promise<ActionResult<number>> { ): Promise<ActionResult<number>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "bank-accounts:create"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }
if (!amount || amount <= 0) { if (!amount || amount <= 0) {

View file

@ -4,7 +4,7 @@ import { headers as nextHeaders } from "next/headers";
import type { BankAccount, BankTransaction, Faction, Resource } from "@/payload-types"; import type { BankAccount, BankTransaction, Faction, Resource } from "@/payload-types";
import { LandmarkIcon } from "lucide-react"; import { LandmarkIcon } from "lucide-react";
import { BankingOverview } from "@/components/frontend/banking/BankingOverview"; import { BankingOverview } from "@/components/frontend/banking/BankingOverview";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
export const metadata = { export const metadata = {
@ -45,7 +45,7 @@ export default async function BankingPage() {
})); }));
const isManager = user const isManager = user
? hasRoles(["admin", "developer"], user) || ? (await hasPermission(payload, user, "banking:manage")) ||
(await hasLogisticsQualification(payload, user).catch(() => false)) (await hasLogisticsQualification(payload, user).catch(() => false))
: false; : false;

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Asset, LockerStorage, MarketListing, MarketNegotiation, User } from "@/payload-types"; import type { Asset, LockerStorage, MarketListing, MarketNegotiation, User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import {
@ -44,8 +44,8 @@ async function authenticate() {
return { payload, user }; return { payload, user };
} }
function isMarketManager(user: User): boolean { async function isMarketManager(payload: Awaited<ReturnType<typeof getPayload>>, user: User): Promise<boolean> {
return hasRoles(["admin", "developer"], user); return await hasPermission(payload, user, "market-listings:update");
} }
function sellerIdOf(listing: MarketListing): number | null { function sellerIdOf(listing: MarketListing): number | null {
@ -467,7 +467,7 @@ export async function cancelMarketListing(listingId: number): Promise<ActionResu
return { success: false, error: "Only active listings can be cancelled." }; return { success: false, error: "Only active listings can be cancelled." };
} }
const sellerId = sellerIdOf(listing); const sellerId = sellerIdOf(listing);
if (sellerId !== userId && !isMarketManager(user)) { if (sellerId !== userId && !(await isMarketManager(payload, user))) {
return { success: false, error: "You can only cancel your own listings." }; return { success: false, error: "You can only cancel your own listings." };
} }
if (listing.isAutoGenerated) { if (listing.isAutoGenerated) {

View file

@ -4,7 +4,7 @@ import { headers as nextHeaders } from "next/headers";
import type { LockerStorage, MarketListing, MarketNegotiation } from "@/payload-types"; import type { LockerStorage, MarketListing, MarketNegotiation } from "@/payload-types";
import { StoreIcon } from "lucide-react"; import { StoreIcon } from "lucide-react";
import { MarketView } from "@/components/frontend/market/MarketView"; import { MarketView } from "@/components/frontend/market/MarketView";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { ensureLockerStorage } from "@/lib/locker"; import { ensureLockerStorage } from "@/lib/locker";
import { getMainCurrencyName } from "@/lib/banking"; import { getMainCurrencyName } from "@/lib/banking";
@ -76,7 +76,7 @@ export default async function MarketPage() {
return false; return false;
}); });
const isManager = user ? hasRoles(["admin", "developer"], user) : false; const isManager = user ? (await hasPermission(payload, user, "logistics:manage")) : false;
const currencyLabel = await getMainCurrencyName(payload); const currencyLabel = await getMainCurrencyName(payload);
return ( return (

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types"; import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { calculateDistance } from "@/lib/distance"; import { calculateDistance } from "@/lib/distance";
@ -42,7 +42,7 @@ export async function createShipment(params: {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "shipments:create"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }
@ -380,7 +380,7 @@ export async function cancelShipment(shipmentId: number): Promise<ActionResult>
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "shipments:update"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }
@ -501,7 +501,7 @@ export async function toggleAutoReturn(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "shipments:update"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { GameStructure, Resource, Structure } from "@/payload-types"; import { GameStructure, Resource, Structure } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
@ -74,10 +74,10 @@ export async function addResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for deposit.", error: "Insufficient permissions. User, Admin, or Developer role required.",
}; };
} }
@ -264,7 +264,7 @@ export async function removeResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for withdrawal.", error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
@ -373,7 +373,7 @@ export async function transferResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.", error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
@ -524,7 +524,7 @@ export async function placeResourceOnGrid(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for placement.", error: "Insufficient permissions. Admin or Developer role required for placement.",
@ -641,7 +641,7 @@ export async function moveGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
@ -730,7 +730,7 @@ export async function mergeGridStacks(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
@ -812,7 +812,7 @@ export async function rotateGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
@ -899,7 +899,7 @@ export async function removeGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for removal.", error: "Insufficient permissions. Admin or Developer role required for removal.",
@ -953,7 +953,7 @@ export async function splitGridStack(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
@ -1061,7 +1061,7 @@ export async function splitGridStack(
export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> { export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -1159,7 +1159,7 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> { export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",

View file

@ -107,7 +107,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const totalKills = infantryKills + vehicleKills + armorKills + airKills; const totalKills = infantryKills + vehicleKills + armorKills + airKills;
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—"; const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—";
const enlistDate = new Date(profile.createdAt); const enlistDate = new Date(profile.dossier.enlistmentDate);
const enlistDateStr = enlistDate.toLocaleDateString("en-US", { const enlistDateStr = enlistDate.toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",

View file

@ -10,12 +10,24 @@ import AppLogo from "@/components/graphics/AppLogo";
// (no DB is available at build time in a container). // (no DB is available at build time in a container).
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function LoginPage() { function safeReturnTo(value: string | undefined): string | undefined {
if (!value?.startsWith("/") || value.startsWith("//")) return undefined;
return value;
}
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ returnTo?: string }>;
}) {
const { returnTo } = await searchParams;
const target = safeReturnTo(returnTo);
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const headers = await nextHeaders(); const headers = await nextHeaders();
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
if (user) redirect("/"); if (user) redirect(target ?? "/");
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center p-5 gap-6 bg-background"> <div className="min-h-screen flex flex-col items-center justify-center p-5 gap-6 bg-background">
@ -27,7 +39,7 @@ export default async function LoginPage() {
Log in to access the operations dashboard, intelligence, and logistics. Log in to access the operations dashboard, intelligence, and logistics.
</p> </p>
</div> </div>
<LoginForm /> <LoginForm returnTo={target} />
</div> </div>
); );
} }

69
src/bot/AGENTS.md Normal file
View file

@ -0,0 +1,69 @@
# AGENTS.md — Discord Bot
> **Parent**: `../../AGENTS.md` — env vars (`DISCORD_TOKEN`, `DISCORD_GUILD_ID`), deployment, Payload config.
## Overview
Standalone long-running process (`bun run bot`). Imports `@payload-config` directly, shares PostgreSQL with web app. Under active development.
## Structure
```
bot/
index.ts # Entry point
config.ts # Env validation (fail-fast on missing required vars)
commands/
index.ts # Command registry (global vs guild scope)
ping.ts # /ping (global, DM-usable)
signup.ts # /signup (DM-only, creates Payload user with temp password)
link.ts # /link (global, links discordId to existing user)
announce.ts # /announce (guild-only, staff only)
events/
interactionCreate.ts # Routes ptf-att: RSVP button interactions
services/
index.ts # Service registry
missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick)
notificationBridge.ts # Poll user-notifications → Discord DMs
signup.ts # Signup service logic
lib/
roles.ts # isStaff check
resolve.ts # discordId ↔ Payload user lookups
```
## Command registration scope
- **Global** (DM-usable): `signup`, `link`, `ping`
- **Guild-only**: `announce` (guild-scoped commands never appear in DMs)
## Feature flow: signup/link
`/signup` is DM-only. Creates Payload user with `username` = `discordUsername` = caller's Discord username. Ephemeral reply carries temp password (guaranteed delivery path); `interaction.user.send()` is best-effort persistent copy. `/link` works in servers and DMs — matches `discordUsername` → sets `discordId`.
## Feature flow: attendance
Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). Web UI: `MissionAttendance` component.
## Feature flow: notifications
`notificationBridge` polls `user-notifications` (cursor = last seen id; cap 5 DMs/tick). DMs opted-in users (`preferences.discord.enabled`, not in `mutedTypes`).
## Where to look
| Task | Path |
|------|------|
| Add new slash command | `bot/commands/<name>.ts` + register in `bot/commands/index.ts` |
| Add button interaction | `bot/events/interactionCreate.ts` |
| Modify embed lifecycle | `bot/services/missionEmbeds.ts` |
| Change DM bridging | `bot/services/notificationBridge.ts` |
| User lookup patterns | `bot/lib/resolve.ts` |
## Anti-patterns
- **NEVER** run bot and web app with separate database connections without connection pooling — they share PostgreSQL
- **NEVER** register guild-only commands if the command needs to work in DMs (signup, link, ping must be global)
- **NEVER** assume DM delivery succeeded — `/signup` uses ephemeral reply as primary delivery path
- **NEVER** modify `discordId` directly in Payload admin — use the `/link` command or the resolve helper

View file

@ -7,6 +7,7 @@ import {
import type { BotCommand } from "./index"; import type { BotCommand } from "./index";
import { botConfig } from "@/bot/config"; import { botConfig } from "@/bot/config";
import { isStaff } from "@/bot/lib/roles"; import { isStaff } from "@/bot/lib/roles";
import { hasPermission } from "@/utils/access-control/hasPermission";
const ANNOUNCE_COLOR = 0xf59e0b; const ANNOUNCE_COLOR = 0xf59e0b;

View file

@ -2,6 +2,7 @@ import type { GuildMember } from "discord.js";
import type { Payload } from "payload"; import type { Payload } from "payload";
import { botConfig } from "@/bot/config"; import { botConfig } from "@/bot/config";
import { findUserByDiscordId } from "@/bot/lib/resolve"; import { findUserByDiscordId } from "@/bot/lib/resolve";
import { hasPermission } from "@/utils/access-control/hasPermission";
export const isStaff = async (member: GuildMember, payload: Payload): Promise<boolean> => { export const isStaff = async (member: GuildMember, payload: Payload): Promise<boolean> => {
if (member.roles.cache.some((role) => botConfig.staffRoleIds.includes(role.id))) { if (member.roles.cache.some((role) => botConfig.staffRoleIds.includes(role.id))) {
@ -9,5 +10,5 @@ export const isStaff = async (member: GuildMember, payload: Payload): Promise<bo
} }
const user = await findUserByDiscordId(payload, member.id); const user = await findUserByDiscordId(payload, member.id);
if (!user) return false; if (!user) return false;
return (user.roles ?? []).some((role) => role === "admin" || role === "developer"); return await hasPermission(payload, user, "discord:staff");
}; };

View file

@ -48,6 +48,16 @@ export const createDiscordUser = async (
input: SignupInput, input: SignupInput,
): Promise<{ user: User; tempPassword: string }> => { ): Promise<{ user: User; tempPassword: string }> => {
const tempPassword = generateTempPassword(); const tempPassword = generateTempPassword();
const userRole = await payload.find({
collection: "roles",
where: { slug: { equals: "user" } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const userRoleId = userRole.docs[0]?.id;
const user = await payload.create({ const user = await payload.create({
collection: "users", collection: "users",
data: { data: {
@ -57,6 +67,7 @@ export const createDiscordUser = async (
displayName: input.displayName, displayName: input.displayName,
steamId: input.steamId, steamId: input.steamId,
roles: ["user"], roles: ["user"],
roleDocs: userRoleId ? [userRoleId] : [],
password: tempPassword, password: tempPassword,
}, },
overrideAccess: true, overrideAccess: true,

81
src/collections/AGENTS.md Normal file
View file

@ -0,0 +1,81 @@
# AGENTS.md — Payload Collections
> **Parent**: `../../AGENTS.md` — commands, RBAC basics, Payload CMS config.
## Overview
36 collections across 12 domain groups. Access control defined in `src/permissions/index.ts` (616 lines, 100+ permissions across 25 groups). RBAC check: `hasPermission(payload, user, "collection:action")`.
## Structure
```
collections/
Media.ts # Generic media upload
Shims.ts # Global: CSS/JS shims (admin-only)
users/ 9 files # Users (auth), Ranks, Profiles, Awards,
# Qualifications, Assignments, Experience,
# Roles (dynamic RBAC), UserNotifications
intelligence/ 5 files # Missions, MissionAttendances, Campaigns,
# Factions, Technologies
logistics/ 5 files # Structures, Resources, Assets, Vehicles, Shipments
banking/ 3 files # BankAccounts, BankTransactions, LedgerEntries
market/ 2 files # MarketListings, MarketNegotiations
locker/ 2 files # LockerStorages, Loadouts
game/ 6 files # GameRules (global), GameStructures, GameVehicles,
# GameNpcs, GameHardResources, GameEventLogs
server/ 2 files # MissionFiles, ModLists
world/ 2 files # Maps, NarrativeEvents (React Flow editor)
tickets/ 1 file # Tickets (Lexical rich text)
```
## Key relationships
```
Users ──┬── Profiles ──┬── Qualifications
│ ├── Awards
│ ├── Assignments ── Ranks
│ └── Experience
├── UserNotifications
└── BankAccounts ── BankTransactions ── LedgerEntries
GameStructures ── Structures (template) ── Resources/Assets/Vehicles
GameVehicles ── Vehicles (template)
GameNpcs ── MarketListings ── MarketNegotiations
MissionAttendances ── Missions ── Campaigns
```
## Access control pattern
Collections define `access` at field + document level using helpers from `src/utils/access-control/`:
- `isRole(role)` — single role check
- `hasRoles(roles[])` — any-of role check
- `hasPermission(payload, user, "collection:action")` — full RBAC (cached 30s)
- `hasLogisticsQualification()` / `hasIntelligenceQualification()` — queries Profiles
Admin group access: `developer` only for destructive operations, `admin` for read/write.
## Hooks with side effects
- `Structures` `beforeChange`: emits `structure:resize`
- `GameStructures` `afterChange`: emits storage edit events
- `Users` `afterChange`: maintains profile sync
- `BankTransactions` `beforeValidate`: auto-generates `transactionNumber`
- `MarketNegotiations`: patience meter enforcement
## Where to look
| Task | Path |
|------|------|
| Add a new collection | Create `<Name>.ts` here, register in `src/payload.config.ts` |
| Add RBAC permission | `src/permissions/index.ts` (add to group + add check) |
| Modify collection access | `<collection>/access.ts` or inline in collection file |
| Add field hook | Inline in collection definition (beforeChange/afterChange) |
| Relationship graph | See key relationships above; Payload manages FK constraints |
## Anti-patterns
- **NEVER** edit `src/payload-types.ts` or `src/payload-generated-schema.ts` — run `bun run generate:types` instead
- **NEVER** add `access` functions that call `payload.auth()` without handling the null user case
- **NEVER** use `payload.create` in `afterChange` hooks without checking for infinite loops
- **NEVER** mix `overrideAccess: true` without a permission check — always gate behind RBAC first

View file

@ -1,11 +1,11 @@
import { GlobalConfig } from "payload"; import { GlobalConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Shims: GlobalConfig = { export const Shims: GlobalConfig = {
slug: "shims", slug: "shims",
access: { access: {
update: isDeveloper, update: requirePermission("shims:update"),
read: isDeveloper, read: requirePermission("shims:read"),
}, },
fields: [ fields: [
{ {

View file

@ -1,6 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import hasRoles from "@/utils/access-control/hasRoles";
export const BankAccounts: CollectionConfig = { export const BankAccounts: CollectionConfig = {
slug: "bank-accounts", slug: "bank-accounts",
@ -19,9 +18,15 @@ export const BankAccounts: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: ({ req }) => hasRoles(["admin", "developer"], req.user), create: async ({ req }) => {
update: ({ req }) => hasRoles(["admin", "developer"], req.user), return hasPermission(req.payload, req.user, "bank-accounts:create");
delete: isDeveloper, },
update: async ({ req }) => {
return hasPermission(req.payload, req.user, "bank-accounts:update");
},
delete: async ({ req }) => {
return hasPermission(req.payload, req.user, "bank-accounts:delete");
},
}, },
fields: [ fields: [
{ {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const BankTransactions: CollectionConfig = { export const BankTransactions: CollectionConfig = {
slug: "bank-transactions", slug: "bank-transactions",
@ -19,9 +19,9 @@ export const BankTransactions: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: isDeveloper, create: requirePermission("bank-transactions:create"),
update: isDeveloper, update: requirePermission("bank-transactions:update"),
delete: isDeveloper, delete: requirePermission("bank-transactions:delete"),
}, },
hooks: { hooks: {
beforeValidate: [ beforeValidate: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const LedgerEntries: CollectionConfig = { export const LedgerEntries: CollectionConfig = {
slug: "ledger-entries", slug: "ledger-entries",
@ -11,9 +11,9 @@ export const LedgerEntries: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: isDeveloper, create: requirePermission("ledger-entries:create"),
update: isDeveloper, update: requirePermission("ledger-entries:update"),
delete: isDeveloper, delete: requirePermission("ledger-entries:delete"),
}, },
fields: [ fields: [
{ {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
const TARGET_COLLECTIONS = [ const TARGET_COLLECTIONS = [
{ label: "Game Structures", value: "game-structures" }, { label: "Game Structures", value: "game-structures" },
@ -38,12 +38,13 @@ export const GameEventLogs: CollectionConfig = {
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user, create: ({ req }) => !!req.user,
update: ({ req }) => { update: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
const roles = req.user.roles as string[] | undefined; return await hasPermission(req.payload, req.user, "game-event-logs:update");
return roles?.includes("admin") || roles?.includes("developer") || false; },
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "game-event-logs:delete");
}, },
delete: isDeveloper,
}, },
fields: [ fields: [
{ {

View file

@ -1,12 +1,12 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const GameHardResources: CollectionConfig = { export const GameHardResources: CollectionConfig = {
slug: "game-hard-resources", slug: "game-hard-resources",
access: { access: {
create: isDeveloper, create: requirePermission("game-hard-resources:create"),
update: isDeveloper, update: requirePermission("game-hard-resources:update"),
delete: isDeveloper, delete: requirePermission("game-hard-resources:delete"),
}, },
admin: { admin: {
group: "Game", group: "Game",

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const GameNpcs: CollectionConfig = { export const GameNpcs: CollectionConfig = {
slug: "game-npcs", slug: "game-npcs",
@ -12,9 +12,9 @@ export const GameNpcs: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: isDeveloper, create: requirePermission("game-npcs:create"),
update: isDeveloper, update: requirePermission("game-npcs:update"),
delete: isDeveloper, delete: requirePermission("game-npcs:delete"),
}, },
fields: [ fields: [
{ {

View file

@ -1,11 +1,11 @@
import { GlobalConfig } from "payload"; import { GlobalConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const GameRules: GlobalConfig = { export const GameRules: GlobalConfig = {
slug: "game-rules", slug: "game-rules",
access: { access: {
read: isDeveloper, read: requirePermission("game-rules:read"),
update: isDeveloper, update: requirePermission("game-rules:update"),
}, },
fields: [ fields: [
{ {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
export const GameStructures: CollectionConfig = { export const GameStructures: CollectionConfig = {
@ -10,9 +10,9 @@ export const GameStructures: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: isDeveloper, create: requirePermission("game-structures:create"),
update: isDeveloper, update: requirePermission("game-structures:update"),
delete: isDeveloper, delete: requirePermission("game-structures:delete"),
}, },
hooks: { hooks: {
afterChange: [ afterChange: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const GameVehicles: CollectionConfig = { export const GameVehicles: CollectionConfig = {
slug: "game-vehicles", slug: "game-vehicles",
@ -9,9 +9,9 @@ export const GameVehicles: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: isDeveloper, create: requirePermission("game-vehicles:create"),
update: isDeveloper, update: requirePermission("game-vehicles:update"),
delete: isDeveloper, delete: requirePermission("game-vehicles:delete"),
}, },
fields: [ fields: [
{ {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Campaigns: CollectionConfig = { export const Campaigns: CollectionConfig = {
slug: "campaigns", slug: "campaigns",
@ -8,9 +8,9 @@ export const Campaigns: CollectionConfig = {
useAsTitle: "name", useAsTitle: "name",
}, },
access: { access: {
create: isDeveloper, create: requirePermission("campaigns:create"),
update: isDeveloper, update: requirePermission("campaigns:update"),
delete: isDeveloper, delete: requirePermission("campaigns:delete"),
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
}, },
fields: [ fields: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Factions: CollectionConfig = { export const Factions: CollectionConfig = {
slug: "factions", slug: "factions",
@ -8,9 +8,9 @@ export const Factions: CollectionConfig = {
useAsTitle: "name", useAsTitle: "name",
}, },
access: { access: {
create: isDeveloper, create: requirePermission("factions:create"),
update: isDeveloper, update: requirePermission("factions:update"),
delete: isDeveloper, delete: requirePermission("factions:delete"),
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
}, },
hooks: { hooks: {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import hasRoles from "@/utils/access-control/hasRoles"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const MissionAttendances: CollectionConfig = { export const MissionAttendances: CollectionConfig = {
slug: "mission-attendances", slug: "mission-attendances",
@ -8,16 +8,14 @@ export const MissionAttendances: CollectionConfig = {
defaultColumns: ["mission", "user", "response"], defaultColumns: ["mission", "user", "response"],
}, },
access: { access: {
read: ({ req }) => !!req.user, read: async ({ req }) => !!req.user,
create: ({ req, data }) => { create: async ({ req, data }) => {
if (hasRoles(["admin", "developer"], req.user)) return true;
if (!req.user || !data) return false; if (!req.user || !data) return false;
const ownerId = typeof data.user === "object" ? data.user?.id : data.user; return await hasPermission(req.payload, req.user, "mission-attendances:create");
return ownerId === req.user.id;
}, },
update: async ({ req, data, id }) => { update: async ({ req, data, id }) => {
if (hasRoles(["admin", "developer"], req.user)) return true;
if (!req.user || typeof id !== "number") return false; if (!req.user || typeof id !== "number") return false;
if (await hasPermission(req.payload, req.user, "mission-attendances:update")) return true;
const ownerId = typeof data?.user === "object" ? data.user?.id : data?.user; const ownerId = typeof data?.user === "object" ? data.user?.id : data?.user;
if (ownerId === req.user.id) return true; if (ownerId === req.user.id) return true;
@ -34,7 +32,9 @@ export const MissionAttendances: CollectionConfig = {
const docOwner = typeof doc.user === "object" ? doc.user?.id : doc.user; const docOwner = typeof doc.user === "object" ? doc.user?.id : doc.user;
return docOwner === req.user.id; return docOwner === req.user.id;
}, },
delete: ({ req }) => hasRoles(["admin", "developer"], req.user), delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "mission-attendances:delete");
},
}, },
fields: [ fields: [
{ {

View file

@ -1,6 +1,5 @@
import type { CollectionConfig, Payload } from "payload"; import type { CollectionConfig, Payload } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import hasRoles from "@/utils/access-control/hasRoles";
type AssignmentRef = { id: number } | number; type AssignmentRef = { id: number } | number;
@ -40,12 +39,18 @@ export const Missions: CollectionConfig = {
useAsTitle: "name", useAsTitle: "name",
}, },
access: { access: {
create: isDeveloper, create: async ({ req }) => {
update: isDeveloper, return await hasPermission(req.payload, req.user, "missions:create");
delete: isDeveloper, },
update: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:update");
},
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:delete");
},
read: async ({ req, data }) => { read: async ({ req, data }) => {
if (!req.user) return false; if (!req.user) return false;
if (hasRoles(["developer", "admin"], req.user)) return true; if (await hasPermission(req.payload, req.user, "missions:read")) return true;
const userId = req.user.id; const userId = req.user.id;
@ -136,17 +141,13 @@ export const Missions: CollectionConfig = {
label: "External Operation", label: "External Operation",
value: "external", value: "external",
}, },
...(isDeveloper({ req: req })
? [
{ {
label: "Main Operation", label: "Main Operation",
value: "main", value: "main",
}, },
]
: []),
]; ];
}, },
defaultValue: ({ req }) => (isDeveloper({ req: req }) ? "main" : "side"), defaultValue: ({ req }) => "side",
required: true, required: true,
}, },
{ {
@ -505,7 +506,9 @@ export const Missions: CollectionConfig = {
hidden: true, hidden: true,
defaultValue: "ptf313", defaultValue: "ptf313",
access: { access: {
read: isDeveloper, read: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:read-sensitive");
},
}, },
}, },
], ],

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isAdmin, isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Technologies: CollectionConfig = { export const Technologies: CollectionConfig = {
slug: "technologies", slug: "technologies",
@ -8,10 +8,10 @@ export const Technologies: CollectionConfig = {
useAsTitle: "name", useAsTitle: "name",
}, },
access: { access: {
create: isAdmin, create: requirePermission("technologies:create"),
update: isAdmin, update: requirePermission("technologies:update"),
delete: isAdmin, delete: requirePermission("technologies:delete"),
read: isAdmin, read: requirePermission("technologies:read"),
}, },
fields: [ fields: [
{ {
@ -31,14 +31,15 @@ export const Technologies: CollectionConfig = {
{ label: "Revision Requested", value: "revision_requested" }, { label: "Revision Requested", value: "revision_requested" },
], ],
defaultValue: "in_progress", defaultValue: "in_progress",
filterOptions: ({ options, data, req }) => { filterOptions: ({ options, req }) => {
return !isDeveloper({ req }) const roles = (req.user?.roles as string[] | undefined) ?? [];
? options.filter((option) => const canReadAll = roles.includes("admin") || roles.includes("developer");
if (canReadAll) return options;
return options.filter((option) =>
typeof option === "string" typeof option === "string"
? options ? options
: ["in_progress", "ready_for_review"].includes(option.value), : ["in_progress", "ready_for_review"].includes(option.value),
) );
: options;
}, },
required: true, required: true,
admin: { admin: {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import { LOADOUT_SLOTS } from "@/lib/locker"; import { LOADOUT_SLOTS } from "@/lib/locker";
export const Loadouts: CollectionConfig = { export const Loadouts: CollectionConfig = {
@ -10,21 +10,20 @@ export const Loadouts: CollectionConfig = {
defaultColumns: ["name", "ownerUser", "updatedAt"], defaultColumns: ["name", "ownerUser", "updatedAt"],
}, },
access: { access: {
read: ({ req }) => { read: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
if (isDeveloper({ req })) return true; if (await hasPermission(req.payload, req.user, "loadouts:read")) return true;
return { ownerUser: { equals: req.user.id } }; return { ownerUser: { equals: req.user.id } };
}, },
create: ({ req }) => !!req.user, create: ({ req }) => !!req.user,
update: ({ req }) => { update: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
if (isDeveloper({ req })) return true; if (await hasPermission(req.payload, req.user, "loadouts:update")) return true;
return { ownerUser: { equals: req.user.id } }; return { ownerUser: { equals: req.user.id } };
}, },
delete: ({ req }) => { delete: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
if (isDeveloper({ req })) return true; return await hasPermission(req.payload, req.user, "loadouts:delete");
return { ownerUser: { equals: req.user.id } };
}, },
}, },
fields: [ fields: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const LockerStorages: CollectionConfig = { export const LockerStorages: CollectionConfig = {
slug: "locker-storages", slug: "locker-storages",
@ -9,18 +9,23 @@ export const LockerStorages: CollectionConfig = {
defaultColumns: ["name", "ownerUser", "itemCount"], defaultColumns: ["name", "ownerUser", "itemCount"],
}, },
access: { access: {
read: ({ req }) => { read: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
if (isDeveloper({ req })) return true; if (await hasPermission(req.payload, req.user, "locker-storages:read")) return true;
return { ownerUser: { equals: req.user.id } }; return { ownerUser: { equals: req.user.id } };
}, },
create: ({ req }) => !!req.user && isDeveloper({ req }), create: ({ req }) => {
update: ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
if (isDeveloper({ req })) return true; return hasPermission(req.payload, req.user, "locker-storages:create");
},
update: async ({ req }) => {
if (!req.user) return false;
if (await hasPermission(req.payload, req.user, "locker-storages:update")) return true;
return { ownerUser: { equals: req.user.id } }; return { ownerUser: { equals: req.user.id } };
}, },
delete: isDeveloper, delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "locker-storages:delete");
},
}, },
fields: [ fields: [
{ {

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Assets: CollectionConfig = { export const Assets: CollectionConfig = {
slug: "assets", slug: "assets",
@ -595,9 +596,9 @@ export const Assets: CollectionConfig = {
condition: ({ user }) => isDeveloper({ req: { user: user } } as never), condition: ({ user }) => isDeveloper({ req: { user: user } } as never),
}, },
access: { access: {
read: isDeveloper, read: requirePermission("assets:read"),
create: isDeveloper, create: requirePermission("assets:create"),
update: isDeveloper, update: requirePermission("assets:update"),
}, },
fields: [ fields: [
{ {

View file

@ -1,6 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import hasRoles from "@/utils/access-control/hasRoles";
export const Shipments: CollectionConfig = { export const Shipments: CollectionConfig = {
slug: "shipments", slug: "shipments",
@ -11,17 +10,15 @@ export const Shipments: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: ({ req }) => { create: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
if (hasRoles(["developer", "admin"], req.user)) return true; return hasPermission(req.payload, req.user, "shipments:create");
return hasRoles(["user"], req.user);
}, },
update: ({ req }) => { update: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
if (hasRoles(["developer", "admin"], req.user)) return true; return hasPermission(req.payload, req.user, "shipments:update");
return hasRoles(["user"], req.user);
}, },
delete: isDeveloper, delete: requirePermission("shipments:delete"),
}, },
fields: [ fields: [
{ {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const MarketListings: CollectionConfig = { export const MarketListings: CollectionConfig = {
slug: "market-listings", slug: "market-listings",
@ -13,14 +13,12 @@ export const MarketListings: CollectionConfig = {
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user, create: ({ req }) => !!req.user,
update: ({ req }) => { update: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
if (isDeveloper({ req })) return true; if (await hasPermission(req.payload, req.user, "market-listings:update")) return true;
return { return { seller: { equals: req.user.id } };
seller: { equals: req.user.id },
};
}, },
delete: isDeveloper, delete: requirePermission("market-listings:delete"),
}, },
fields: [ fields: [
{ {

View file

@ -1,7 +1,6 @@
import { CollectionConfig, Where } from "payload"; import { CollectionConfig, Where } from "payload";
import type { User } from "@/payload-types"; import type { User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import { isDeveloper } from "@/utils/access-control/isRole";
export const MarketNegotiations: CollectionConfig = { export const MarketNegotiations: CollectionConfig = {
slug: "market-negotiations", slug: "market-negotiations",
@ -21,24 +20,24 @@ export const MarketNegotiations: CollectionConfig = {
pagination: { defaultLimit: 50 }, pagination: { defaultLimit: 50 },
}, },
access: { access: {
read: ({ req }) => { read: async ({ req }) => {
const user = req.user as User | null; const user = req.user as User | null;
if (!user) return false; if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true; if (await hasPermission(req.payload, user, "market-negotiations:read")) return true;
return { return {
or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }], or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }],
} as Where; } as Where;
}, },
create: ({ req }) => !!req.user, create: ({ req }) => !!req.user,
update: ({ req }) => { update: async ({ req }) => {
const user = req.user as User | null; const user = req.user as User | null;
if (!user) return false; if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true; if (await hasPermission(req.payload, user, "market-negotiations:update")) return true;
return { return {
or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }], or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }],
} as Where; } as Where;
}, },
delete: isDeveloper, delete: requirePermission("market-negotiations:delete"),
}, },
fields: [ fields: [
{ {

View file

@ -1,13 +1,13 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const MissionFiles: CollectionConfig = { export const MissionFiles: CollectionConfig = {
slug: "mission-files", slug: "mission-files",
access: { access: {
create: isDeveloper, create: requirePermission("mission-files:create"),
update: isDeveloper, update: requirePermission("mission-files:update"),
delete: isDeveloper, delete: requirePermission("mission-files:delete"),
read: isDeveloper, read: requirePermission("mission-files:read"),
}, },
admin: { admin: {
group: "Server", group: "Server",

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const ModLists: CollectionConfig = { export const ModLists: CollectionConfig = {
slug: "mod-lists", slug: "mod-lists",
@ -7,10 +7,10 @@ export const ModLists: CollectionConfig = {
group: "Server", group: "Server",
}, },
access: { access: {
create: isDeveloper, create: requirePermission("mod-lists:create"),
update: isDeveloper, update: requirePermission("mod-lists:update"),
delete: isDeveloper, delete: requirePermission("mod-lists:delete"),
read: isDeveloper, read: requirePermission("mod-lists:read"),
}, },
fields: [ fields: [
{ {

View file

@ -1,6 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import hasRoles from "@/utils/access-control/hasRoles";
import { import {
CLOSING_STATUSES, CLOSING_STATUSES,
STATUS_LABEL, STATUS_LABEL,
@ -49,17 +48,19 @@ export const Tickets: CollectionConfig = {
pagination: { defaultLimit: 50 }, pagination: { defaultLimit: 50 },
}, },
access: { access: {
read: ({ req }) => { read: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
if (hasRoles(["admin", "developer"], req.user)) return true; if (await hasPermission(req.payload, req.user, "tickets:read")) return true;
return { reporter: { equals: req.user.id } }; return { reporter: { equals: req.user.id } };
}, },
create: ({ req }) => !!req.user, create: ({ req }) => !!req.user,
update: ({ req }) => { update: async ({ req }) => {
if (!req.user) return false; if (!req.user) return false;
return hasRoles(["admin", "developer"], req.user) ? true : false; return await hasPermission(req.payload, req.user, "tickets:update");
},
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "tickets:delete");
}, },
delete: isDeveloper,
}, },
hooks: { hooks: {
beforeChange: [ beforeChange: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Assignments: CollectionConfig = { export const Assignments: CollectionConfig = {
slug: "assignments", slug: "assignments",
@ -8,9 +8,9 @@ export const Assignments: CollectionConfig = {
useAsTitle: "name", useAsTitle: "name",
}, },
access: { access: {
create: isDeveloper, create: requirePermission("assignments:create"),
update: isDeveloper, update: requirePermission("assignments:update"),
delete: isDeveloper, delete: requirePermission("assignments:delete"),
}, },
fields: [ fields: [
{ {

View file

@ -1,12 +1,12 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Awards: CollectionConfig = { export const Awards: CollectionConfig = {
slug: "awards", slug: "awards",
access: { access: {
create: isDeveloper, create: requirePermission("awards:create"),
update: isDeveloper, update: requirePermission("awards:update"),
delete: isDeveloper, delete: requirePermission("awards:delete"),
}, },
admin: { admin: {
useAsTitle: "name", useAsTitle: "name",

View file

@ -1,12 +1,12 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Experience: CollectionConfig = { export const Experience: CollectionConfig = {
slug: "experience", slug: "experience",
access: { access: {
create: isDeveloper, create: requirePermission("experience:create"),
update: isDeveloper, update: requirePermission("experience:update"),
delete: isDeveloper, delete: requirePermission("experience:delete"),
}, },
admin: { admin: {
group: "Users", group: "Users",

View file

@ -1,7 +1,6 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { Award } from "@/payload-types"; import { Award } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Profiles: CollectionConfig = { export const Profiles: CollectionConfig = {
slug: "profiles", slug: "profiles",
@ -11,10 +10,18 @@ export const Profiles: CollectionConfig = {
defaultColumns: ["profileTitle", "user", "rank"], defaultColumns: ["profileTitle", "user", "rank"],
}, },
access: { access: {
read: (acl) => hasRoles(["user"], acl.req.user), read: async ({ req }) => {
create: isDeveloper, return await hasPermission(req.payload, req.user, "profiles:read");
update: isDeveloper, },
delete: isDeveloper, create: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:create");
},
update: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:update");
},
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:delete");
},
}, },
fields: [ fields: [
{ {
@ -43,6 +50,12 @@ export const Profiles: CollectionConfig = {
label: "Personnel Dossier", label: "Personnel Dossier",
type: "group", type: "group",
fields: [ fields: [
{
name: "enlistmentDate",
type: "date",
required: true,
defaultValue: ({ user }) => user?.createdAt,
},
{ {
name: "personalExcerpt", name: "personalExcerpt",
type: "textarea", type: "textarea",
@ -51,6 +64,14 @@ export const Profiles: CollectionConfig = {
name: "intelExcerpt", name: "intelExcerpt",
label: "Intelligence Record", label: "Intelligence Record",
type: "richText", type: "richText",
access: {
create: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:intel_excerpt:update");
},
update: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:intel_excerpt:update");
},
},
}, },
], ],
}, },

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Qualifications: CollectionConfig = { export const Qualifications: CollectionConfig = {
slug: "qualifications", slug: "qualifications",
@ -7,9 +7,9 @@ export const Qualifications: CollectionConfig = {
group: "Users", group: "Users",
}, },
access: { access: {
create: isDeveloper, create: requirePermission("qualifications:create"),
update: isDeveloper, update: requirePermission("qualifications:update"),
delete: isDeveloper, delete: requirePermission("qualifications:delete"),
}, },
fields: [ fields: [
{ {

View file

@ -1,12 +1,12 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Ranks: CollectionConfig = { export const Ranks: CollectionConfig = {
slug: "ranks", slug: "ranks",
access: { access: {
create: isDeveloper, create: requirePermission("ranks:create"),
update: isDeveloper, update: requirePermission("ranks:update"),
delete: isDeveloper, delete: requirePermission("ranks:delete"),
}, },
admin: { admin: {
useAsTitle: "name", useAsTitle: "name",

View file

@ -0,0 +1,149 @@
import type { CollectionConfig } from "payload";
import { isAdmin, isDeveloper } from "@/utils/access-control/isRole";
import { permissionSelectOptions } from "@/permissions";
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
export const Roles: CollectionConfig = {
slug: "roles",
admin: {
group: "Users",
useAsTitle: "name",
description:
"Dynamic roles for the RBAC permission system. Assign permissions to roles, then assign roles to users.",
},
access: {
admin: isAdmin,
create: isAdmin,
update: isAdmin,
delete: isDeveloper,
},
hooks: {
beforeDelete: [
async ({ req, id }) => {
const doc = (await req.payload.findByID({
collection: "roles",
id,
overrideAccess: true,
})) as { isSystem?: boolean } | null;
if (doc?.isSystem) {
throw new Error(
"System roles cannot be deleted. Disable them by removing their permissions instead.",
);
}
},
],
afterChange: [
() => {
// Bust the entire permission cache — any user with this role may be affected.
invalidatePermissionCache();
},
],
},
fields: [
{
name: "name",
type: "text",
required: true,
unique: true,
admin: {
description: "Display name for this role, e.g. 'Logistics Officer'.",
},
},
{
name: "slug",
type: "text",
required: true,
unique: true,
index: true,
admin: {
description:
"Machine-readable key. Built-in roles: 'guest', 'user', 'admin', 'developer'. Used by seed scripts and the Discord bot to look up roles by key.",
readOnly: true,
},
hooks: {
beforeValidate: [
({ data, originalDoc }) => {
// Auto-generate from name if not explicitly set (or name changed).
const name = data?.name as string | undefined;
const existing = (originalDoc as { slug?: string })?.slug;
if (data?.slug && data.slug === existing) {
// Keep explicit slug unless name changed and slug was auto-generated.
return data.slug;
}
if (data?.slug) {
return data.slug as string;
}
if (!name) return existing ?? "";
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
},
],
},
},
{
name: "description",
type: "textarea",
admin: {
description: "Optional notes about what this role is for.",
},
},
{
name: "permissions",
type: "select",
hasMany: true,
options: permissionSelectOptions,
admin: {
description:
"Permissions granted by this role. The full list is defined in src/permissions/index.ts.",
isSortable: false,
},
},
{
name: "parentRoles",
label: "Inherits from...",
type: "relationship",
relationTo: "roles",
hasMany: true,
access: {
create: isDeveloper,
update: isDeveloper,
},
filterOptions: ({ id }) => {
if (!id) return true;
return { id: { not_equals: id } };
},
admin: {
description:
"Parent roles to inherit permissions and settings from. Inheriting from a superuser role makes this role a superuser. Restricted to developers.",
position: "sidebar",
},
},
{
name: "isSystem",
type: "checkbox",
defaultValue: false,
admin: {
description:
"System roles are built-in and cannot be deleted. They can still be edited (e.g. to change their permissions).",
position: "sidebar",
readOnly: true,
},
},
{
name: "isSuperuser",
type: "checkbox",
defaultValue: false,
access: {
create: isDeveloper,
update: isDeveloper,
},
admin: {
description:
"Superuser roles bypass ALL permission checks. Use with extreme caution — this grants unrestricted access.",
position: "sidebar",
},
},
],
};

View file

@ -1,7 +1,6 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import type { User } from "@/payload-types"; import type { User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import { isDeveloper } from "@/utils/access-control/isRole";
export const UserNotifications: CollectionConfig = { export const UserNotifications: CollectionConfig = {
slug: "user-notifications", slug: "user-notifications",
@ -13,20 +12,20 @@ export const UserNotifications: CollectionConfig = {
pagination: { defaultLimit: 25 }, pagination: { defaultLimit: 25 },
}, },
access: { access: {
read: ({ req }) => { read: async ({ req }) => {
const user = req.user as User | null; const user = req.user as User | null;
if (!user) return false; if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true; if (await hasPermission(req.payload, user, "user-notifications:read")) return true;
return { user: { equals: user.id } }; return { user: { equals: user.id } };
}, },
create: isDeveloper, create: requirePermission("user-notifications:create"),
update: ({ req }) => { update: async ({ req }) => {
const user = req.user as User | null; const user = req.user as User | null;
if (!user) return false; if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true; if (await hasPermission(req.payload, user, "user-notifications:update")) return true;
return { user: { equals: user.id } }; return { user: { equals: user.id } };
}, },
delete: isDeveloper, delete: requirePermission("user-notifications:delete"),
}, },
fields: [ fields: [
{ {

View file

@ -1,5 +1,5 @@
import type { CollectionConfig } from "payload"; import type { CollectionConfig } from "payload";
import { isAdmin, isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission, hasPermission, isSuperuser } from "@/utils/access-control/hasPermission";
import { ensurePersonalAccount } from "@/lib/banking/index"; import { ensurePersonalAccount } from "@/lib/banking/index";
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes"; import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
@ -20,6 +20,9 @@ export const Users: CollectionConfig = {
); );
} }
let recruit: { id: number } | undefined;
try {
const ranks = await payload.find({ const ranks = await payload.find({
collection: "ranks", collection: "ranks",
where: { where: {
@ -31,14 +34,31 @@ export const Users: CollectionConfig = {
depth: 0, depth: 0,
overrideAccess: true, overrideAccess: true,
}); });
const recruit = (ranks as { docs: Array<{ id: number }> }).docs[0]; recruit = (ranks as { docs: Array<{ id: number }> }).docs[0];
if (!recruit) {
const created = await payload.create({
collection: "ranks",
data: {
name: "Recruit",
abbreviation: "Rct",
description: "Entry-level rank for new members.",
},
overrideAccess: true,
});
recruit = { id: created.id };
payload.logger.info("[Users] Created default 'Recruit' rank on demand.");
}
} catch (e) {
payload.logger.error(`[Users] Failed to resolve Recruit rank: ${e}`);
}
try { try {
await payload.create({ await payload.create({
collection: "profiles", collection: "profiles",
data: { data: {
user: userId, user: userId,
rank: recruit?.id ?? null, rank: recruit?.id ?? (null as unknown as number),
progression: { progression: {
qualifications: [], qualifications: [],
experience: 0, experience: 0,
@ -57,22 +77,42 @@ export const Users: CollectionConfig = {
}, },
slug: "users", slug: "users",
access: { access: {
admin: isAdmin, admin: requirePermission("system:admin-access"),
unlock: isDeveloper, unlock: requirePermission("users:unlock"),
create: isDeveloper, create: requirePermission("users:create"),
update: ({ req, data }) => { update: async ({ req, id, data }) => {
const isEditingDeveloper = data?.roles?.includes("developer"); if (data?.roles?.includes("developer")) {
if (isEditingDeveloper) { return isSuperuser(req.payload, req.user);
return isDeveloper({ req });
} }
return isAdmin({ req }); const existing = id
? ((await req.payload.findByID({
collection: "users",
id,
depth: 2,
overrideAccess: true,
})) as { roleDocs?: Array<{ isSuperuser?: boolean }> | null })
: null;
if (existing?.roleDocs?.some((r) => r.isSuperuser)) {
return isSuperuser(req.payload, req.user);
}
return hasPermission(req.payload, req.user, "users:update");
}, },
delete: ({ req, data }) => { delete: async ({ req, id, data }) => {
const isEditingDeveloper = data?.roles?.includes("developer"); if (data?.roles?.includes("developer")) {
if (isEditingDeveloper) { return isSuperuser(req.payload, req.user);
return isDeveloper({ req });
} }
return isAdmin({ req }); const existing = id
? ((await req.payload.findByID({
collection: "users",
id,
depth: 2,
overrideAccess: true,
})) as { roleDocs?: Array<{ isSuperuser?: boolean }> | null })
: null;
if (existing?.roleDocs?.some((r) => r.isSuperuser)) {
return isSuperuser(req.payload, req.user);
}
return hasPermission(req.payload, req.user, "users:delete");
}, },
}, },
admin: { admin: {
@ -140,8 +180,8 @@ export const Users: CollectionConfig = {
name: "roles", name: "roles",
type: "select", type: "select",
access: { access: {
create: isDeveloper, create: requirePermission("users:assign-roles"),
update: isDeveloper, update: requirePermission("users:assign-roles"),
}, },
hasMany: true, hasMany: true,
options: [ options: [
@ -173,6 +213,22 @@ export const Users: CollectionConfig = {
) )
: options; : options;
},*/ },*/
admin: {
description: "Legacy role enum — being replaced by the dynamic RBAC system (see 'Role Assignments' below).",
},
},
{
name: "roleDocs",
type: "relationship",
relationTo: "roles",
hasMany: true,
access: {
create: requirePermission("users:assign-roles"),
update: requirePermission("users:assign-roles"),
},
admin: {
description: "Dynamic RBAC role assignments. These determine the user's permissions via the roles collection.",
},
}, },
{ {
name: "preferences", name: "preferences",

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const Maps: CollectionConfig = { export const Maps: CollectionConfig = {
slug: "maps", slug: "maps",
@ -8,10 +8,10 @@ export const Maps: CollectionConfig = {
useAsTitle: "name", useAsTitle: "name",
}, },
access: { access: {
create: isDeveloper, create: requirePermission("maps:create"),
update: isDeveloper, update: requirePermission("maps:update"),
delete: isDeveloper, delete: requirePermission("maps:delete"),
read: isDeveloper, read: requirePermission("maps:read"),
}, },
fields: [ fields: [
{ {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { requirePermission } from "@/utils/access-control/hasPermission";
export const NarrativeEvents: CollectionConfig = { export const NarrativeEvents: CollectionConfig = {
slug: "narrative-events", slug: "narrative-events",
@ -8,10 +8,10 @@ export const NarrativeEvents: CollectionConfig = {
useAsTitle: "name", useAsTitle: "name",
}, },
access: { access: {
create: isDeveloper, create: requirePermission("narrative-events:create"),
update: isDeveloper, update: requirePermission("narrative-events:update"),
delete: isDeveloper, delete: requirePermission("narrative-events:delete"),
read: isDeveloper, read: requirePermission("narrative-events:read"),
}, },
fields: [ fields: [
{ {

View file

@ -0,0 +1,65 @@
# AGENTS.md — Frontend Components
> **Parent**: `../../AGENTS.md` — commands, auth flow, Next.js 16 rules, deployment.
## Overview
100+ client components organized by domain. The `(frontend)` layout renders guests `LandingPage` (no app shell); authed users get `AppSidebar` + `SiteHeader` + `CommandPalette`.
## Structure
```
components/frontend/
auth/ 2 files LoginForm, LoginLink (returnTo via usePathname)
account/ 4 files Profile, password, preferences, Discord link
banking/ 9 files Account cards, ledger, deposit/withdraw/transfer dialogs
blocks/ 6 files AppSidebar, NavCore, NavUser, XPDisplay
dashboard/ 6 files ProfileSummary, QuickStats, MissionBriefing, RecentEvents
flappy/ 4 files Canvas game, leaderboard, sounds
helpdesk/ 6 files Ticket list/detail, create dialog, timeline
intelligence/ 13 files Mission cards/attendance/comms, campaign/faction cards
locker/ 12 files Grid, equipment editor, loadouts, wardrobe
logistics/ 8 files Shipment cards/actions, toast notifications
market/ 10 files Listing cards, negotiation flow, NPC chat, patience meter
notifications/ 2 files Bell (polling), inbox page
realtime/ 1 file GameTickRealtime (SSE -> router.refresh)
roster/ 1 file Org chart view
storage/ 14 files Structure grid, storage dialogs, event ledger
```
## Conventions
### Server → Client data flow
Pages are server components that fetch via `getPayload()`, then pass data as props to client components. Client components receive typed props and never call Payload directly.
### Item compound component
`storage/` uses a compound `<Item>` component for grid cells with slots: `<Item.Slot name="icon">`, `<Item.Slot name="stats">`, `<Item.Menu>`. Attachments render as stacked badges on the grid cell; popover shows full detail on hover.
### SSE consumers
- `GameTickRealtime` mounts in `(frontend)/layout.tsx` for all authed users.
- `ShipmentToasts` mounts only for logistics-qualified users.
- Both use `useGameTick()` hook (custom `ptf:game-tick` event dispatch).
### Nested dialogs
Market negotiation uses `MakeOfferDialog` nested inside `ListingCard` dialog. Loadout editor uses `EquipmentEditorDialog` nested inside locker grid. When a parent dialog unmounts (e.g., sold listing), the child stays visible for 3.5s minimum via `useNow()` hook for readability before refresh.
### Route anomalies
`logistics/vehicles/VehiclesList.tsx` is a client component placed directly in the route directory (not under `components/frontend/`). This is the only route with an inline component file.
## Where to look
| Task | Path |
|------|------|
| Add a new page | `src/app/(frontend)/<domain>/page.tsx` + create client component here |
| Add nav entry | `src/components/frontend/blocks/NavCore.tsx` |
| Modify auth gate | `src/app/(frontend)/layout.tsx` (conditional renders `LandingPage` or shell) |
| Add SSE consumer | `src/hooks/useGameTick.ts` + mount in layout |
| NPC dialogue/chatter | `src/lib/market/npcDialogue.ts` (pure, client-safe import) |
| Keyboard shortcuts | `src/components/command-palette/` |
## Anti-patterns
- **NEVER** call `getPayload()` in client components — fetch in server page, pass as props
- **NEVER** add `useRouter().refresh()` in SSE consumers without a guard — use the `useGameTick()` hook
- **NEVER** use shadcn defaults for dark theme — the admin panel's theme handles styling
- **NEVER** place server-side logic (hooks, Payload calls) in `"use client"` files

View file

@ -1,6 +1,6 @@
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import AppLogo from "@/components/graphics/AppLogo"; import AppLogo from "@/components/graphics/AppLogo";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
export function LandingPage() { export function LandingPage() {
return ( return (
@ -14,7 +14,7 @@ export function LandingPage() {
operations dashboard, intelligence, and logistics. operations dashboard, intelligence, and logistics.
</p> </p>
<Button asChild className="mt-2"> <Button asChild className="mt-2">
<Link href="/login">Log in to access the portal</Link> <LoginLink>Log in to access the portal</LoginLink>
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -7,7 +7,11 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
export function LoginForm() { interface LoginFormProps {
returnTo?: string;
}
export function LoginForm({ returnTo }: LoginFormProps) {
const router = useRouter(); const router = useRouter();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@ -33,7 +37,12 @@ export function LoginForm() {
return; return;
} }
// Redirect to returnTo if provided, otherwise to home
if (returnTo && returnTo !== "/") {
router.push(returnTo);
} else {
router.push("/"); router.push("/");
}
router.refresh(); router.refresh();
} catch { } catch {
setError("Something went wrong. Please try again."); setError("Something went wrong. Please try again.");

View file

@ -0,0 +1,12 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ComponentProps } from "react";
type LoginLinkProps = Omit<ComponentProps<typeof Link>, "href">;
export function LoginLink(props: LoginLinkProps) {
const pathname = usePathname();
return <Link {...props} href={`/login?returnTo=${encodeURIComponent(pathname)}`} />;
}

85
src/lib/AGENTS.md Normal file
View file

@ -0,0 +1,85 @@
# AGENTS.md — Shared Business Logic
> **Parent**: `../../AGENTS.md` — Payload config, server actions pattern, env vars.
## Overview
23 files across 8 domain subdirectories. Contains pure business logic and Payload-dependent service modules. Server actions in route directories delegate here; these modules hold the actual domain rules.
## Structure
```
lib/
utils.ts # cn() class merger (Tailwind)
storageRules.ts # Storage validation (prohibited → whitelist → per-item cap)
shipping.ts # Fuel cost, transit time, vehicle effective speed
distance.ts # Haversine distance calculation
logistics.ts # Shared logistics helpers
versionInfo.ts # Build version display
attendance/ # Mission attendance service (single write path)
banking/ # Transaction engine, account creation, formatting
locker/ # Grid logic, placement validation, loadouts
market/ # Buy/sell, NPC negotiation, dialogue, vendor resolution
notifications/ # User notification helper + muteable types
realtime/ # In-process SSE bus (single-instance only)
tickets/ # Ticket vocabulary, Lexical helpers, staff resolution
```
## Dependency graph
```
Server actions ──┬── storageRules.ts
├── lib/banking/index.ts ──┬── format.ts
│ └── ui.ts
├── lib/market/index.ts ──┬── negotiations.ts
│ ├── npcs.ts
│ ├── npcDialogue.ts (pure, client-safe)
│ └── chatBubbles.ts (pure, client-safe)
├── lib/locker/index.ts ── search.ts
├── lib/attendance/index.ts
├── lib/notifications/index.ts
└── lib/tickets/
game-tick script ── shipping.ts, distance.ts, storageRules.ts
market-tick script ── lib/market/index.ts (autoPrice, autoQuantity, npc vendor logic)
```
## Pure vs Payload-dependent
- **Pure modules** (no Payload import, safe for client + server): `npcDialogue.ts`, `chatBubbles.ts`, `ticketMeta.ts`, `distance.ts`, `storageRules.ts` (logic only)
- **Payload-dependent** (import `@payload-config`, server-only): everything else
## Key modules
### `storageRules.ts`
Enforcement order: **prohibited → whitelist → per-item cap**. Functions: `checkStorageDeposit`, `isResourceProhibited`, `isResourceWhitelisted`, `getResourceStorageCap`, `storageViolationMessage`. Used in structure actions, shipment creation, and arrival processing.
### `banking/index.ts`
`applyTransaction()` — single source of truth for balance math. Validates accounts, creates transaction + ledger entries, updates balances. `ensurePersonalAccount()` — dedup find-or-create. `getMainCurrencyId()` — resolves Game Rules main currency.
### `market/index.ts`
`buyListing()` — validates, debits buyer, credits locker, marks sold. Supports partial buys via `quantity` parameter. `creditLockerQuantity()` — merges stackables or fills empty grid spots; throws if no space.
### `market/negotiations.ts`
NPC vendor pricing engine: stance starts at asking price, only moves down. Offers ≥ stance accepted; within 2% with 85% chance; well-below draw concession (30% of gap). Patience meter increments per round, closes at cap.
### `realtime/bus.ts`
Module-level subscriber Set. SSE endpoint `GET /api/realtime` subscribes; game tick POST `/api/game-tick/notify` broadcasts. **Single-instance only** — will not work across multiple server processes.
## Where to look
| Task | Path |
|------|------|
| Add storage rule logic | `storageRules.ts` (pure functions) |
| Modify transaction flow | `lib/banking/index.ts` — `applyTransaction()` |
| Change NPC pricing | `lib/market/negotiations.ts` — NPC_ACCEPT constants |
| Add notification type | `lib/notifications/notificationTypes.ts` |
| Add pure client+server logic | Verify no Payload import; place in appropriate domain dir |
## Anti-patterns
- **NEVER** import `@payload-config` in files under `market/npcDialogue.ts` or `market/chatBubbles.ts` — they must stay client-safe
- **NEVER** duplicate business logic in server actions — always delegate to `lib/{domain}/`
- **NEVER** use module-level state in `realtime/bus.ts` for cross-instance scenarios — use external pub/sub (Redis) instead
- **NEVER** mutate `storageRules.ts` enforcement order without updating all 3 call sites (structure actions, shipment creation, arrival processing)

View file

@ -8,11 +8,19 @@ type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export async function getStaffUserIds(payload: PayloadType): Promise<number[]> { export async function getStaffUserIds(payload: PayloadType): Promise<number[]> {
try { try {
const adminRole = await payload.find({
collection: "roles",
where: { slug: { in: ["admin", "developer"] } },
limit: 2,
depth: 0,
overrideAccess: true,
});
const roleIds = adminRole.docs.map((d) => d.id);
if (roleIds.length === 0) return [];
const res = await payload.find({ const res = await payload.find({
collection: "users", collection: "users",
where: { where: { roleDocs: { in: roleIds } },
or: [{ roles: { contains: "admin" } }, { roles: { contains: "developer" } }],
},
limit: 50, limit: 50,
depth: 0, depth: 0,
select: { username: true }, select: { username: true },

View file

@ -74,6 +74,7 @@ export interface Config {
'game-npcs': GameNpc; 'game-npcs': GameNpc;
'game-event-logs': GameEventLog; 'game-event-logs': GameEventLog;
users: User; users: User;
roles: Role;
ranks: Rank; ranks: Rank;
profiles: Profile; profiles: Profile;
awards: Award; awards: Award;
@ -125,6 +126,7 @@ export interface Config {
'game-npcs': GameNpcsSelect<false> | GameNpcsSelect<true>; 'game-npcs': GameNpcsSelect<false> | GameNpcsSelect<true>;
'game-event-logs': GameEventLogsSelect<false> | GameEventLogsSelect<true>; 'game-event-logs': GameEventLogsSelect<false> | GameEventLogsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
roles: RolesSelect<false> | RolesSelect<true>;
ranks: RanksSelect<false> | RanksSelect<true>; ranks: RanksSelect<false> | RanksSelect<true>;
profiles: ProfilesSelect<false> | ProfilesSelect<true>; profiles: ProfilesSelect<false> | ProfilesSelect<true>;
awards: AwardsSelect<false> | AwardsSelect<true>; awards: AwardsSelect<false> | AwardsSelect<true>;
@ -1260,7 +1262,14 @@ export interface User {
displayName: string; displayName: string;
payloadDisplayName?: string | null; payloadDisplayName?: string | null;
steamId: string; steamId: string;
/**
* Legacy role enum — being replaced by the dynamic RBAC system (see 'Role Assignments' below).
*/
roles?: ('guest' | 'user' | 'trusted' | 'admin' | 'developer')[] | null; roles?: ('guest' | 'user' | 'trusted' | 'admin' | 'developer')[] | null;
/**
* Dynamic RBAC role assignments. These determine the user's permissions via the roles collection.
*/
roleDocs?: (number | Role)[] | null;
preferences?: { preferences?: {
notifications?: { notifications?: {
mutedAll?: boolean | null; mutedAll?: boolean | null;
@ -1335,6 +1344,203 @@ export interface User {
password?: string | null; password?: string | null;
collection: 'users'; collection: 'users';
} }
/**
* Dynamic roles for the RBAC permission system. Assign permissions to roles, then assign roles to users.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "roles".
*/
export interface Role {
id: number;
/**
* Display name for this role, e.g. 'Logistics Officer'.
*/
name: string;
/**
* Machine-readable key. Built-in roles: 'guest', 'user', 'admin', 'developer'. Used by seed scripts and the Discord bot to look up roles by key.
*/
slug: string;
/**
* Optional notes about what this role is for.
*/
description?: string | null;
/**
* Permissions granted by this role. The full list is defined in src/permissions/index.ts.
*/
permissions?:
| (
| 'system:admin-access'
| 'users:create'
| 'users:read'
| 'users:update'
| 'users:delete'
| 'users:unlock'
| 'users:assign-roles'
| 'ranks:create'
| 'ranks:read'
| 'ranks:update'
| 'ranks:delete'
| 'profiles:create'
| 'profiles:read'
| 'profiles:update'
| 'profiles:delete'
| 'awards:create'
| 'awards:read'
| 'awards:update'
| 'awards:delete'
| 'qualifications:create'
| 'qualifications:read'
| 'qualifications:update'
| 'qualifications:delete'
| 'assignments:create'
| 'assignments:read'
| 'assignments:update'
| 'assignments:delete'
| 'experience:create'
| 'experience:read'
| 'experience:update'
| 'experience:delete'
| 'user-notifications:create'
| 'user-notifications:read'
| 'user-notifications:update'
| 'user-notifications:delete'
| 'missions:create'
| 'missions:read'
| 'missions:update'
| 'missions:delete'
| 'missions:read-sensitive'
| 'mission-attendances:create'
| 'mission-attendances:read'
| 'mission-attendances:update'
| 'mission-attendances:delete'
| 'campaigns:create'
| 'campaigns:read'
| 'campaigns:update'
| 'campaigns:delete'
| 'factions:create'
| 'factions:read'
| 'factions:update'
| 'factions:delete'
| 'technologies:create'
| 'technologies:read'
| 'technologies:update'
| 'technologies:delete'
| 'assets:create'
| 'assets:read'
| 'assets:update'
| 'assets:delete'
| 'resources:create'
| 'resources:read'
| 'resources:update'
| 'resources:delete'
| 'vehicles:create'
| 'vehicles:read'
| 'vehicles:update'
| 'vehicles:delete'
| 'structures:create'
| 'structures:read'
| 'structures:update'
| 'structures:delete'
| 'shipments:create'
| 'shipments:read'
| 'shipments:update'
| 'shipments:delete'
| 'bank-accounts:create'
| 'bank-accounts:read'
| 'bank-accounts:update'
| 'bank-accounts:delete'
| 'bank-transactions:create'
| 'bank-transactions:read'
| 'bank-transactions:update'
| 'bank-transactions:delete'
| 'ledger-entries:create'
| 'ledger-entries:read'
| 'ledger-entries:update'
| 'ledger-entries:delete'
| 'locker-storages:create'
| 'locker-storages:read'
| 'locker-storages:update'
| 'locker-storages:delete'
| 'loadouts:create'
| 'loadouts:read'
| 'loadouts:update'
| 'loadouts:delete'
| 'market-listings:create'
| 'market-listings:read'
| 'market-listings:update'
| 'market-listings:delete'
| 'market-negotiations:create'
| 'market-negotiations:read'
| 'market-negotiations:update'
| 'market-negotiations:delete'
| 'tickets:create'
| 'tickets:read'
| 'tickets:update'
| 'tickets:delete'
| 'game-structures:create'
| 'game-structures:read'
| 'game-structures:update'
| 'game-structures:delete'
| 'game-vehicles:create'
| 'game-vehicles:read'
| 'game-vehicles:update'
| 'game-vehicles:delete'
| 'game-npcs:create'
| 'game-npcs:read'
| 'game-npcs:update'
| 'game-npcs:delete'
| 'game-hard-resources:create'
| 'game-hard-resources:read'
| 'game-hard-resources:update'
| 'game-hard-resources:delete'
| 'game-event-logs:create'
| 'game-event-logs:read'
| 'game-event-logs:update'
| 'game-event-logs:delete'
| 'maps:create'
| 'maps:read'
| 'maps:update'
| 'maps:delete'
| 'narrative-events:create'
| 'narrative-events:read'
| 'narrative-events:update'
| 'narrative-events:delete'
| 'mission-files:create'
| 'mission-files:read'
| 'mission-files:update'
| 'mission-files:delete'
| 'mod-lists:create'
| 'mod-lists:read'
| 'mod-lists:update'
| 'mod-lists:delete'
| 'game-rules:read'
| 'game-rules:update'
| 'shims:read'
| 'shims:update'
| 'logistics:manage'
| 'intelligence:manage'
| 'profiles:intel_excerpt:update'
| 'banking:manage'
| 'tickets:staff'
| 'discord:staff'
| 'discord:announce'
)[]
| null;
/**
* Parent roles to inherit permissions and settings from. Inheriting from a superuser role makes this role a superuser. Restricted to developers.
*/
parentRoles?: (number | Role)[] | null;
/**
* System roles are built-in and cannot be deleted. They can still be edited (e.g. to change their permissions).
*/
isSystem?: boolean | null;
/**
* Superuser roles bypass ALL permission checks. Use with extreme caution — this grants unrestricted access.
*/
isSuperuser?: boolean | null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ranks". * via the `definition` "ranks".
@ -1358,7 +1564,8 @@ export interface Profile {
user: number | User; user: number | User;
rank: number | Rank; rank: number | Rank;
profileTitle?: string | null; profileTitle?: string | null;
dossier?: { dossier: {
enlistmentDate: string;
personalExcerpt?: string | null; personalExcerpt?: string | null;
intelExcerpt?: { intelExcerpt?: {
root: { root: {
@ -2438,6 +2645,10 @@ export interface PayloadLockedDocument {
relationTo: 'users'; relationTo: 'users';
value: number | User; value: number | User;
} | null) } | null)
| ({
relationTo: 'roles';
value: number | Role;
} | null)
| ({ | ({
relationTo: 'ranks'; relationTo: 'ranks';
value: number | Rank; value: number | Rank;
@ -2746,6 +2957,7 @@ export interface UsersSelect<T extends boolean = true> {
payloadDisplayName?: T; payloadDisplayName?: T;
steamId?: T; steamId?: T;
roles?: T; roles?: T;
roleDocs?: T;
preferences?: preferences?:
| T | T
| { | {
@ -2786,6 +2998,21 @@ export interface UsersSelect<T extends boolean = true> {
expiresAt?: T; expiresAt?: T;
}; };
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "roles_select".
*/
export interface RolesSelect<T extends boolean = true> {
name?: T;
slug?: T;
description?: T;
permissions?: T;
parentRoles?: T;
isSystem?: T;
isSuperuser?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ranks_select". * via the `definition` "ranks_select".
@ -2810,6 +3037,7 @@ export interface ProfilesSelect<T extends boolean = true> {
dossier?: dossier?:
| T | T
| { | {
enlistmentDate?: T;
personalExcerpt?: T; personalExcerpt?: T;
intelExcerpt?: T; intelExcerpt?: T;
}; };

View file

@ -7,6 +7,7 @@ import { fileURLToPath } from "url";
import sharp from "sharp"; import sharp from "sharp";
import { Users } from "@/collections/users/Users"; import { Users } from "@/collections/users/Users";
import { Roles } from "@/collections/users/Roles";
import { Awards } from "@/collections/users/Awards"; import { Awards } from "@/collections/users/Awards";
import { Media } from "@/collections/Media"; import { Media } from "@/collections/Media";
import { Ranks } from "@/collections/users/Ranks"; import { Ranks } from "@/collections/users/Ranks";
@ -190,6 +191,7 @@ export default buildConfig({
// Users // Users
Users, Users,
Roles,
Ranks, Ranks,
Profiles, Profiles,
Awards, Awards,
@ -280,6 +282,7 @@ export default buildConfig({
// Users // Users
[Users.slug]: true, [Users.slug]: true,
[Roles.slug]: true,
[Ranks.slug]: true, [Ranks.slug]: true,
[Profiles.slug]: true, [Profiles.slug]: true,
[Awards.slug]: true, [Awards.slug]: true,

616
src/permissions/index.ts Normal file
View file

@ -0,0 +1,616 @@
/**
* Permission universe — the single source of truth for every permission
* recognized by the dynamic RBAC system.
*
* Permissions are hardcoded here in application code. The Payload admin panel
* reads this list when rendering the permission selector on the `roles`
* collection. Roles are dynamic (created/edited in the admin panel), but the
* set of assignable permissions is NOT — it can only grow by adding entries
* here.
*
* Format: `{collection-slug}:{create|read|update|delete}` for collection CRUD,
* plus domain-specific special permissions for feature areas that don't map
* cleanly to CRUD (e.g. `logistics:manage`, `discord:announce`).
*/
export interface PermissionOption {
/** Machine-readable key, e.g. `"users:create"`. */
value: string;
/** Human-readable label, e.g. `"Create"`. */
label: string | string[];
}
export interface PermissionGroup {
/** Group label shown as a header in the admin UI, e.g. `"Users"`. */
group: string;
/** Permissions within this group. */
permissions: PermissionOption[];
}
// ---------------------------------------------------------------------------
// Permission groups
// ---------------------------------------------------------------------------
export const PERMISSION_GROUPS: PermissionGroup[] = [
{
group: "System",
permissions: [{ value: "system:admin-access", label: "Access Admin Panel" }],
},
// ---- Users ---------------------------------------------------------------
{
group: "Users",
permissions: [
{ value: "users:create", label: "Create Users" },
{ value: "users:read", label: "Read Users" },
{ value: "users:update", label: "Update Users" },
{ value: "users:delete", label: "Delete Users" },
{ value: "users:unlock", label: "Unlock User Accounts" },
{ value: "users:assign-roles", label: "Assign Roles to Users" },
],
},
{
group: "Ranks",
permissions: [
{ value: "ranks:create", label: "Create" },
{ value: "ranks:read", label: "Read" },
{ value: "ranks:update", label: "Update" },
{ value: "ranks:delete", label: "Delete" },
],
},
{
group: "Profiles",
permissions: [
{ value: "profiles:create", label: "Create" },
{ value: "profiles:read", label: "Read" },
{ value: "profiles:update", label: "Update" },
{ value: "profiles:delete", label: "Delete" },
],
},
{
group: "Awards",
permissions: [
{ value: "awards:create", label: "Create" },
{ value: "awards:read", label: "Read" },
{ value: "awards:update", label: "Update" },
{ value: "awards:delete", label: "Delete" },
],
},
{
group: "Qualifications",
permissions: [
{ value: "qualifications:create", label: "Create" },
{ value: "qualifications:read", label: "Read" },
{ value: "qualifications:update", label: "Update" },
{ value: "qualifications:delete", label: "Delete" },
],
},
{
group: "Assignments",
permissions: [
{ value: "assignments:create", label: "Create" },
{ value: "assignments:read", label: "Read" },
{ value: "assignments:update", label: "Update" },
{ value: "assignments:delete", label: "Delete" },
],
},
{
group: "Experience",
permissions: [
{ value: "experience:create", label: "Create" },
{ value: "experience:read", label: "Read" },
{ value: "experience:update", label: "Update" },
{ value: "experience:delete", label: "Delete" },
],
},
{
group: "User Notifications",
permissions: [
{ value: "user-notifications:create", label: "Create" },
{ value: "user-notifications:read", label: "Read" },
{ value: "user-notifications:update", label: "Update" },
{ value: "user-notifications:delete", label: "Delete" },
],
},
// ---- Intelligence --------------------------------------------------------
{
group: "Missions",
permissions: [
{ value: "missions:create", label: "Create" },
{ value: "missions:read", label: "Read" },
{ value: "missions:update", label: "Update" },
{ value: "missions:delete", label: "Delete" },
{ value: "missions:read-sensitive", label: "Read Sensitive Fields (server passwords)" },
],
},
{
group: "Mission Attendances",
permissions: [
{ value: "mission-attendances:create", label: "Create" },
{ value: "mission-attendances:read", label: "Read" },
{ value: "mission-attendances:update", label: "Update" },
{ value: "mission-attendances:delete", label: "Delete" },
],
},
{
group: "Campaigns",
permissions: [
{ value: "campaigns:create", label: "Create" },
{ value: "campaigns:read", label: "Read" },
{ value: "campaigns:update", label: "Update" },
{ value: "campaigns:delete", label: "Delete" },
],
},
{
group: "Factions",
permissions: [
{ value: "factions:create", label: "Create" },
{ value: "factions:read", label: "Read" },
{ value: "factions:update", label: "Update" },
{ value: "factions:delete", label: "Delete" },
],
},
{
group: "Technologies",
permissions: [
{ value: "technologies:create", label: "Create" },
{ value: "technologies:read", label: "Read" },
{ value: "technologies:update", label: "Update" },
{ value: "technologies:delete", label: "Delete" },
],
},
// ---- Logistics -----------------------------------------------------------
{
group: "Assets",
permissions: [
{ value: "assets:create", label: "Create" },
{ value: "assets:read", label: "Read" },
{ value: "assets:update", label: "Update" },
{ value: "assets:delete", label: "Delete" },
],
},
{
group: "Resources",
permissions: [
{ value: "resources:create", label: "Create" },
{ value: "resources:read", label: "Read" },
{ value: "resources:update", label: "Update" },
{ value: "resources:delete", label: "Delete" },
],
},
{
group: "Vehicles",
permissions: [
{ value: "vehicles:create", label: "Create" },
{ value: "vehicles:read", label: "Read" },
{ value: "vehicles:update", label: "Update" },
{ value: "vehicles:delete", label: "Delete" },
],
},
{
group: "Structures",
permissions: [
{ value: "structures:create", label: "Create" },
{ value: "structures:read", label: "Read" },
{ value: "structures:update", label: "Update" },
{ value: "structures:delete", label: "Delete" },
],
},
{
group: "Shipments",
permissions: [
{ value: "shipments:create", label: "Create" },
{ value: "shipments:read", label: "Read" },
{ value: "shipments:update", label: "Update" },
{ value: "shipments:delete", label: "Delete" },
],
},
// ---- Banking -------------------------------------------------------------
{
group: "Bank Accounts",
permissions: [
{ value: "bank-accounts:create", label: "Create" },
{ value: "bank-accounts:read", label: "Read" },
{ value: "bank-accounts:update", label: "Update" },
{ value: "bank-accounts:delete", label: "Delete" },
],
},
{
group: "Bank Transactions",
permissions: [
{ value: "bank-transactions:create", label: "Create" },
{ value: "bank-transactions:read", label: "Read" },
{ value: "bank-transactions:update", label: "Update" },
{ value: "bank-transactions:delete", label: "Delete" },
],
},
{
group: "Ledger Entries",
permissions: [
{ value: "ledger-entries:create", label: "Create" },
{ value: "ledger-entries:read", label: "Read" },
{ value: "ledger-entries:update", label: "Update" },
{ value: "ledger-entries:delete", label: "Delete" },
],
},
// ---- Locker --------------------------------------------------------------
{
group: "Locker Storages",
permissions: [
{ value: "locker-storages:create", label: "Create" },
{ value: "locker-storages:read", label: "Read" },
{ value: "locker-storages:update", label: "Update" },
{ value: "locker-storages:delete", label: "Delete" },
],
},
{
group: "Loadouts",
permissions: [
{ value: "loadouts:create", label: "Create" },
{ value: "loadouts:read", label: "Read" },
{ value: "loadouts:update", label: "Update" },
{ value: "loadouts:delete", label: "Delete" },
],
},
// ---- Market --------------------------------------------------------------
{
group: "Market Listings",
permissions: [
{ value: "market-listings:create", label: "Create" },
{ value: "market-listings:read", label: "Read" },
{ value: "market-listings:update", label: "Update" },
{ value: "market-listings:delete", label: "Delete" },
],
},
{
group: "Market Negotiations",
permissions: [
{ value: "market-negotiations:create", label: "Create" },
{ value: "market-negotiations:read", label: "Read" },
{ value: "market-negotiations:update", label: "Update" },
{ value: "market-negotiations:delete", label: "Delete" },
],
},
// ---- Helpdesk ------------------------------------------------------------
{
group: "Tickets",
permissions: [
{ value: "tickets:create", label: "Create" },
{ value: "tickets:read", label: "Read" },
{ value: "tickets:update", label: "Update" },
{ value: "tickets:delete", label: "Delete" },
],
},
// ---- Game ----------------------------------------------------------------
{
group: "Game Structures",
permissions: [
{ value: "game-structures:create", label: "Create" },
{ value: "game-structures:read", label: "Read" },
{ value: "game-structures:update", label: "Update" },
{ value: "game-structures:delete", label: "Delete" },
],
},
{
group: "Game Vehicles",
permissions: [
{ value: "game-vehicles:create", label: "Create" },
{ value: "game-vehicles:read", label: "Read" },
{ value: "game-vehicles:update", label: "Update" },
{ value: "game-vehicles:delete", label: "Delete" },
],
},
{
group: "Game NPCs",
permissions: [
{ value: "game-npcs:create", label: "Create" },
{ value: "game-npcs:read", label: "Read" },
{ value: "game-npcs:update", label: "Update" },
{ value: "game-npcs:delete", label: "Delete" },
],
},
{
group: "Game Hard Resources",
permissions: [
{ value: "game-hard-resources:create", label: "Create" },
{ value: "game-hard-resources:read", label: "Read" },
{ value: "game-hard-resources:update", label: "Update" },
{ value: "game-hard-resources:delete", label: "Delete" },
],
},
{
group: "Game Event Logs",
permissions: [
{ value: "game-event-logs:create", label: "Create" },
{ value: "game-event-logs:read", label: "Read" },
{ value: "game-event-logs:update", label: "Update" },
{ value: "game-event-logs:delete", label: "Delete" },
],
},
// ---- World ---------------------------------------------------------------
{
group: "Maps",
permissions: [
{ value: "maps:create", label: "Create" },
{ value: "maps:read", label: "Read" },
{ value: "maps:update", label: "Update" },
{ value: "maps:delete", label: "Delete" },
],
},
{
group: "Narrative Events",
permissions: [
{ value: "narrative-events:create", label: "Create" },
{ value: "narrative-events:read", label: "Read" },
{ value: "narrative-events:update", label: "Update" },
{ value: "narrative-events:delete", label: "Delete" },
],
},
// ---- Server --------------------------------------------------------------
{
group: "Mission Files",
permissions: [
{ value: "mission-files:create", label: "Create" },
{ value: "mission-files:read", label: "Read" },
{ value: "mission-files:update", label: "Update" },
{ value: "mission-files:delete", label: "Delete" },
],
},
{
group: "Mod Lists",
permissions: [
{ value: "mod-lists:create", label: "Create" },
{ value: "mod-lists:read", label: "Read" },
{ value: "mod-lists:update", label: "Update" },
{ value: "mod-lists:delete", label: "Delete" },
],
},
// ---- Globals -------------------------------------------------------------
{
group: "Game Rules (Global)",
permissions: [
{ value: "game-rules:read", label: "Read" },
{ value: "game-rules:update", label: "Update" },
],
},
{
group: "Shims (Global)",
permissions: [
{ value: "shims:read", label: "Read" },
{ value: "shims:update", label: "Update" },
],
},
// ---- Special / Feature-area ----------------------------------------------
{
group: "Logistics",
permissions: [{ value: "logistics:manage", label: "Manage Logistics (manager bypass)" }],
},
{
group: "Intelligence",
permissions: [
{ value: "intelligence:manage", label: "Manage Intelligence (manager bypass)" },
{ value: "profiles:intel_excerpt:update", label: ["Profile", "Intel Excerpt", "Update"] },
],
},
{
group: "Banking",
permissions: [{ value: "banking:manage", label: "Manage Banking (manager bypass)" }],
},
{
group: "Helpdesk",
permissions: [{ value: "tickets:staff", label: "Staff Helpdesk Tickets" }],
},
{
group: "Discord Bot",
permissions: [
{ value: "discord:staff", label: "Bot Staff (isStaff)" },
{ value: "discord:announce", label: "Post Announcements (/announce)" },
],
},
];
// ---------------------------------------------------------------------------
// Derived exports
// ---------------------------------------------------------------------------
/** Flat array of all permission value strings, as a const tuple for type inference. */
export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
g.permissions.map((p) => p.value),
) as unknown as readonly [
"system:admin-access",
"users:create",
"users:read",
"users:update",
"users:delete",
"users:unlock",
"users:assign-roles",
"ranks:create",
"ranks:read",
"ranks:update",
"ranks:delete",
"profiles:create",
"profiles:read",
"profiles:update",
"profiles:delete",
"awards:create",
"awards:read",
"awards:update",
"awards:delete",
"qualifications:create",
"qualifications:read",
"qualifications:update",
"qualifications:delete",
"assignments:create",
"assignments:read",
"assignments:update",
"assignments:delete",
"experience:create",
"experience:read",
"experience:update",
"experience:delete",
"user-notifications:create",
"user-notifications:read",
"user-notifications:update",
"user-notifications:delete",
"missions:create",
"missions:read",
"missions:update",
"missions:delete",
"missions:read-sensitive",
"mission-attendances:create",
"mission-attendances:read",
"mission-attendances:update",
"mission-attendances:delete",
"campaigns:create",
"campaigns:read",
"campaigns:update",
"campaigns:delete",
"factions:create",
"factions:read",
"factions:update",
"factions:delete",
"technologies:create",
"technologies:read",
"technologies:update",
"technologies:delete",
"assets:create",
"assets:read",
"assets:update",
"assets:delete",
"resources:create",
"resources:read",
"resources:update",
"resources:delete",
"vehicles:create",
"vehicles:read",
"vehicles:update",
"vehicles:delete",
"structures:create",
"structures:read",
"structures:update",
"structures:delete",
"shipments:create",
"shipments:read",
"shipments:update",
"shipments:delete",
"bank-accounts:create",
"bank-accounts:read",
"bank-accounts:update",
"bank-accounts:delete",
"bank-transactions:create",
"bank-transactions:read",
"bank-transactions:update",
"bank-transactions:delete",
"ledger-entries:create",
"ledger-entries:read",
"ledger-entries:update",
"ledger-entries:delete",
"locker-storages:create",
"locker-storages:read",
"locker-storages:update",
"locker-storages:delete",
"loadouts:create",
"loadouts:read",
"loadouts:update",
"loadouts:delete",
"market-listings:create",
"market-listings:read",
"market-listings:update",
"market-listings:delete",
"market-negotiations:create",
"market-negotiations:read",
"market-negotiations:update",
"market-negotiations:delete",
"tickets:create",
"tickets:read",
"tickets:update",
"tickets:delete",
"game-structures:create",
"game-structures:read",
"game-structures:update",
"game-structures:delete",
"game-vehicles:create",
"game-vehicles:read",
"game-vehicles:update",
"game-vehicles:delete",
"game-npcs:create",
"game-npcs:read",
"game-npcs:update",
"game-npcs:delete",
"game-hard-resources:create",
"game-hard-resources:read",
"game-hard-resources:update",
"game-hard-resources:delete",
"game-event-logs:create",
"game-event-logs:read",
"game-event-logs:update",
"game-event-logs:delete",
"maps:create",
"maps:read",
"maps:update",
"maps:delete",
"narrative-events:create",
"narrative-events:read",
"narrative-events:update",
"narrative-events:delete",
"mission-files:create",
"mission-files:read",
"mission-files:update",
"mission-files:delete",
"mod-lists:create",
"mod-lists:read",
"mod-lists:update",
"mod-lists:delete",
"game-rules:read",
"game-rules:update",
"shims:read",
"shims:update",
"logistics:manage",
"banking:manage",
"tickets:staff",
"discord:staff",
"discord:announce",
"intelligence:manage",
"profiles:intel_excerpt:update",
];
/**
* Union type of every valid permission string.
* Use this to type-check permission arguments at compile time.
*/
export type Permission = (typeof ALL_PERMISSION_VALUES)[number];
/**
* Payload `select` field options, flattened with group-prefixed labels.
* e.g. `{ label: "Users › Create Users", value: "users:create" }`
*/
export const permissionSelectOptions: { label: string; value: string }[] =
PERMISSION_GROUPS.flatMap((g) =>
g.permissions.map((p) => {
let label = p.label;
if (Array.isArray(p.label)) label = p.label.join(" › ");
return {
label: `${g.group} › ${label}`,
value: p.value,
};
}),
);
/**
* Guard function — returns true if the given string is a valid permission.
* Useful for runtime validation of permission strings from DB or API input.
*/
export function isPermission(value: string): value is Permission {
return (ALL_PERMISSION_VALUES as readonly string[]).includes(value);
}

226
src/tools/seed/seedRoles.ts Normal file
View file

@ -0,0 +1,226 @@
import { getPayload } from "payload";
import config from "@payload-config";
import type { Permission } from "@/permissions";
const USER_PERMISSIONS: Permission[] = [
"users:read",
"ranks:read",
"profiles:read",
"awards:read",
"qualifications:read",
"assignments:read",
"experience:read",
"user-notifications:read",
"missions:read",
"mission-attendances:read",
"campaigns:read",
"factions:read",
"technologies:read",
"assets:read",
"resources:read",
"vehicles:read",
"structures:read",
"shipments:read",
"bank-accounts:read",
"bank-transactions:read",
"ledger-entries:read",
"locker-storages:read",
"loadouts:read",
"market-listings:read",
"market-negotiations:read",
"tickets:read",
"game-structures:read",
"game-vehicles:read",
"game-npcs:read",
"game-hard-resources:read",
"game-event-logs:read",
"maps:read",
"narrative-events:read",
"mission-files:read",
"mod-lists:read",
"game-rules:read",
"shims:read",
"shipments:create",
"shipments:update",
"structures:update",
"structures:delete",
];
const ADMIN_PERMISSIONS: Permission[] = [
...USER_PERMISSIONS,
"system:admin-access",
"users:read",
"users:update",
"users:delete",
"technologies:create",
"technologies:read",
"technologies:update",
"technologies:delete",
"tickets:read",
"tickets:update",
"tickets:staff",
"bank-accounts:create",
"bank-accounts:update",
"user-notifications:read",
"user-notifications:update",
"mission-attendances:create",
"mission-attendances:read",
"mission-attendances:update",
"mission-attendances:delete",
"missions:read",
"market-negotiations:read",
"market-negotiations:update",
"logistics:manage",
"banking:manage",
"discord:staff",
"discord:announce",
"structures:create",
];
interface BuiltinRole {
slug: string;
name: string;
description: string;
permissions: Permission[];
isSystem: boolean;
isSuperuser: boolean;
}
const BUILTIN_ROLES: BuiltinRole[] = [
{
slug: "guest",
name: "Guest",
description: "Default role for unauthenticated or basic users. No permissions.",
permissions: [],
isSystem: true,
isSuperuser: false,
},
{
slug: "user",
name: "User",
description: "Standard authenticated user. Can manage shipments, structures, and read profiles.",
permissions: [...USER_PERMISSIONS],
isSystem: true,
isSuperuser: false,
},
{
slug: "admin",
name: "Admin",
description: "Administrator. Can manage users, tickets, banking, logistics, and most collections.",
permissions: [...ADMIN_PERMISSIONS],
isSystem: true,
isSuperuser: false,
},
{
slug: "developer",
name: "Developer",
description: "Superuser. Bypasses all permission checks. Full access to everything.",
permissions: [],
isSystem: true,
isSuperuser: true,
},
];
const ENUM_TO_SLUG: Record<string, string> = {
guest: "guest",
user: "user",
trusted: "user",
admin: "admin",
developer: "developer",
};
export const seedRoles = async () => {
const payload = await getPayload({ config });
payload.logger.info("Seeding built-in RBAC roles...");
const roleIdMap = new Map<string, number>();
for (const role of BUILTIN_ROLES) {
const existing = await payload.find({
collection: "roles",
where: { slug: { equals: role.slug } },
limit: 1,
overrideAccess: true,
});
if (existing.docs.length > 0) {
const doc = existing.docs[0] as { id: number };
roleIdMap.set(role.slug, doc.id);
payload.logger.info(` Role "${role.slug}" already exists (id=${doc.id}), skipping.`);
continue;
}
const created = await payload.create({
collection: "roles",
data: {
name: role.name,
slug: role.slug,
description: role.description,
permissions: role.permissions,
isSystem: role.isSystem,
isSuperuser: role.isSuperuser,
},
overrideAccess: true,
});
roleIdMap.set(role.slug, created.id);
payload.logger.info(` Created role "${role.slug}" (id=${created.id}).`);
}
payload.logger.info("Migrating existing users from roles enum to roleDocs...");
const users = await payload.find({
collection: "users",
limit: 0,
depth: 0,
overrideAccess: true,
select: { roles: true, roleDocs: true },
});
let migrated = 0;
let skipped = 0;
for (const user of users.docs) {
const u = user as { id: number; roles?: string[] | null; roleDocs?: unknown[] | null };
if (u.roleDocs && Array.isArray(u.roleDocs) && u.roleDocs.length > 0) {
skipped++;
continue;
}
const enumRoles = u.roles ?? [];
if (enumRoles.length === 0) {
skipped++;
continue;
}
const roleDocIds: number[] = [];
const seenSlugs = new Set<string>();
for (const enumRole of enumRoles) {
const slug = ENUM_TO_SLUG[enumRole];
if (!slug || seenSlugs.has(slug)) continue;
seenSlugs.add(slug);
const roleId = roleIdMap.get(slug);
if (roleId) roleDocIds.push(roleId);
}
if (roleDocIds.length === 0) {
skipped++;
continue;
}
await payload.update({
collection: "users",
id: user.id,
data: { roleDocs: roleDocIds },
overrideAccess: true,
});
migrated++;
}
payload.logger.info(`Migration complete: ${migrated} users migrated, ${skipped} users skipped.`);
};
await seedRoles();

View file

@ -14,6 +14,15 @@ export const seedUsers = async () => {
}, },
}); });
const devRole = await payload.find({
collection: "roles",
where: { slug: { equals: "developer" } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const devRoleId = devRole.docs[0]?.id;
payload.logger.info(`Creating initial sysadmin/developer user...`); payload.logger.info(`Creating initial sysadmin/developer user...`);
try { try {
const result = await payload.create({ const result = await payload.create({
@ -25,6 +34,7 @@ export const seedUsers = async () => {
discordUsername: "Z8MB1E", discordUsername: "Z8MB1E",
steamId: "76561198091303179", steamId: "76561198091303179",
roles: ["developer"], roles: ["developer"],
roleDocs: devRoleId ? [devRoleId] : [],
}, },
}); });

57
src/utils/AGENTS.md Normal file
View file

@ -0,0 +1,57 @@
# AGENTS.md — Utilities & Access Control
> **Parent**: `../../AGENTS.md` — RBAC overview, permission groups, event system.
## Overview
10 files across 3 subdirectories. Core cross-cutting concerns: three-layer RBAC, fire-and-forget event logging, XP resolution.
## Structure
```
utils/
access-control/ 6 files Permission checking, role gates, qualification queries
event-log/ 3 files Event emitter, type constants, formatting
xp/ 1 file Level resolver
```
## Access control layers
Three independent access mechanisms, each used in different contexts:
### 1. `hasPermission(payload, user, "domain:action")`
Full RBAC check against `src/permissions/index.ts` (100+ permissions). Superuser bypass. Cached 30s via `loadUserPermissions`. Used in server actions and collection access functions.
### 2. `isRole(role)` / `hasRoles(roles[])`
Lightweight role checks. Used for quick conditional rendering (e.g., `isRole("admin")` for admin-only UI). No Payload call — reads from the user object directly.
### 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.
## Event log system
### Emitter: `emitGameEvent(payload, { type, message, ... })`
Fire-and-forget create on `game-event-logs`. Always sets `system: true`. Silent error catch (no throw). Always called AFTER successful mutation in server actions.
### Event types: `EventTypes` constants (95 types across 12 categories)
Defined in `eventTypes.ts`. Use these constants for TypeScript narrowing on the `type` field. Categories: `mission:*`, `finance:*`, `market:*`, `structure:*`, `logistics:*`, `bank:*`, `notification:*`, `locker:*`, `xp:*`, `ticket:*`, `attendance:*`, `system:*`.
### Display: `formatType(type)` — human-readable label for event types.
## Where to look
| Task | Path |
|------|------|
| Add new RBAC permission | `src/permissions/index.ts` + use in `hasPermission` calls |
| Add qualification gate | `access-control/hasLogisticsQualification.ts` (or create similar) |
| Add event type constant | `event-log/eventTypes.ts` (add to `EventTypes` object) |
| Emit event from action | `import { emitGameEvent } from "@/utils/event-log/emit"` |
| Check qualification in layout | Use `hasLogisticsQualification(payload, user)` |
| Modify XP calculation | `xp/resolveLevel.ts` |
## Anti-patterns
- **NEVER** throw in `emitGameEvent` — it's fire-and-forget by design
- **NEVER** use `isRole` for permission-sensitive operations — use `hasPermission` instead
- **NEVER** hardcode qualification strings — use the constants in the qualification collection
- **NEVER** add event types without adding to `EventTypes` constants (breaks TypeScript narrowing)

View file

@ -1,4 +1,26 @@
import type { Payload } from "payload"; import type { Payload } from "payload";
import { hasAnyPermission } from "@/utils/access-control/hasPermission";
const INTELLIGENCE_PERMISSIONS = [
"intelligence:manage",
"missions:read",
"missions:create",
"missions:update",
"missions:delete",
"missions:read-sensitive",
"campaigns:read",
"campaigns:create",
"campaigns:update",
"campaigns:delete",
"factions:read",
"factions:create",
"factions:update",
"factions:delete",
"technologies:read",
"technologies:create",
"technologies:update",
"technologies:delete",
] as const;
export async function hasIntelligenceQualification( export async function hasIntelligenceQualification(
payload: Payload, payload: Payload,
@ -6,10 +28,7 @@ export async function hasIntelligenceQualification(
): Promise<boolean> { ): Promise<boolean> {
if (!user) return false; if (!user) return false;
const roles = user.roles as string[] | undefined; if (await hasAnyPermission(payload, user, ...INTELLIGENCE_PERMISSIONS)) return true;
if (roles?.includes("developer") || roles?.includes("admin")) {
return true;
}
const profile = (await payload.find({ const profile = (await payload.find({
collection: "profiles", collection: "profiles",

View file

@ -1,9 +1,55 @@
import type { Payload } from "payload"; import type { Payload } from "payload";
import { hasAnyPermission } from "@/utils/access-control/hasPermission";
const LOGISTICS_PERMISSIONS = [
"logistics:manage",
"assets:read",
"assets:create",
"assets:update",
"assets:delete",
"resources:read",
"resources:create",
"resources:update",
"resources:delete",
"vehicles:read",
"vehicles:create",
"vehicles:update",
"vehicles:delete",
"structures:read",
"structures:create",
"structures:update",
"structures:delete",
"shipments:read",
"shipments:create",
"shipments:update",
"shipments:delete",
"bank-accounts:read",
"bank-accounts:create",
"bank-accounts:update",
"bank-accounts:delete",
"banking:manage",
"market-listings:read",
"market-listings:create",
"market-listings:update",
"market-listings:delete",
"game-structures:read",
"game-structures:create",
"game-structures:update",
"game-structures:delete",
"game-vehicles:read",
"game-vehicles:create",
"game-vehicles:update",
"game-vehicles:delete",
"game-npcs:read",
"game-npcs:create",
"game-npcs:update",
"game-npcs:delete",
] as const;
/** /**
* Checks if a user has the Logistics qualification. * Checks if a user has the Logistics qualification.
* Queries the user's profile for qualifications matching "Logistics" (case-insensitive). * Queries the user's profile for qualifications matching "Logistics" (case-insensitive).
* Developers and admins always pass. * Users with ANY logistics-domain RBAC permission always pass.
*/ */
export async function hasLogisticsQualification( export async function hasLogisticsQualification(
payload: Payload, payload: Payload,
@ -11,10 +57,7 @@ export async function hasLogisticsQualification(
): Promise<boolean> { ): Promise<boolean> {
if (!user) return false; if (!user) return false;
const roles = user.roles as string[] | undefined; if (await hasAnyPermission(payload, user, ...LOGISTICS_PERMISSIONS)) return true;
if (roles?.includes("developer") || roles?.includes("admin")) {
return true;
}
const profileRes = await payload.find({ const profileRes = await payload.find({
collection: "profiles", collection: "profiles",

View file

@ -0,0 +1,113 @@
import type { Payload, PayloadRequest } from "payload";
import { loadUserPermissions } from "@/utils/access-control/loadUserPermissions";
import type { Permission } from "@/permissions";
/**
* Minimal user shape for permission checks.
* Accepts the full Payload User or a stripped-down { id } from server actions.
*/
interface UserLike {
id: number | string;
roleDocs?: unknown;
}
/**
* Check whether a user has a specific permission.
*
* Resolution order:
* 1. No user → false.
* 2. User has a role with `isSuperuser: true` → true (bypasses all checks).
* 3. User has a role whose `permissions` array includes the given permission → true.
* 4. Otherwise → false.
*
* Results are cached per-user for 30s (see `loadUserPermissions`).
*
* @example
* const canCreate = await hasPermission(payload, user, "users:create");
*/
export async function hasPermission(
payload: Payload,
user: UserLike | null | undefined,
permission: Permission,
): Promise<boolean> {
if (!user) return false;
const { permissions, isSuperuser } = await loadUserPermissions(payload, user);
if (isSuperuser) return true;
return permissions.has(permission);
}
/**
* Check whether a user has a superuser role (bypasses all permission checks).
*
* Use this for the legacy `isDeveloper` replacement where the check was
* "can do anything" rather than a specific permission.
*/
export async function isSuperuser(
payload: Payload,
user: UserLike | null | undefined,
): Promise<boolean> {
if (!user) return false;
const { isSuperuser: su } = await loadUserPermissions(payload, user);
return su;
}
/**
* Check whether a user has ANY of the given permissions.
*/
export async function hasAnyPermission(
payload: Payload,
user: UserLike | null | undefined,
...permissions: Permission[]
): Promise<boolean> {
if (!user) return false;
const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(payload, user);
if (su) return true;
return permissions.some((p) => userPerms.has(p));
}
/**
* Check whether a user has ALL of the given permissions.
*/
export async function hasAllPermissions(
payload: Payload,
user: UserLike | null | undefined,
...permissions: Permission[]
): Promise<boolean> {
if (!user) return false;
const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(payload, user);
if (su) return true;
return permissions.every((p) => userPerms.has(p));
}
/**
* Factory that creates a Payload collection access function requiring a specific
* permission. Drop-in replacement for `isDeveloper` / `isAdmin` in collection
* `access` blocks.
*
* @example
* access: {
* create: requirePermission("users:create"),
* update: requirePermission("users:update"),
* }
*/
export function requirePermission(permission: Permission) {
return async ({ req }: { req: PayloadRequest }): Promise<boolean> => {
return hasPermission(req.payload, req.user, permission);
};
}
/**
* Factory that creates a Payload collection access function requiring ANY of
* the given permissions.
*
* @example
* access: {
* update: requireAnyPermission("tickets:update", "tickets:staff"),
* }
*/
export function requireAnyPermission(...permissions: Permission[]) {
return async ({ req }: { req: PayloadRequest }): Promise<boolean> => {
return hasAnyPermission(req.payload, req.user, ...permissions);
};
}

View file

@ -0,0 +1,121 @@
import type { Payload } from "payload";
const CACHE_TTL_MS = 30_000;
const MAX_INHERITANCE_DEPTH = 10;
interface CachedPermissions {
permissions: Set<string>;
isSuperuser: boolean;
loadedAt: number;
}
const cache = new Map<number, CachedPermissions>();
interface UserLike {
id: number | string;
roleDocs?: unknown;
}
interface RoleDoc {
id: number;
permissions?: string[] | null;
isSuperuser?: boolean | null;
parentRoles?: Array<number | { id: number }> | null;
}
function resolveRoleId(ref: number | { id: number }): number {
return typeof ref === "number" ? ref : ref.id;
}
export async function loadUserPermissions(
payload: Payload,
user: UserLike | null | undefined,
): Promise<{ permissions: Set<string>; isSuperuser: boolean }> {
if (!user) return { permissions: new Set(), isSuperuser: false };
const userId = Number(user.id);
if (Number.isNaN(userId)) return { permissions: new Set(), isSuperuser: false };
const cached = cache.get(userId);
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
return cached;
}
const permissions = new Set<string>();
let isSuperuser = false;
try {
const userDoc = (await payload.findByID({
collection: "users",
id: userId,
depth: 2,
overrideAccess: true,
})) as { roleDocs?: RoleDoc[] | null } | null;
const directRoles = userDoc?.roleDocs;
if (!directRoles || !Array.isArray(directRoles)) {
const result: CachedPermissions = { permissions, isSuperuser, loadedAt: Date.now() };
cache.set(userId, result);
return result;
}
const visited = new Set<number>();
let currentLevel: RoleDoc[] = directRoles.filter((r) => {
const id = Number(r.id);
if (visited.has(id)) return false;
visited.add(id);
return true;
});
for (let depth = 0; depth < MAX_INHERITANCE_DEPTH && currentLevel.length > 0; depth++) {
for (const role of currentLevel) {
if (role.isSuperuser) isSuperuser = true;
if (role.permissions && Array.isArray(role.permissions)) {
for (const p of role.permissions) {
if (typeof p === "string") permissions.add(p);
}
}
}
const parentIds: number[] = [];
for (const role of currentLevel) {
if (!role.parentRoles || !Array.isArray(role.parentRoles)) continue;
for (const parentRef of role.parentRoles) {
const parentId = resolveRoleId(parentRef);
if (!visited.has(parentId)) {
visited.add(parentId);
parentIds.push(parentId);
}
}
}
if (parentIds.length === 0) {
currentLevel = [];
break;
}
const parentRes = await payload.find({
collection: "roles",
where: { id: { in: parentIds } },
limit: parentIds.length,
depth: 1,
overrideAccess: true,
});
currentLevel = parentRes.docs as unknown as RoleDoc[];
}
} catch {
return { permissions: new Set(), isSuperuser: false };
}
const result: CachedPermissions = { permissions, isSuperuser, loadedAt: Date.now() };
cache.set(userId, result);
return result;
}
export function invalidatePermissionCache(userId?: number): void {
if (userId !== undefined) {
cache.delete(userId);
} else {
cache.clear();
}
}