Compare commits
8 commits
4c3c3373d3
...
a8e147e52a
| Author | SHA1 | Date | |
|---|---|---|---|
| a8e147e52a | |||
| fb74e6b0c2 | |||
| d3890cc191 | |||
| 1f9f919443 | |||
| eef1b11bb1 | |||
| 653caa8064 | |||
| 80f28ed939 | |||
| adaedaefde |
75 changed files with 2661 additions and 340 deletions
50
AGENTS.md
50
AGENTS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
238
docs/case-studies/login-return-url.md
Normal file
238
docs/case-studies/login-return-url.md
Normal 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.
|
||||||
|
|
@ -144,26 +144,34 @@ 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 staffRes = await payload.find({
|
const staffRoles = await payload.find({
|
||||||
collection: "users",
|
collection: "roles",
|
||||||
where: {
|
where: { slug: { in: ["admin", "developer"] } },
|
||||||
or: [{ roles: { contains: "admin" } }, { roles: { contains: "developer" } }],
|
limit: 2,
|
||||||
},
|
|
||||||
limit: 100,
|
|
||||||
depth: 0,
|
depth: 0,
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
const staffIds = [...new Set(staffRes.docs.map((doc) => doc.id as number))].filter(
|
const staffRoleIds = staffRoles.docs.map((d) => d.id);
|
||||||
(id) => id !== userId,
|
if (staffRoleIds.length > 0) {
|
||||||
);
|
const staffRes = await payload.find({
|
||||||
for (const staffId of staffIds) {
|
collection: "users",
|
||||||
await notifyUser(payload, {
|
where: { roleDocs: { in: staffRoleIds } },
|
||||||
userId: staffId,
|
limit: 100,
|
||||||
type: "account:discord-request",
|
depth: 0,
|
||||||
title: `Discord username change requested by ${user.username}`,
|
overrideAccess: true,
|
||||||
message: `wants to change their Discord username to "${trimmed}".`,
|
|
||||||
link: `/admin/collections/users/${userId}`,
|
|
||||||
});
|
});
|
||||||
|
const staffIds = [...new Set(staffRes.docs.map((doc) => doc.id as number))].filter(
|
||||||
|
(id) => id !== userId,
|
||||||
|
);
|
||||||
|
for (const staffId of staffIds) {
|
||||||
|
await notifyUser(payload, {
|
||||||
|
userId: staffId,
|
||||||
|
type: "account:discord-request",
|
||||||
|
title: `Discord username change requested by ${user.username}`,
|
||||||
|
message: `wants to change their Discord username to "${trimmed}".`,
|
||||||
|
link: `/admin/collections/users/${userId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await notifyUser(payload, {
|
await notifyUser(payload, {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -40,8 +42,8 @@ export default async function TicketPage({ params }: TicketPageProps) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
@ -28,8 +30,8 @@ export default async function HelpdeskPage() {
|
||||||
limit: 200,
|
limit: 200,
|
||||||
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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -34,9 +34,9 @@ export default async function LockerPage() {
|
||||||
limit: 100,
|
limit: 100,
|
||||||
depth: 1,
|
depth: 1,
|
||||||
});
|
});
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
@ -35,12 +35,12 @@ export default async function AccountPage({ params }: AccountPageProps) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
const ownerId =
|
const ownerId =
|
||||||
typeof typedAccount.ownerUser === "object"
|
typeof typedAccount.ownerUser === "object"
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
@ -34,20 +34,20 @@ export default async function BankingPage() {
|
||||||
});
|
});
|
||||||
const recentTransactions = transactionsRes.docs as unknown as BankTransaction[];
|
const recentTransactions = transactionsRes.docs as unknown as BankTransaction[];
|
||||||
|
|
||||||
const factionsRes = await payload.find({
|
const factionsRes = await payload.find({
|
||||||
collection: "factions",
|
collection: "factions",
|
||||||
limit: 100,
|
limit: 100,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
const factions = (factionsRes.docs as unknown as Faction[]).map((f) => ({
|
const factions = (factionsRes.docs as unknown as Faction[]).map((f) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
const currentUser = user
|
const currentUser = user
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
69
src/bot/AGENTS.md
Normal 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
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
81
src/collections/AGENTS.md
Normal 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
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -126,27 +131,23 @@ export const Missions: CollectionConfig = {
|
||||||
value: "external",
|
value: "external",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
filterOptions: ({ req }) => {
|
filterOptions: ({ req }) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "Side Operation",
|
label: "Side Operation",
|
||||||
value: "side",
|
value: "side",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "External Operation",
|
label: "External Operation",
|
||||||
value: "external",
|
value: "external",
|
||||||
},
|
},
|
||||||
...(isDeveloper({ req: req })
|
{
|
||||||
? [
|
label: "Main Operation",
|
||||||
{
|
value: "main",
|
||||||
label: "Main Operation",
|
},
|
||||||
value: "main",
|
];
|
||||||
},
|
},
|
||||||
]
|
defaultValue: ({ req }) => "side",
|
||||||
: []),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
defaultValue: ({ req }) => (isDeveloper({ req: req }) ? "main" : "side"),
|
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -504,9 +505,11 @@ export const Missions: CollectionConfig = {
|
||||||
type: "text",
|
type: "text",
|
||||||
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");
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
typeof option === "string"
|
if (canReadAll) return options;
|
||||||
? options
|
return options.filter((option) =>
|
||||||
: ["in_progress", "ready_for_review"].includes(option.value),
|
typeof option === "string"
|
||||||
)
|
? options
|
||||||
: options;
|
: ["in_progress", "ready_for_review"].includes(option.value),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
required: true,
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
149
src/collections/users/Roles.ts
Normal file
149
src/collections/users/Roles.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,25 +20,45 @@ export const Users: CollectionConfig = {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ranks = await payload.find({
|
let recruit: { id: number } | undefined;
|
||||||
collection: "ranks",
|
|
||||||
where: {
|
try {
|
||||||
name: {
|
const ranks = await payload.find({
|
||||||
like: "Recruit",
|
collection: "ranks",
|
||||||
|
where: {
|
||||||
|
name: {
|
||||||
|
like: "Recruit",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
limit: 1,
|
||||||
limit: 1,
|
depth: 0,
|
||||||
depth: 0,
|
overrideAccess: true,
|
||||||
overrideAccess: true,
|
});
|
||||||
});
|
recruit = (ranks as { docs: Array<{ id: number }> }).docs[0];
|
||||||
const 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",
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
65
src/components/frontend/AGENTS.md
Normal file
65
src/components/frontend/AGENTS.md
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push("/");
|
// Redirect to returnTo if provided, otherwise to home
|
||||||
|
if (returnTo && returnTo !== "/") {
|
||||||
|
router.push(returnTo);
|
||||||
|
} else {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Something went wrong. Please try again.");
|
setError("Something went wrong. Please try again.");
|
||||||
|
|
|
||||||
12
src/components/frontend/auth/LoginLink.tsx
Normal file
12
src/components/frontend/auth/LoginLink.tsx
Normal 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
85
src/lib/AGENTS.md
Normal 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)
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
616
src/permissions/index.ts
Normal 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
226
src/tools/seed/seedRoles.ts
Normal 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();
|
||||||
|
|
@ -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
57
src/utils/AGENTS.md
Normal 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)
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
113
src/utils/access-control/hasPermission.ts
Normal file
113
src/utils/access-control/hasPermission.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
121
src/utils/access-control/loadUserPermissions.ts
Normal file
121
src/utils/access-control/loadUserPermissions.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue