From 93eb846b6ae576dcc6eaa4b8c576de4ed25e253d Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 16:23:02 +0300 Subject: [PATCH 01/36] docs(permissions): spec delegated dashboard access + generated registry Lets guild members with explicit dashboard grants see and manage a server without Discord MANAGE_GUILD, and derives the permission registry from the route table instead of a hand-maintained constant. Co-Authored-By: Claude Opus 5 (1M context) --- ...07-28-delegated-dashboard-access-design.md | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md diff --git a/docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md b/docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md new file mode 100644 index 00000000..e61037d4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md @@ -0,0 +1,276 @@ +# Delegated Dashboard Access + Generated Permission Registry + +> **Date:** 2026-07-28 +> **Status:** Approved (design) +> **Supersedes:** `docs/features/dashboard-permissions.md` design decision "Non-MANAGE_GUILD access: No" (line 21) + +## Problem + +Two problems, one change. + +**1. The permission system can't actually delegate.** `resolveUserPermissions` returns an empty +set the moment `isUserGuildAdmin` is false (`permissions.ts:91-98`), so a member's dashboard roles +are never read. `/api/guilds` separately filters the OAuth snapshot to `owner || canManageGuild` +(`guilds/routes.ts:21`). A server owner can build a "Ticket Moderator" role, assign it to someone, +and that person still sees nothing — Discord `MANAGE_GUILD` remains a hard prerequisite, which +defeats the point of granular permissions. + +**2. The permission registry is hand-maintained.** `packages/types/src/dashboard-permissions.ts` +is a 305-line literal listing 48 keys. Nothing ties it to the `requirePermission(...)` calls that +actually enforce anything, so a key can exist in the UI with no route behind it (or the reverse) +and no test catches it. The permissions page compounds this by importing the constant directly and +ignoring the `GET /api/guilds/:guildId/permission-registry` endpoint that serves the same data. + +## Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Non-admin access | Yes, via explicit grants | The delegation feature is inert without it | +| What makes someone eligible | A `DashboardRoleAssignment` or `DashboardUserPermission` row | Explicit, auditable, opt-in per user | +| `isDefault` roles for non-admins | Never applied | Otherwise enabling the toggle admits every member at once | +| `requirePermissions` semantics | Governs whether **admins** are constrained — nothing else | Grants work in both modes; the toggle stops meaning "on/off for the system" | +| Discord lookup routes | Own permission key `dashboard.lookups.view` | Precision over convenience; broken pickers mitigated in UI | +| Registry source | Runtime, from the route table | A key cannot exist without enforcement, and vice versa | +| Registry labels | Derived i18n keys | ~52 strings instead of ~96 hardcoded English ones | + +## Part A — Access Model + +### Resolution order + +`resolveUserPermissions(userId, guildId)`: + +| User | `requirePermissions: false` | `requirePermissions: true` | +|---|---|---| +| Guild owner | `*` | `*` | +| Live guild admin | `*` (unchanged) | assignments + `isDefault` roles + overrides | +| Guild member with grants | their grants | their grants | +| Guild member, no grants | ∅ | ∅ | +| Not a member | ∅ | ∅ | + +`requirePermissions` is read **only** on the admin path. Explicit grants resolve identically in +both modes. `isDefault` roles are merged **only** on the admin path. + +### Live authority, fetched once + +`guildAuthz.ts` gains: + +```typescript +export interface GuildAuthority { + isOwner: boolean; + isAdmin: boolean; // owner, Administrator, or Manage Server + isMember: boolean; +} + +export async function getGuildAuthority(guildId, userId): Promise +``` + +One `getGuildMember` + `getGuildRoles` pair serves all three answers. `isUserGuildAdmin` becomes a +thin wrapper over it so existing callers and tests keep working. The non-admin path needs +`isMember` anyway, so this avoids a second round trip. + +`ResolvedPermissions` gains `isGuildMember: boolean` alongside `isOwner` / `isGuildAdmin`. The 60s +cache and its invalidation are unchanged, so a kicked or demoted user loses access within 60s. + +### Middleware + +`requireGuildAdmin` → **`requireGuildAccess`**, renamed across all 21 route files. The gate becomes +"does this user have any authority here": + +```typescript +const authorized = resolved.isOwner || resolved.isGuildAdmin || resolved.permissions.size > 0; +``` + +The `botNotInGuild` check and the `resolvedPermissions` attachment are unchanged. `requirePermission` +is unchanged in behavior. Every guild-scoped route already pairs with one except six: the four +Discord lookup routes (handled below) and `GET .../my-permissions` + `GET .../permission-registry`, +which stay at access level by design — anyone who can enter a guild must be able to read their own +permissions and the key vocabulary, or the UI cannot render. + +Places that genuinely need admin authority (the owner-only `requirePermissions` toggle) keep +checking `isOwner` / `isGuildAdmin` explicitly — the rename does not weaken them. + +### Guild list + +`buildManageableGuilds` becomes a union: + +1. OAuth guilds where `owner || canManageGuild(permissions)` — as today. +2. Guild IDs with an explicit grant for this user: two indexed queries + (`dashboardRoleAssignment.findMany({ where: { userId } })`, + `dashboardUserPermission.findMany({ where: { userId } })`), **intersected with + `session.guilds`**. The session holds the user's full OAuth guild list, so the intersection is + the membership check — a guild they were removed from cannot appear. + +Each entry gains `access: "admin" | "delegated"`. Sorting (bot-present first, then name) is +unchanged. `POST /api/guilds/refresh` uses the same builder and so inherits this. + +### Discord lookup routes + +The four routes in `discord/routes.ts` (channels, roles, members, member search) currently pass +`requireGuildAdmin` with no permission key — they would otherwise become reachable by anyone with +any grant. They get `requirePermission("dashboard.lookups.view")`. + +Consequence: a role with `tickets.list.manage` and no lookups permission renders empty channel and +role pickers. Mitigations, both required: + +- Every built-in preset includes `dashboard.lookups.view`. +- The role editor shows an inline warning when a role holds any `*.manage` / `*.config.*` + permission without it, with a one-click fix. + +### Security gaps closed in this work + +**Privilege escalation.** `permissions/roles-routes.ts` has no escalation guard today, despite the +feature spec calling for one. With non-admins in scope this is a path from "delegated moderator" to +full control. Add: when creating or updating a dashboard role, or writing a user permission +override, every key in the payload must be one the actor already holds (`matchPermission` against +the actor's resolved set). The guild owner bypasses this. The same check applies to assigning a +role — you cannot assign a role holding permissions you lack. Violations return 403 with the +offending key. + +**Admission power.** `dashboard.roles.manage` now means "can admit people to the dashboard". It +stays a single key, but the permissions page labels it that way so an owner delegating it +understands what they are handing over. + +## Part B — Generated Registry + +### Self-registering keys + +`requirePermission(...keys)` records its keys in a module-level `Set` when the factory runs — that +is, at route-registration time. After `app.ready()` the set is complete and *is* the registry. + +```typescript +const declaredPermissions = new Set(); + +export function requirePermission(...keys: string[]) { + for (const key of keys) declaredPermissions.add(key); + return async (request, reply) => { /* unchanged */ }; +} + +export function getDeclaredPermissions(): ReadonlySet { + return declaredPermissions; +} +``` + +### Building the tree + +New `server/shared/permissionRegistry.ts` parses each `module.resource.action` key and groups it, +taking presentation from the one hand-maintained map: + +```typescript +const MODULE_META: Record = { + dashboard: { icon: "admin_panel_settings", order: 0 }, + moderation: { icon: "shield", order: 1 }, + // ... +}; +``` + +Each entry carries derived i18n keys rather than English text: + +- module label → `permissions:modules.` +- permission label → `permissions:resources..` + `permissions:actions.` +- description → `permissions:descriptions...`, falling back to a + composed "`` ``" string when absent. + +**Boot validation** (runs after `app.ready()`, throws in dev, logs an error in production): + +- every declared key's module has a `MODULE_META` entry; +- every key has exactly three segments; +- every non-wildcard key in `ROLE_PRESETS` is declared; +- every `navItems[].permission` is declared (asserted by test, since nav is client-side). + +### Consumers + +- `GET /api/guilds/:guildId/permission-registry` serves the built tree instead of the constant. +- The permissions page fetches it via a new `usePermissionRegistry(guildId)` hook and drops its + direct import. +- `PERMISSION_REGISTRY` is deleted from `packages/types`. `matchPermission`, `expandWildcard`, + `ROLE_PRESETS`, and the `PermissionDefinition` / `PermissionModule` types stay — the types now + carry i18n keys instead of literal labels. +- Role-save validation (rejecting unknown permission keys) validates against the live registry. + +## Part C — Landing & Overview + +`OverviewPage` calls `useAnalytics` unconditionally and renders `CardGridSkeleton` while +`isLoading || !analytics`. On a 403 that skeleton never resolves — a live bug today for any +restricted admin, and the default landing for every delegated user. Changes: + +- Analytics block renders only when `can("actions.analytics.view")`. +- A failed or absent query renders an error/empty state, never an indefinite skeleton. +- Users without that permission get an "access summary" panel: the nav items they can reach, as + links, plus their assigned dashboard roles. +- Overview stays permission-free in `navItems` — it is the landing page and must always resolve. + +## Part D — Permissions Page + +- Grid renders from the fetched registry; labels come from i18n keys. +- Role editor: pickers warning (Part A), escalation guard reflected as disabled checkboxes with a + tooltip explaining that you cannot grant what you do not hold. +- `dashboard.roles.manage` is labelled as granting dashboard admission. + +## Part E — i18n + +New keys under the `permissions` namespace: ~16 module names, ~30 resource names, 6 action verbs, +plus the new warning/labels. Per project convention these are translated in **all 48 locales** in +the same change; `en` placeholders are not acceptable. Source of truth is `src/locales`, and the +app serves `dist/locales`. + +This is a net reduction: the grid is currently the only untranslated surface in the dashboard, +rendering ~96 hardcoded English strings. + +## Testing + +### Unit — `apps/dashboard/tests/server/` + +`resolveUserPermissions`, one test per path: + +- owner → `*` regardless of toggle +- admin + `requirePermissions: false` → `*` +- admin + `requirePermissions: true` → assignments ∪ defaults ∪ overrides +- non-admin member + explicit grant → exactly the grant, in **both** toggle states +- non-admin member + `isDefault` role only → ∅ (defaults must not leak) +- non-member with a stale grant row → ∅ + +`requireGuildAccess`: owner, admin, delegated → pass; member without grants → 403; +bot not in guild → 403 `botNotInGuild`. + +Registry: keys collected from a built app match the `requirePermission` calls; unknown module → +boot validation throws; presets fully declared; every `navItems[].permission` declared. + +Escalation guard: create/update role, user override, and role assignment each reject a key the +actor lacks; owner bypasses. + +`/api/guilds`: returns the union; a grant for a guild the user is not in is excluded; `access` is +`"delegated"` for grant-only guilds and `"admin"` otherwise. + +### Integration — `packages/systems/tests/integration/` + +Assign a dashboard role to a non-admin user → the guild appears in their list, their permitted +route returns 200, a non-permitted route returns 403; remove the assignment → access gone after +cache invalidation. + +### Client + +`usePermissionRegistry` renders the grid from fetched data; overview renders the access-summary +panel without `actions.analytics.view` and an error state on failure (never a permanent skeleton). + +Per project rules: no `any`, no `as` casts in tests; mock Discord API and the logger; use the +shared factories. + +## Rollout + +No schema migration — all four models already exist. No behavioral change for any existing guild +until someone assigns a dashboard role or user override to a non-admin. Recommended order: + +1. `getGuildAuthority` refactor + `resolveUserPermissions` rework + tests +2. `requireGuildAccess` rename + `dashboard.lookups.view` on the Discord routes +3. Escalation guard +4. `/api/guilds` union + `access` field + servers-page badge +5. Generated registry + endpoint + client hook + delete the static constant +6. Overview fix and access-summary panel +7. i18n across 48 locales + +## Out of Scope + +- Mirroring Discord roles onto dashboard roles (considered, rejected — needs schema and UI beyond + this change). +- Bot-side permission checks; this is dashboard-only. +- Audit log retention/cleanup work. From 934b045ac903811e0e58d45f84eca9eb845dc391 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 16:37:01 +0300 Subject: [PATCH 02/36] docs(permissions): implementation plan for delegated dashboard access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 TDD tasks covering non-admin grant resolution, the requireGuildAccess gate, the role-assignment escalation fix, the route-table-derived registry, and the overview landing for delegated users. Also corrects the spec: escalation guards already exist on role create, update, preset create, and user overrides — only role assignment is unguarded. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-07-28-delegated-dashboard-access.md | 2638 +++++++++++++++++ ...07-28-delegated-dashboard-access-design.md | 22 +- 2 files changed, 2653 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-28-delegated-dashboard-access.md diff --git a/docs/superpowers/plans/2026-07-28-delegated-dashboard-access.md b/docs/superpowers/plans/2026-07-28-delegated-dashboard-access.md new file mode 100644 index 00000000..c06ed705 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-delegated-dashboard-access.md @@ -0,0 +1,2638 @@ +# Delegated Dashboard Access + Generated Permission Registry — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a guild member with explicit dashboard grants see and manage a server without Discord `MANAGE_GUILD`, and derive the permission registry from the route table instead of a hand-maintained constant. + +**Architecture:** `resolveUserPermissions` stops treating live Discord admin authority as a prerequisite and instead treats it as one of three sources of permissions (owner → `*`, admin → `*` or role grants, member → explicit grants only). The route gate `requireGuildAdmin` is renamed `requireGuildAccess` and authorizes on "resolved permission set is non-empty". Separately, `requirePermission(...)` self-registers every key it enforces, so the registry is a byproduct of route registration rather than a parallel list. + +**Tech Stack:** Fastify 5, Prisma 7, React 19, TanStack Query/Router, Vitest 4, Zod, react-i18next, Tailwind 4 + shadcn/ui. + +**Spec:** `docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md` + +## Global Constraints + +- All `pnpm` commands run inside Docker. Never run `pnpm add`/`pnpm install` on the host. +- Strict TypeScript. No `any`. No `as` casts — **including in test files**. Narrow to a structural type instead of naming a wide class. +- Never read or write `.env` files. +- Tests are mandatory for every task. Mock the logger and all Discord API calls; never mock a pure data/constants module. +- Locale source of truth is `packages/i18n/src/locales//*.json` (48 languages). The app serves `dist/locales`. New user-facing strings must be translated in **all 48 locales** in the same change — English placeholders block a merge. +- `JSON.stringify(obj, null, 2)` is **not** format-preserving for these locale files (some are semi-compact; 17 contain `\u` escapes). Only round-trip a file when a no-op round-trip is byte-identical; otherwise splice text. +- Dashboard UI uses existing shadcn/ui wrappers in `apps/dashboard/src/client/shared/ui/`. Lucide icons via the `Icon` component. Never fill Lucide icons. +- Commit after every task. Branch: `feat/delegated-dashboard-access`. +- Verification commands: `pnpm typecheck`, `pnpm test`, `pnpm test:integration`. Note `pnpm typecheck` does **not** cover `apps/dashboard/tests/**` — a green typecheck says nothing about test files; run the tests. + +--- + +### Task 1: `getGuildAuthority` — one live authority lookup + +Today `isUserGuildAdmin` answers only "is admin". The delegated path also needs "is a member at all", and fetching that twice would double the Discord calls. + +**Files:** + +- Modify: `apps/dashboard/src/server/shared/guildAuthz.ts` +- Test: `apps/dashboard/tests/server/shared/guildAuthz.test.ts` + +**Interfaces:** + +- Consumes: `getGuildOwnerId`, `getGuildMember`, `getGuildRoles` from `./discordApi.js`; `canManageGuild` from `./guildPermissions.js`. +- Produces: `export interface GuildAuthority { isOwner: boolean; isAdmin: boolean; isMember: boolean }` and `export async function getGuildAuthority(guildId: string, userId: string): Promise`. `isUserGuildAdmin(guildId, userId): Promise` keeps its signature. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/dashboard/tests/server/shared/guildAuthz.test.ts` (reuse the existing mocks at the top of that file — do not add new ones): + +```typescript +describe("getGuildAuthority", () => { + it("reports the owner as owner, admin, and member without fetching the member", async () => { + mockGetGuildOwnerId.mockResolvedValue("user-1"); + + const authority = await getGuildAuthority("guild-1", "user-1"); + + expect(authority).toEqual({ isOwner: true, isAdmin: true, isMember: true }); + expect(mockGetGuildMember).not.toHaveBeenCalled(); + }); + + it("reports a non-member as nothing", async () => { + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockGetGuildMember.mockResolvedValue(null); + + const authority = await getGuildAuthority("guild-1", "user-1"); + + expect(authority).toEqual({ isOwner: false, isAdmin: false, isMember: false }); + }); + + it("reports a plain member as a member but not an admin", async () => { + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockGetGuildMember.mockResolvedValue({ roles: ["role-1"] }); + mockGetGuildRoles.mockResolvedValue([ + { id: "guild-1", name: "@everyone", permissions: "0" }, + { id: "role-1", name: "Member", permissions: "0" }, + ]); + + const authority = await getGuildAuthority("guild-1", "user-1"); + + expect(authority).toEqual({ isOwner: false, isAdmin: false, isMember: true }); + }); + + it("reports a member with Manage Server as an admin", async () => { + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockGetGuildMember.mockResolvedValue({ roles: ["role-1"] }); + mockGetGuildRoles.mockResolvedValue([ + { id: "guild-1", name: "@everyone", permissions: "0" }, + { id: "role-1", name: "Staff", permissions: BigInt(0x20).toString() }, + ]); + + const authority = await getGuildAuthority("guild-1", "user-1"); + + expect(authority).toEqual({ isOwner: false, isAdmin: true, isMember: true }); + }); + + it("fetches the member only once per call", async () => { + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockGetGuildMember.mockResolvedValue({ roles: [] }); + mockGetGuildRoles.mockResolvedValue([ + { id: "guild-1", name: "@everyone", permissions: "0" }, + ]); + + await getGuildAuthority("guild-1", "user-1"); + + expect(mockGetGuildMember).toHaveBeenCalledTimes(1); + }); +}); +``` + +Add `getGuildAuthority` to the existing import from `../../../src/server/shared/guildAuthz.js` in that test file. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/shared/guildAuthz.test.ts +``` + +Expected: FAIL — `getGuildAuthority is not a function`. + +- [ ] **Step 3: Implement** + +In `apps/dashboard/src/server/shared/guildAuthz.ts`, keep `computeBasePermissions` as-is and replace the exported `isUserGuildAdmin` with: + +```typescript +/** A user's live authority in a guild, from the bot's view of Discord. */ +export interface GuildAuthority { + isOwner: boolean; + /** Owner, Administrator, or Manage Server. */ + isAdmin: boolean; + /** Currently in the guild at all. */ + isMember: boolean; +} + +/** + * Authoritative, LIVE authority check, computed from the bot's view of Discord + * rather than the OAuth session snapshot, so access revoked on Discord is + * honored — subject only to the short discordApi cache TTL. + * + * Answers owner / admin / member in one member fetch, because the delegated + * (non-admin) permission path needs membership and the admin path needs both. + */ +export async function getGuildAuthority( + guildId: string, + userId: string, +): Promise { + const ownerId = await getGuildOwnerId(guildId); + if (ownerId === userId) { + return { isOwner: true, isAdmin: true, isMember: true }; + } + + const member = await getGuildMember(guildId, userId); + if (!member) { + return { isOwner: false, isAdmin: false, isMember: false }; + } + + const roles = await getGuildRoles(guildId); + const perms = computeBasePermissions(guildId, member.roles, roles); + return { + isOwner: false, + isAdmin: canManageGuild(perms.toString()), + isMember: true, + }; +} + +/** + * True when the user currently has admin authority (owner, Administrator, or + * Manage Server) in the guild. Thin wrapper over {@link getGuildAuthority}. + */ +export async function isUserGuildAdmin( + guildId: string, + userId: string, +): Promise { + const { isAdmin } = await getGuildAuthority(guildId, userId); + return isAdmin; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/shared/guildAuthz.test.ts +pnpm typecheck +``` + +Expected: PASS, including the pre-existing `isUserGuildAdmin` tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/shared/guildAuthz.ts apps/dashboard/tests/server/shared/guildAuthz.test.ts +git commit -m "refactor(dashboard): answer owner/admin/member in one authority lookup" +``` + +--- + +### Task 2: Delegated permission resolution + +**Files:** + +- Modify: `apps/dashboard/src/server/shared/permissions.ts:33-150` +- Test: `apps/dashboard/tests/server/shared/permissions-resolve.test.ts` (create) + +**Interfaces:** + +- Consumes: `getGuildAuthority` from Task 1. +- Produces: `ResolvedPermissions` gains `isGuildMember: boolean`. `resolveUserPermissions(userId, guildId)` keeps its signature. + +Resolution table (from the spec): + +| User | `requirePermissions: false` | `requirePermissions: true` | +|---|---|---| +| Owner | `*` | `*` | +| Live admin | `*` | assignments ∪ `isDefault` roles ∪ overrides | +| Member with grants | assignments ∪ overrides | assignments ∪ overrides | +| Member without grants | ∅ | ∅ | +| Non-member | ∅ | ∅ | + +`isDefault` roles are merged **only** on the admin path — that is what stops the toggle from admitting every member at once. + +- [ ] **Step 1: Write the failing tests** + +Create `apps/dashboard/tests/server/shared/permissions-resolve.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "test-token", clientId: "test-client-id", logLevel: "info" }, +})); + +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const mockGetGuildAuthority = vi.fn(); +vi.mock("../../../src/server/shared/guildAuthz.js", () => ({ + getGuildAuthority: (...args: unknown[]) => mockGetGuildAuthority(...args), + isUserGuildAdmin: vi.fn(), +})); + +const mockFindGuildSettings = vi.fn(); +const mockFindAssignments = vi.fn(); +const mockFindDefaultRoles = vi.fn(); +const mockFindUserPermissions = vi.fn(); +vi.mock("@fluxcore/database", () => ({ + getPrisma: () => ({ + dashboardGuildSettings: { findUnique: mockFindGuildSettings }, + dashboardRoleAssignment: { findMany: mockFindAssignments }, + dashboardRole: { findMany: mockFindDefaultRoles }, + dashboardUserPermission: { findMany: mockFindUserPermissions }, + }), +})); + +const { resolveUserPermissions, invalidatePermissionCache } = await import( + "../../../src/server/shared/permissions.js" +); + +const TICKET_ROLE = { + id: "role-1", + permissions: JSON.stringify(["tickets.list.view", "tickets.list.manage"]), +}; + +describe("resolveUserPermissions", () => { + beforeEach(() => { + vi.clearAllMocks(); + invalidatePermissionCache("guild-1"); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: false }); + mockFindAssignments.mockResolvedValue([]); + mockFindDefaultRoles.mockResolvedValue([]); + mockFindUserPermissions.mockResolvedValue([]); + }); + + it("grants the owner everything", async () => { + mockGetGuildAuthority.mockResolvedValue({ isOwner: true, isAdmin: true, isMember: true }); + + const resolved = await resolveUserPermissions("user-1", "guild-1"); + + expect([...resolved.permissions]).toEqual(["*"]); + expect(resolved.isOwner).toBe(true); + expect(resolved.isGuildMember).toBe(true); + }); + + it("grants an admin everything in legacy mode", async () => { + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: true, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: false }); + + const resolved = await resolveUserPermissions("user-1", "guild-1"); + + expect([...resolved.permissions]).toEqual(["*"]); + expect(resolved.isGuildAdmin).toBe(true); + }); + + it("restricts an admin to role grants plus default roles when the system is on", async () => { + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: true, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: true }); + mockFindAssignments.mockResolvedValue([{ roleId: "role-1", role: TICKET_ROLE }]); + mockFindDefaultRoles.mockResolvedValue([ + { id: "role-2", permissions: JSON.stringify(["logging.entries.view"]) }, + ]); + + const resolved = await resolveUserPermissions("user-1", "guild-1"); + + expect([...resolved.permissions].sort()).toEqual([ + "logging.entries.view", + "tickets.list.manage", + "tickets.list.view", + ]); + }); + + it("grants a non-admin member exactly their explicit grants", async () => { + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: true }); + mockFindAssignments.mockResolvedValue([{ roleId: "role-1", role: TICKET_ROLE }]); + mockFindUserPermissions.mockResolvedValue([{ permission: "logging.entries.view" }]); + + const resolved = await resolveUserPermissions("user-1", "guild-1"); + + expect([...resolved.permissions].sort()).toEqual([ + "logging.entries.view", + "tickets.list.view", + "tickets.list.manage", + ].sort()); + expect(resolved.isGuildAdmin).toBe(false); + expect(resolved.isGuildMember).toBe(true); + }); + + it("grants a non-admin member their grants in legacy mode too", async () => { + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: false }); + mockFindAssignments.mockResolvedValue([{ roleId: "role-1", role: TICKET_ROLE }]); + + const resolved = await resolveUserPermissions("user-1", "guild-1"); + + expect([...resolved.permissions].sort()).toEqual([ + "tickets.list.manage", + "tickets.list.view", + ]); + }); + + it("never applies default roles to a non-admin member", async () => { + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: true }); + mockFindDefaultRoles.mockResolvedValue([ + { id: "role-2", permissions: JSON.stringify(["logging.entries.view"]) }, + ]); + + const resolved = await resolveUserPermissions("user-1", "guild-1"); + + expect(resolved.permissions.size).toBe(0); + }); + + it("gives a non-member nothing even with a stale grant row", async () => { + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: false }); + mockFindAssignments.mockResolvedValue([{ roleId: "role-1", role: TICKET_ROLE }]); + + const resolved = await resolveUserPermissions("user-1", "guild-1"); + + expect(resolved.permissions.size).toBe(0); + expect(resolved.isGuildMember).toBe(false); + expect(mockFindAssignments).not.toHaveBeenCalled(); + }); + + it("tolerates a role whose permissions column is not valid JSON", async () => { + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: true }); + mockFindAssignments.mockResolvedValue([ + { roleId: "role-1", role: { id: "role-1", permissions: "not json" } }, + ]); + + const resolved = await resolveUserPermissions("user-1", "guild-1"); + + expect(resolved.permissions.size).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/shared/permissions-resolve.test.ts +``` + +Expected: FAIL — `getGuildAuthority` is not called (the module still imports `isUserGuildAdmin`), and `isGuildMember` is undefined. + +- [ ] **Step 3: Implement** + +In `apps/dashboard/src/server/shared/permissions.ts`: + +Replace the imports of `getGuildOwnerId` / `isUserGuildAdmin` with: + +```typescript +import { getGuildAuthority } from "./guildAuthz.js"; +``` + +(`getGuildOwnerId` is no longer needed here — `getGuildAuthority` answers ownership.) + +Extend the cache entry and the result type with `isGuildMember`: + +```typescript +interface CachedPermissions { + permissions: Set; + isOwner: boolean; + isGuildAdmin: boolean; + isGuildMember: boolean; + expiresAt: number; +} + +export interface ResolvedPermissions { + permissions: Set; + isOwner: boolean; + /** Whether the user currently has live Discord admin authority in the guild. */ + isGuildAdmin: boolean; + /** Whether the user is currently in the guild at all. */ + isGuildMember: boolean; +} +``` + +Carry `isGuildMember` through `cacheResult` and through the cache-hit early return. + +Replace the body of `resolveUserPermissions` after the cache lookup with: + +```typescript + const authority = await getGuildAuthority(guildId, userId); + + if (authority.isOwner) { + return cacheResult(key, { + permissions: new Set(["*"]), + isOwner: true, + isGuildAdmin: true, + isGuildMember: true, + }); + } + + // Not in the guild → no authority, and no reason to read grant rows. + if (!authority.isMember) { + return cacheResult(key, { + permissions: new Set(), + isOwner: false, + isGuildAdmin: false, + isGuildMember: false, + }); + } + + const prisma = getPrisma(); + + // `requirePermissions` governs whether ADMINS are constrained. It never gates + // explicit grants, which resolve the same way in both modes. + if (authority.isAdmin) { + const guildSettings = await prisma.dashboardGuildSettings.findUnique({ + where: { guildId }, + }); + if (!guildSettings?.requirePermissions) { + return cacheResult(key, { + permissions: new Set(["*"]), + isOwner: false, + isGuildAdmin: true, + isGuildMember: true, + }); + } + } + + const permissions = await loadGrantedPermissions(guildId, userId, { + // Default roles are an admin baseline only. Applying them to every member + // would turn the requirePermissions toggle into a server-wide grant. + includeDefaultRoles: authority.isAdmin, + }); + + return cacheResult(key, { + permissions, + isOwner: false, + isGuildAdmin: authority.isAdmin, + isGuildMember: true, + }); +} + +/** + * Merge a user's dashboard role permissions and per-user overrides into one set. + */ +async function loadGrantedPermissions( + guildId: string, + userId: string, + options: { includeDefaultRoles: boolean }, +): Promise> { + const prisma = getPrisma(); + + const assignments = await prisma.dashboardRoleAssignment.findMany({ + where: { guildId, userId }, + include: { role: true }, + }); + + const defaultRoles = options.includeDefaultRoles + ? await prisma.dashboardRole.findMany({ where: { guildId, isDefault: true } }) + : []; + + const allRoles = [ + ...assignments.map((a) => a.role), + ...defaultRoles.filter((dr) => !assignments.some((a) => a.roleId === dr.id)), + ]; + + const permissions = new Set(); + for (const role of allRoles) { + for (const perm of safeJsonParse(role.permissions, [])) { + permissions.add(perm); + } + } + + const userPerms = await prisma.dashboardUserPermission.findMany({ + where: { guildId, userId }, + }); + for (const up of userPerms) { + permissions.add(up.permission); + } + + return permissions; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/shared/permissions-resolve.test.ts +pnpm typecheck +``` + +Expected: PASS. `pnpm typecheck` may now flag other call sites constructing `ResolvedPermissions` literals — fix them by adding `isGuildMember`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/shared/permissions.ts apps/dashboard/tests/server/shared/permissions-resolve.test.ts +git commit -m "feat(permissions): resolve explicit grants for non-admin guild members" +``` + +--- + +### Task 3: `requireGuildAccess` — gate on permissions, not admin-ness + +**Files:** + +- Modify: `apps/dashboard/src/server/shared/middleware.ts:58-89` +- Modify (mechanical rename): all 21 files under `apps/dashboard/src/server/features/*/` that import `requireGuildAdmin` +- Test: `apps/dashboard/tests/server/shared/middleware.test.ts` + +**Interfaces:** + +- Consumes: `resolveUserPermissions` (Task 2). +- Produces: `requireGuildAccess(request, reply)` replaces `requireGuildAdmin`. No other export changes. + +- [ ] **Step 1: Write the failing tests** + +In `apps/dashboard/tests/server/shared/middleware.test.ts`, rename the import and the `describe("requireGuildAdmin")` block to `requireGuildAccess`, add `isGuildMember: true` to the default `mockResolveUserPermissions` value in `beforeEach`, and add these cases inside that block: + +```typescript + it("allows a non-admin member holding explicit grants", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["tickets.list.view"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + const request = createMockRequest({ + session: { userId: "user-1" }, + params: { guildId: "guild-1" }, + }); + const reply = createMockReply(); + + await requireGuildAccess(request, reply); + + expect(reply.code).not.toHaveBeenCalled(); + expect(request.resolvedPermissions?.permissions.has("tickets.list.view")).toBe(true); + }); + + it("rejects a member holding no grants", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + const request = createMockRequest({ + session: { userId: "user-1" }, + params: { guildId: "guild-1" }, + }); + const reply = createMockReply(); + + await requireGuildAccess(request, reply); + + expect(reply.code).toHaveBeenCalledWith(403); + }); + + it("rejects a non-member", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(), + isOwner: false, + isGuildAdmin: false, + isGuildMember: false, + }); + const request = createMockRequest({ + session: { userId: "user-1" }, + params: { guildId: "guild-1" }, + }); + const reply = createMockReply(); + + await requireGuildAccess(request, reply); + + expect(reply.code).toHaveBeenCalledWith(403); + }); +``` + +The existing cases (bot not in guild → 403 `botNotInGuild`; admin → pass) stay and must keep passing. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/shared/middleware.test.ts +``` + +Expected: FAIL — `requireGuildAccess` is not exported. + +- [ ] **Step 3: Implement — rename and re-gate** + +In `apps/dashboard/src/server/shared/middleware.ts`, replace `requireGuildAdmin` with: + +```typescript +/** + * Gate for every guild-scoped route: does this user have ANY authority here? + * + * Authority comes from three places — guild ownership, live Discord admin + * authority, or explicit dashboard grants (a dashboard role assignment or a + * per-user override). A member with grants but no MANAGE_GUILD passes here and + * is then narrowed by `requirePermission` on each route. + */ +export async function requireGuildAccess( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const { guildId } = request.params as { guildId: string }; + const session = request.session!; + + if (!(await isBotInGuild(guildId))) { + reply.code(403).send({ + error: request.t("errors:permissions.botNotInGuild"), + errorKey: "errors:permissions.botNotInGuild", + }); + return; + } + + // Authorize from LIVE Discord authority + DB grants, not the cached OAuth + // session snapshot — so access revoked on Discord is honored here. + const resolved = await resolveUserPermissions(session.userId, guildId); + const authorized = + resolved.isOwner || resolved.isGuildAdmin || resolved.permissions.size > 0; + if (!authorized) { + reply.code(403).send({ + error: request.t("errors:permissions.noGuildPermission"), + errorKey: "errors:permissions.noGuildPermission", + }); + return; + } + + request.resolvedPermissions = resolved; +} +``` + +Then rename every call site: + +```bash +grep -rl "requireGuildAdmin" apps/dashboard/src apps/dashboard/tests \ + | xargs sed -i 's/requireGuildAdmin/requireGuildAccess/g' +grep -rn "requireGuildAdmin" apps/dashboard || echo "no references left" +``` + +- [ ] **Step 4: Run the full dashboard suite** + +```bash +pnpm --filter @fluxcore/dashboard test +pnpm typecheck +``` + +Expected: PASS. Every route test that mocked `resolveUserPermissions` with `permissions: new Set(["*"])` still passes, since a non-empty set authorizes. + +- [ ] **Step 5: Commit** + +```bash +git add -A apps/dashboard +git commit -m "feat(permissions): gate guild routes on any authority, not admin-ness" +``` + +--- + +### Task 4: `dashboard.lookups.view` on the Discord passthrough routes + +The four routes in `discord/routes.ts` carry no permission key, so after Task 3 anyone with any grant could call them. They return channel, role, and member names that nearly every picker needs. + +**Files:** + +- Modify: `apps/dashboard/src/server/features/discord/routes.ts` (4 route definitions) +- Modify: `packages/types/src/dashboard-permissions.ts` (add the key to the registry's `dashboard` module and to every preset) +- Test: `apps/dashboard/tests/server/features/discord/discord.test.ts` + +**Interfaces:** + +- Consumes: `requirePermission` from `../../shared/middleware.js` (already imported? if not, add it). +- Produces: permission key `dashboard.lookups.view`, enforced on `GET /api/guilds/:guildId/members`, `/channels`, `/roles`, and `POST /api/guilds/:guildId/refresh`. + +- [ ] **Step 1: Write the failing test** + +Add to `apps/dashboard/tests/server/features/discord/discord.test.ts` (follow the mocking already at the top of that file; make `hasPermission` controllable if it is currently a fixed `true` stub): + +```typescript + it("returns 403 when the caller lacks dashboard.lookups.view", async () => { + mockHasPermission.mockReturnValue(false); + mockGetSession.mockResolvedValue({ userId: "user-1", username: "u", guilds: [] }); + + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/channels", + cookies: { session: "sid" }, + }); + + expect(res.statusCode).toBe(403); + }); +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/features/discord/discord.test.ts +``` + +Expected: FAIL — returns 200 because no permission is required. + +- [ ] **Step 3: Implement** + +In `apps/dashboard/src/server/features/discord/routes.ts`, import `requirePermission` and change each of the four `preHandler` arrays: + +```typescript +preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.lookups.view")], +``` + +In `packages/types/src/dashboard-permissions.ts`, add to the `dashboard` module's `permissions` array: + +```typescript + { key: "dashboard.lookups.view", label: "Use Pickers", description: "Look up channels, roles, and members for pickers" }, +``` + +and add `"dashboard.lookups.view"` to the `permissions` array of the `moderator` and `content-manager` presets (`full-admin` has `*` and `viewer` matches `*.*.view`, so both already cover it). + +- [ ] **Step 4: Run the tests** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/features/discord/discord.test.ts +pnpm --filter @fluxcore/dashboard test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/discord/routes.ts packages/types/src/dashboard-permissions.ts apps/dashboard/tests/server/features/discord/discord.test.ts +git commit -m "feat(permissions): require dashboard.lookups.view for Discord passthroughs" +``` + +--- + +### Task 5: Close the role-assignment escalation hole + +Escalation guards already exist on role create (`roles-routes.ts:129`), role update (`:250`), preset create (`:555`), and user overrides (`routes.ts:161`). `POST /dashboard-roles/:roleId/members` has none — a `dashboard.roles.manage` holder can assign themselves an existing Full Admin role. + +**Files:** + +- Modify: `apps/dashboard/src/server/features/permissions/roles-routes.ts` (the `POST .../members` handler, around line 408) +- Test: `apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts` + +**Interfaces:** + +- Consumes: `matchPermission` from `@fluxcore/types` (already imported in this file); `request.resolvedPermissions` from Task 3. +- Produces: no new exports. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts`, in the assignment describe block: + +```typescript + it("refuses to assign a role holding permissions the caller lacks", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + mockRoleFindUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Full Admin", + permissions: JSON.stringify(["*"]), + }); + + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/dashboard-roles/role-1/members", + cookies: { session: "sid" }, + payload: { userId: "user-2" }, + }); + + expect(res.statusCode).toBe(403); + expect(mockAssignmentCreate).not.toHaveBeenCalled(); + }); + + it("refuses to assign any role to yourself", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage", "tickets.list.view"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + mockRoleFindUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Ticket Staff", + permissions: JSON.stringify(["tickets.list.view"]), + }); + + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/dashboard-roles/role-1/members", + cookies: { session: "sid" }, + payload: { userId: "user-1" }, + }); + + expect(res.statusCode).toBe(403); + expect(mockAssignmentCreate).not.toHaveBeenCalled(); + }); + + it("lets the owner assign anything, including to themselves", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + isGuildAdmin: true, + isGuildMember: true, + }); + mockRoleFindUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Full Admin", + permissions: JSON.stringify(["*"]), + }); + + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/dashboard-roles/role-1/members", + cookies: { session: "sid" }, + payload: { userId: "user-1" }, + }); + + expect(res.statusCode).toBe(201); + }); + + it("allows assigning a role whose permissions the caller holds", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage", "tickets.*"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + mockRoleFindUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Ticket Staff", + permissions: JSON.stringify(["tickets.list.view"]), + }); + + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/dashboard-roles/role-1/members", + cookies: { session: "sid" }, + payload: { userId: "user-2" }, + }); + + expect(res.statusCode).toBe(201); + }); +``` + +Use whatever mock names that file already defines for `prisma.dashboardRole.findUnique` and `prisma.dashboardRoleAssignment.create`; the names above (`mockRoleFindUnique`, `mockAssignmentCreate`) are the expected ones — match the file. + +- [ ] **Step 2: Run them to verify they fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/features/permissions/dashboardRoles.test.ts +``` + +Expected: FAIL — assignment returns 201 in the first two cases. + +- [ ] **Step 3: Implement** + +In the `POST .../members` handler, immediately after the existing `role`/404 check and before the `try` block: + +```typescript + // Assignment grants everything the role holds, so it is an escalation + // vector in its own right: without this a `dashboard.roles.manage` holder + // could hand themselves an existing Full Admin role. + if (!request.resolvedPermissions?.isOwner) { + if (userId === session.userId) { + reply.code(403).send({ error: "Cannot assign a role to yourself" }); + return; + } + + const callerPerms = request.resolvedPermissions!.permissions; + const rolePerms = safeParsePermissions(role.permissions); + for (const perm of rolePerms) { + if (!matchPermission(callerPerms, perm)) { + reply.code(403).send({ + error: "Cannot grant permissions you don't have", + permission: perm, + }); + return; + } + } + } +``` + +Add this helper at the bottom of the file, next to `isValidPermissionKey`: + +```typescript +function safeParsePermissions(json: string): string[] { + try { + const parsed: unknown = JSON.parse(json); + return Array.isArray(parsed) + ? parsed.filter((p): p is string => typeof p === "string") + : []; + } catch { + return []; + } +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/features/permissions/dashboardRoles.test.ts +pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/permissions/roles-routes.ts apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts +git commit -m "fix(permissions): block privilege escalation via role assignment" +``` + +--- + +### Task 6: `/api/guilds` returns delegated guilds + +**Files:** + +- Modify: `apps/dashboard/src/server/features/guilds/routes.ts:9-58` +- Test: `apps/dashboard/tests/server/features/guilds/guilds.test.ts` + +**Interfaces:** + +- Consumes: `getPrisma` from `@fluxcore/database`. +- Produces: each guild object gains `access: "admin" | "delegated"`. Response schema updated so Fastify does not strip the new field. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/dashboard/tests/server/features/guilds/guilds.test.ts`. The file currently mocks `@fluxcore/database`? If not, add this mock next to the others at the top: + +```typescript +const mockAssignmentFindMany = vi.fn().mockResolvedValue([]); +const mockUserPermissionFindMany = vi.fn().mockResolvedValue([]); +vi.mock("@fluxcore/database", () => ({ + getPrisma: () => ({ + dashboardRoleAssignment: { findMany: mockAssignmentFindMany }, + dashboardUserPermission: { findMany: mockUserPermissionFindMany }, + }), +})); +``` + +Tests: + +```typescript + it("includes a guild the user cannot manage but holds a dashboard grant in", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [ + { id: "g1", name: "Guild 1", icon: null, permissions: "0" }, + { id: "g2", name: "Guild 2", icon: null, permissions: "0" }, + ], + }); + mockAssignmentFindMany.mockResolvedValueOnce([{ guildId: "g1" }]); + + const res = await app.inject({ method: "GET", url: "/api/guilds", cookies: { session: "sid" } }); + + expect(res.statusCode).toBe(200); + expect(res.json>()).toEqual([ + expect.objectContaining({ id: "g1", access: "delegated" }), + ]); + }); + + it("includes a guild granted only through a per-user override", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [{ id: "g1", name: "Guild 1", icon: null, permissions: "0" }], + }); + mockUserPermissionFindMany.mockResolvedValueOnce([{ guildId: "g1" }]); + + const res = await app.inject({ method: "GET", url: "/api/guilds", cookies: { session: "sid" } }); + + expect(res.json>()).toHaveLength(1); + }); + + it("marks manageable guilds as admin access", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [{ id: "g1", name: "Guild 1", icon: null, permissions: MANAGE_GUILD.toString() }], + }); + + const res = await app.inject({ method: "GET", url: "/api/guilds", cookies: { session: "sid" } }); + + expect(res.json>()[0].access).toBe("admin"); + }); + + it("ignores a grant for a guild the user is no longer in", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [{ id: "g1", name: "Guild 1", icon: null, permissions: "0" }], + }); + mockAssignmentFindMany.mockResolvedValueOnce([{ guildId: "g-gone" }]); + + const res = await app.inject({ method: "GET", url: "/api/guilds", cookies: { session: "sid" } }); + + expect(res.json>()).toEqual([]); + }); + + it("counts a guild once when the user is both an admin and a grantee", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [{ id: "g1", name: "Guild 1", icon: null, permissions: MANAGE_GUILD.toString() }], + }); + mockAssignmentFindMany.mockResolvedValueOnce([{ guildId: "g1" }]); + + const res = await app.inject({ method: "GET", url: "/api/guilds", cookies: { session: "sid" } }); + + expect(res.json>()).toEqual([ + expect.objectContaining({ id: "g1", access: "admin" }), + ]); + }); +``` + +Use `res.json()` with the generic — never a cast. + +- [ ] **Step 2: Run them to verify they fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/features/guilds/guilds.test.ts +``` + +Expected: FAIL — delegated guilds are filtered out; `access` is undefined. + +- [ ] **Step 3: Implement** + +In `apps/dashboard/src/server/features/guilds/routes.ts`, add `import { getPrisma } from "@fluxcore/database";` and replace `buildManageableGuilds`: + +```typescript +/** + * Guild IDs where this user holds an explicit dashboard grant — a role + * assignment or a per-user override. These admit a user who has no Discord + * MANAGE_GUILD at all. + */ +async function guildIdsWithGrants(userId: string): Promise> { + const prisma = getPrisma(); + const [assignments, overrides] = await Promise.all([ + prisma.dashboardRoleAssignment.findMany({ + where: { userId }, + select: { guildId: true }, + distinct: ["guildId"], + }), + prisma.dashboardUserPermission.findMany({ + where: { userId }, + select: { guildId: true }, + distinct: ["guildId"], + }), + ]); + + return new Set([ + ...assignments.map((a) => a.guildId), + ...overrides.map((o) => o.guildId), + ]); +} + +/** + * Filter the user's OAuth guilds down to the ones they can open in the + * dashboard: they own it, have Administrator/Manage Server, or hold an explicit + * dashboard grant there. + * + * Intersecting grants with the OAuth guild list is also the membership check — + * a grant row for a guild the user has left cannot resurface it. + * + * Guilds the bot has NOT been added to are included, flagged with + * `botPresent: false`, so the dashboard can offer a preselected invite instead + * of hiding them. This grants no access on its own — `requireGuildAccess` still + * rejects guild-scoped requests with `botNotInGuild`. + * + * Bot-present guilds sort first so the actionable cards lead the grid. + */ +async function buildManageableGuilds(userId: string, guilds: OAuthGuild[]) { + const grantedIds = await guildIdsWithGrants(userId); + + const visible = guilds + .map((guild) => ({ + guild, + isAdmin: guild.owner || canManageGuild(guild.permissions), + })) + .filter((entry) => entry.isAdmin || grantedIds.has(entry.guild.id)); + + const checks = await Promise.all( + visible.map(async (entry) => ({ + ...entry, + botPresent: await isBotInGuild(entry.guild.id), + })), + ); + + return checks + .map((c) => ({ + id: c.guild.id, + name: c.guild.name, + icon: c.guild.icon, + botPresent: c.botPresent, + access: c.isAdmin ? "admin" : "delegated", + })) + .sort( + (a, b) => + Number(b.botPresent) - Number(a.botPresent) || + a.name.localeCompare(b.name), + ); +} +``` + +Add `access: { type: "string" }` to `guildListResponseSchema`'s item properties, and update both call sites: + +```typescript + reply.send(await buildManageableGuilds(session.userId, session.guilds)); +``` + +```typescript + const guilds = await forceRefreshSessionGuilds(request.sessionId!); + reply.send(await buildManageableGuilds(request.session!.userId, guilds)); +``` + +- [ ] **Step 4: Run the tests** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/features/guilds/ +pnpm typecheck +``` + +Expected: PASS, including the pre-existing "excludes guilds the user cannot manage" test (no grants mocked → still excluded). + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/guilds/routes.ts apps/dashboard/tests/server/features/guilds/guilds.test.ts +git commit -m "feat(guilds): list guilds where the user holds dashboard grants" +``` + +--- + +### Task 7: Servers page shows delegated guilds + +**Files:** + +- Modify: `apps/dashboard/src/client/shared/lib/schemas.ts:12-19` +- Modify: `apps/dashboard/src/client/shared/components/GuildCard.tsx` +- Modify: `packages/i18n/src/locales/en/guilds.json` (+ 47 locales) +- Test: `apps/dashboard/tests/client/shared/components/GuildCard.test.tsx` (create) + +**Interfaces:** + +- Consumes: `access` field from Task 6. +- Produces: `Guild` type gains `access: "admin" | "delegated"`. + +**Client test convention** (used by every existing client test, e.g. `tests/client/features/tempvoice/HubCard.test.tsx`): declare `// @vitest-environment jsdom` on line 1, mock `react-i18next` with an identity `t`, mock hooks directly rather than wrapping in providers, and plain `render`. Because `t` returns its key, assertions match **i18n keys**, not English. + +- [ ] **Step 1: Write the failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children }: { children: React.ReactNode }) => {children}, +})); + +import { GuildCard } from "../../../../src/client/shared/components/GuildCard"; + +const baseGuild = { id: "g1", name: "Guild 1", icon: null, botPresent: true }; + +describe("GuildCard", () => { + it("badges a delegated guild", () => { + render(); + expect(screen.getByText("badge.delegated")).toBeInTheDocument(); + }); + + it("does not badge an admin guild", () => { + render(); + expect(screen.queryByText("badge.delegated")).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/client/GuildCard.test.tsx +``` + +Expected: FAIL — no badge rendered, and a type error on `access`. + +- [ ] **Step 3: Implement** + +`schemas.ts`: + +```typescript +export const GuildSchema = z.object({ + id: z.string(), + name: z.string(), + icon: z.string().nullable(), + /** False when the user administers the guild but the bot has not been added. */ + botPresent: z.boolean(), + /** "delegated" = access comes from dashboard grants, not Discord admin rights. */ + access: z.enum(["admin", "delegated"]), +}); +``` + +`GuildCard.tsx` — in the bot-present branch, under the `

`: + +```tsx + {guild.access === "delegated" && ( +
+ {t("badge.delegated")} +
+ )} +``` + +`GuildCard` currently has no `useTranslation` in its main export — add `const { t } = useTranslation("guilds");` there. + +`packages/i18n/src/locales/en/guilds.json` — add under `badge`: + +```json + "delegated": "Delegated access" +``` + +Translate that one key in the other 47 locales (see the i18n procedure in Task 12, Step 3). + +- [ ] **Step 4: Run the tests** + +```bash +pnpm --filter @fluxcore/dashboard test +pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client/shared/lib/schemas.ts apps/dashboard/src/client/shared/components/GuildCard.tsx apps/dashboard/tests/client/GuildCard.test.tsx packages/i18n/src/locales +git commit -m "feat(guilds): badge servers reached through delegated access" +``` + +--- + +### Task 8: Collect permission keys from the route table + +**Files:** + +- Modify: `apps/dashboard/src/server/shared/middleware.ts` (self-registration in `requirePermission`) +- Create: `apps/dashboard/src/server/shared/permissionRegistry.ts` +- Modify: `apps/dashboard/src/server/index.ts` (call the validator after route registration) +- Test: `apps/dashboard/tests/server/shared/permissionRegistry.test.ts` (create) + +**Interfaces:** + +- Produces: + - `getDeclaredPermissions(): ReadonlySet` from `middleware.js` + - `buildPermissionRegistry(keys: ReadonlySet): PermissionModuleView[]` from `permissionRegistry.js` + - `validatePermissionRegistry(keys: ReadonlySet): void` — throws on an unknown module or a malformed key + - `interface PermissionModuleView { key: string; icon: string; labelKey: string; permissions: PermissionView[] }` + - `interface PermissionView { key: string; resourceKey: string; actionKey: string }` + +Labels are i18n keys, not English text — the client translates them (Task 10). + +- [ ] **Step 1: Write the failing tests** + +Create `apps/dashboard/tests/server/shared/permissionRegistry.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { + buildPermissionRegistry, + validatePermissionRegistry, +} from "../../../src/server/shared/permissionRegistry.js"; + +describe("buildPermissionRegistry", () => { + it("groups keys by module in MODULE_META order", () => { + const registry = buildPermissionRegistry( + new Set(["tickets.list.view", "dashboard.roles.view", "tickets.panels.manage"]), + ); + + expect(registry.map((m) => m.key)).toEqual(["dashboard", "tickets"]); + expect(registry[1].permissions.map((p) => p.key)).toEqual([ + "tickets.list.view", + "tickets.panels.manage", + ]); + }); + + it("exposes i18n keys rather than English labels", () => { + const [mod] = buildPermissionRegistry(new Set(["tickets.list.view"])); + + expect(mod.labelKey).toBe("permissions:permissionCategories.tickets"); + expect(mod.permissions[0]).toEqual({ + key: "tickets.list.view", + resourceKey: "permissions:resources.list", + actionKey: "permissions:permissionActions.view", + }); + }); + + it("carries the module icon", () => { + const [mod] = buildPermissionRegistry(new Set(["moderation.cases.view"])); + expect(mod.icon).toBe("Shield"); + }); +}); + +describe("validatePermissionRegistry", () => { + it("accepts well-formed keys in known modules", () => { + expect(() => + validatePermissionRegistry(new Set(["tickets.list.view"])), + ).not.toThrow(); + }); + + it("rejects a key whose module has no metadata", () => { + expect(() => + validatePermissionRegistry(new Set(["quests.list.view"])), + ).toThrow(/quests/); + }); + + it("rejects a key that is not module.resource.action", () => { + expect(() => + validatePermissionRegistry(new Set(["tickets.view"])), + ).toThrow(/tickets\.view/); + }); + + it("rejects an unknown action verb", () => { + expect(() => + validatePermissionRegistry(new Set(["tickets.list.obliterate"])), + ).toThrow(/obliterate/); + }); +}); +``` + +Add to `apps/dashboard/tests/server/shared/middleware.test.ts`: + +```typescript + describe("getDeclaredPermissions", () => { + it("records every key passed to requirePermission", () => { + requirePermission("tickets.list.view", "tickets.list.manage"); + + const declared = getDeclaredPermissions(); + + expect(declared.has("tickets.list.view")).toBe(true); + expect(declared.has("tickets.list.manage")).toBe(true); + }); + }); +``` + +- [ ] **Step 2: Run them to verify they fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/shared/permissionRegistry.test.ts tests/server/shared/middleware.test.ts +``` + +Expected: FAIL — module not found / `getDeclaredPermissions` not exported. + +- [ ] **Step 3: Implement** + +In `middleware.ts`, above `requirePermission`: + +```typescript +/** + * Every permission key any route enforces. Populated when `requirePermission` + * runs at route-registration time, which makes the route table — not a + * hand-maintained list — the source of truth for what permissions exist. + */ +const declaredPermissions = new Set(); + +export function getDeclaredPermissions(): ReadonlySet { + return declaredPermissions; +} +``` + +and record the keys inside the factory, before the returned handler: + +```typescript +export function requirePermission(...keys: string[]) { + for (const key of keys) declaredPermissions.add(key); + + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + // ...unchanged... + }; +} +``` + +Create `apps/dashboard/src/server/shared/permissionRegistry.ts`: + +```typescript +/** + * The permission registry is derived from the route table: `requirePermission` + * records each key it enforces, and this module turns that flat set into the + * module → permission tree the dashboard renders. A key therefore cannot appear + * in the UI without a route enforcing it, nor the reverse. + * + * Only presentation lives here. Labels are i18n keys; the client translates. + */ + +/** Display metadata per module. Order is the order modules render in. */ +const MODULE_META: Record = { + dashboard: { icon: "LayoutDashboard", order: 0 }, + moderation: { icon: "Shield", order: 1 }, + actions: { icon: "Zap", order: 2 }, + logging: { icon: "ScrollText", order: 3 }, + welcome: { icon: "Hand", order: 4 }, + leveling: { icon: "TrendingUp", order: 5 }, + tickets: { icon: "Ticket", order: 6 }, + giveaways: { icon: "Gift", order: 7 }, + starboard: { icon: "Star", order: 8 }, + suggestions: { icon: "Lightbulb", order: 9 }, + roles: { icon: "Badge", order: 10 }, + tempvoice: { icon: "Mic", order: 11 }, + security: { icon: "ShieldAlert", order: 12 }, + scheduled: { icon: "Clock", order: 13 }, + commands: { icon: "Terminal", order: 14 }, +}; + +/** Action verbs the key convention allows. */ +const ACTIONS = new Set(["view", "manage", "execute", "purge"]); + +export interface PermissionView { + key: string; + /** i18n key for the resource noun, e.g. "Cases". */ + resourceKey: string; + /** i18n key for the action verb, e.g. "View". */ + actionKey: string; +} + +export interface PermissionModuleView { + key: string; + icon: string; + /** i18n key for the module name. */ + labelKey: string; + permissions: PermissionView[]; +} + +export function buildPermissionRegistry( + keys: ReadonlySet, +): PermissionModuleView[] { + const byModule = new Map(); + + for (const key of [...keys].sort()) { + const [module, resource, action] = key.split("."); + if (!module || !resource || !action) continue; + + const views = byModule.get(module) ?? []; + views.push({ + key, + resourceKey: `permissions:resources.${resource}`, + actionKey: `permissions:permissionActions.${action}`, + }); + byModule.set(module, views); + } + + return [...byModule.entries()] + .filter(([module]) => module in MODULE_META) + .sort(([a], [b]) => MODULE_META[a].order - MODULE_META[b].order) + .map(([module, permissions]) => ({ + key: module, + icon: MODULE_META[module].icon, + labelKey: `permissions:permissionCategories.${module}`, + permissions, + })); +} + +/** + * Fail fast when a route declares a key the UI could never render — an unknown + * module, a malformed key, or an unknown action verb. Called once at boot. + */ +export function validatePermissionRegistry(keys: ReadonlySet): void { + const problems: string[] = []; + + for (const key of keys) { + const parts = key.split("."); + if (parts.length !== 3 || parts.some((p) => p.length === 0)) { + problems.push(`"${key}" is not module.resource.action`); + continue; + } + const [module, , action] = parts; + if (!(module in MODULE_META)) { + problems.push(`"${key}" has no MODULE_META entry for module "${module}"`); + } + if (!ACTIONS.has(action)) { + problems.push(`"${key}" uses unknown action "${action}"`); + } + } + + if (problems.length > 0) { + throw new Error(`Invalid permission registry:\n ${problems.join("\n ")}`); + } +} +``` + +In `apps/dashboard/src/server/index.ts`, after the last `register*Routes(app)` call (line ~179): + +```typescript + // Routes are registered, so every requirePermission() has run — the declared + // key set is now complete and can be checked. + validatePermissionRegistry(getDeclaredPermissions()); +``` + +with imports from `./shared/permissionRegistry.js` and `./shared/middleware.js`. + +- [ ] **Step 4: Run the tests** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/shared/ +pnpm --filter @fluxcore/dashboard test +pnpm typecheck +``` + +Expected: PASS. If `validatePermissionRegistry` throws while building the app in `tests/server/index.test.ts`, a real route is declaring a bad key — fix the route, not the validator. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/shared/middleware.ts apps/dashboard/src/server/shared/permissionRegistry.ts apps/dashboard/src/server/index.ts apps/dashboard/tests/server/shared/permissionRegistry.test.ts apps/dashboard/tests/server/shared/middleware.test.ts +git commit -m "feat(permissions): derive the permission registry from the route table" +``` + +--- + +### Task 9: Retire the static registry + +`ALL_PERMISSION_KEYS`, `expandWildcard`, and `resolveEffectivePermissions` all read the static registry. They become pure functions over a caller-supplied vocabulary. + +**Files:** + +- Modify: `packages/types/src/dashboard-permissions.ts` (delete `PERMISSION_REGISTRY`, `ALL_PERMISSION_KEYS`, `PermissionDefinition`, `PermissionModule`; re-signature two helpers) +- Modify: `packages/types/src/index.ts` (export list) +- Modify: `apps/dashboard/src/server/features/permissions/routes.ts` (2 call sites + `isValidPermKey` + the registry endpoint) +- Modify: `apps/dashboard/src/server/features/permissions/roles-routes.ts` (`isValidPermissionKey`) +- Test: `apps/dashboard/tests/server/shared/permissions.test.ts` + +**Interfaces:** + +- Consumes: `getDeclaredPermissions`, `buildPermissionRegistry` (Task 8). +- Produces: + - `expandWildcard(pattern: string, allKeys: readonly string[]): string[]` + - `resolveEffectivePermissions(granted: string[], allKeys: readonly string[]): string[]` + - `ROLE_PRESETS` and `matchPermission` unchanged. + +- [ ] **Step 1: Update the tests first** + +In `apps/dashboard/tests/server/shared/permissions.test.ts`: + +- Drop the `PERMISSION_REGISTRY` / `ALL_PERMISSION_KEYS` imports and the `describe("PERMISSION_REGISTRY")` block. +- Define a local vocabulary and pass it through: + +```typescript +const KEYS = [ + "moderation.cases.view", + "moderation.cases.manage", + "moderation.settings.manage", + "actions.rules.view", + "tickets.list.view", +]; + +describe("expandWildcard", () => { + it("expands * to every key", () => { + expect(expandWildcard("*", KEYS)).toEqual(KEYS); + }); + + it("expands a module wildcard", () => { + expect(expandWildcard("moderation.*", KEYS)).toEqual([ + "moderation.cases.view", + "moderation.cases.manage", + "moderation.settings.manage", + ]); + }); + + it("expands a cross-module action wildcard", () => { + expect(expandWildcard("*.*.view", KEYS)).toEqual([ + "moderation.cases.view", + "actions.rules.view", + "tickets.list.view", + ]); + }); +}); + +describe("resolveEffectivePermissions", () => { + it("returns nothing for an empty grant", () => { + expect(resolveEffectivePermissions([], KEYS)).toEqual([]); + }); + + it("merges wildcards and literals", () => { + expect(resolveEffectivePermissions(["moderation.*", "actions.rules.view"], KEYS)).toEqual([ + "moderation.cases.view", + "moderation.cases.manage", + "moderation.settings.manage", + "actions.rules.view", + ]); + }); +}); +``` + +Add a drift test — this is the check that replaces the deleted registry test. It scans the route +sources rather than booting the app: the only app factory is `createApp()`, which connects to the +database and calls `process.exit` on missing config, so it is not usable from a unit test. Scanning +is also the honest check here — it reads the same `requirePermission(...)` calls the runtime set is +built from, without needing 21 modules' worth of mocks. + +Create `apps/dashboard/tests/server/shared/permissionDrift.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ROLE_PRESETS } from "@fluxcore/types"; +import { navItems } from "../../../src/client/shared/lib/navigation.js"; + +const FEATURES_DIR = join(__dirname, "../../../src/server/features"); + +/** Every permission key enforced by a requirePermission(...) call in a route file. */ +function declaredKeysFromSource(): Set { + const keys = new Set(); + const callPattern = /requirePermission\(([^)]*)\)/g; + const literalPattern = /"([^"]+)"/g; + + for (const feature of readdirSync(FEATURES_DIR)) { + const dir = join(FEATURES_DIR, feature); + for (const file of readdirSync(dir)) { + if (!file.endsWith(".ts")) continue; + const source = readFileSync(join(dir, file), "utf8"); + for (const call of source.matchAll(callPattern)) { + for (const literal of call[1].matchAll(literalPattern)) { + keys.add(literal[1]); + } + } + } + } + return keys; +} + +describe("permission drift", () => { + it("finds permission keys to check", () => { + expect(declaredKeysFromSource().size).toBeGreaterThan(40); + }); + + it("enforces every permission the sidebar navigates by", () => { + const declared = declaredKeysFromSource(); + + const missing = navItems + .map((item) => item.permission) + .filter((perm): perm is string => Boolean(perm)) + .filter((perm) => !declared.has(perm)); + + expect(missing).toEqual([]); + }); + + it("enforces every literal key used by a role preset", () => { + const declared = declaredKeysFromSource(); + + const missing = Object.values(ROLE_PRESETS) + .flatMap((preset) => preset.permissions) + .filter((perm) => !perm.includes("*")) + .filter((perm) => !declared.has(perm)); + + expect(missing).toEqual([]); + }); +}); +``` + +The first case is the guard that makes the other two meaningful — a regex that silently matched +nothing would otherwise make both pass vacuously. + +- [ ] **Step 2: Run to verify they fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/server/shared/permissions.test.ts tests/server/shared/permissionDrift.test.ts +``` + +Expected: FAIL — the helpers take one argument. + +- [ ] **Step 3: Implement** + +In `packages/types/src/dashboard-permissions.ts`: delete `PERMISSION_REGISTRY`, `ALL_PERMISSION_KEYS`, `PermissionDefinition`, and `PermissionModule`. Keep `RolePreset`, `ROLE_PRESETS`, `matchPermission`, `wildcardMatch`. Re-signature: + +```typescript +/** + * Expand a wildcard pattern to the concrete keys it covers. + * `allKeys` is the caller's vocabulary — the dashboard passes the keys declared + * by its route table, so this module never has to know what permissions exist. + */ +export function expandWildcard( + pattern: string, + allKeys: readonly string[], +): string[] { + if (pattern === "*") return [...allKeys]; + return allKeys.filter((key) => matchPermission(new Set([pattern]), key)); +} + +/** + * Given granted permissions (possibly wildcards), return every concrete key + * they cover, out of `allKeys`. + */ +export function resolveEffectivePermissions( + granted: string[], + allKeys: readonly string[], +): string[] { + const grantedSet = new Set(granted); + return allKeys.filter((key) => matchPermission(grantedSet, key)); +} +``` + +Update `packages/types/src/index.ts` to stop exporting `PERMISSION_REGISTRY` and `ALL_PERMISSION_KEYS`. + +In `apps/dashboard/src/server/features/permissions/routes.ts`: + +```typescript +import { resolveEffectivePermissions } from "@fluxcore/types"; +import { getDeclaredPermissions } from "../../shared/middleware.js"; +import { buildPermissionRegistry } from "../../shared/permissionRegistry.js"; +``` + +Both `effectivePermissions` call sites become: + +```typescript + effectivePermissions: resolveEffectivePermissions( + [...resolved.permissions], + [...getDeclaredPermissions()], + ), +``` + +The registry endpoint body becomes: + +```typescript + reply.send(buildPermissionRegistry(getDeclaredPermissions())); +``` + +and its response schema becomes an array: + +```typescript +{ tag: "DashboardPermissions", response: { 200: { type: "array", items: { type: "object", additionalProperties: true } } } }, +``` + +`isValidPermKey` (routes.ts) and `isValidPermissionKey` (roles-routes.ts) both replace their first line with: + +```typescript + if (getDeclaredPermissions().has(key)) return true; +``` + +and drop the `ALL_PERMISSION_KEYS` import. + +- [ ] **Step 4: Run everything** + +```bash +pnpm --filter @fluxcore/dashboard test +pnpm typecheck +``` + +Expected: PASS. Typecheck will point at any remaining importer of the deleted exports. + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/src apps/dashboard/src/server/features/permissions apps/dashboard/tests/server/shared +git commit -m "refactor(permissions): drop the hand-maintained permission registry" +``` + +--- + +### Task 10: Permissions page renders the live registry + +**Files:** + +- Modify: `apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts` (add `usePermissionRegistry`) +- Modify: `apps/dashboard/src/client/shared/lib/schemas.ts` (registry schema) +- Modify: `apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx:47,284,346` (drop the static import) +- Test: `apps/dashboard/tests/client/features/permissions/usePermissionRegistry.test.tsx` (create) + +**Interfaces:** + +- Consumes: `GET /api/guilds/:guildId/permission-registry` (Task 9). +- Produces: `usePermissionRegistry(guildId)` returning `PermissionModuleView[]`. + +- [ ] **Step 1: Write the failing test** + +This one tests a react-query hook, so it needs a real `QueryClientProvider`. Declare the wrapper in +the test file — it is four lines and self-contained: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import type { ReactNode } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mockApiFetch = vi.fn(); +vi.mock("../../../../src/client/shared/lib/client", () => ({ + apiFetch: (...args: unknown[]) => mockApiFetch(...args), +})); + +import { usePermissionRegistry } from "../../../../src/client/features/permissions/hooks/usePermissions"; + +const REGISTRY = [ + { + key: "tickets", + icon: "Ticket", + labelKey: "permissions:permissionCategories.tickets", + permissions: [ + { + key: "tickets.list.view", + resourceKey: "permissions:resources.list", + actionKey: "permissions:permissionActions.view", + }, + ], + }, +]; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe("usePermissionRegistry", () => { + it("parses the served registry", async () => { + mockApiFetch.mockResolvedValue(REGISTRY); + + const { result } = renderHook(() => usePermissionRegistry("guild-1"), { wrapper }); + + await waitFor(() => expect(result.current.data).toEqual(REGISTRY)); + expect(mockApiFetch).toHaveBeenCalledWith("/api/guilds/guild-1/permission-registry"); + }); + + it("rejects a registry entry missing its i18n keys", async () => { + mockApiFetch.mockResolvedValue([{ key: "tickets", icon: "Ticket", permissions: [] }]); + + const { result } = renderHook(() => usePermissionRegistry("guild-1"), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/client/usePermissionRegistry.test.tsx +``` + +Expected: FAIL — `usePermissionRegistry` is not exported. + +- [ ] **Step 3: Implement** + +`schemas.ts`: + +```typescript +export const PermissionViewSchema = z.object({ + key: z.string(), + resourceKey: z.string(), + actionKey: z.string(), +}); + +export const PermissionModuleViewSchema = z.object({ + key: z.string(), + icon: z.string(), + labelKey: z.string(), + permissions: z.array(PermissionViewSchema), +}); + +export const PermissionRegistrySchema = z.array(PermissionModuleViewSchema); +export type PermissionModuleView = z.infer; +``` + +`usePermissions.ts`: + +```typescript +/** + * The permission vocabulary, served from the route table rather than a static + * list, so the grid can never offer a permission no route enforces. + */ +export function usePermissionRegistry(guildId: string) { + return useQuery({ + queryKey: ["guilds", guildId, "permission-registry"], + queryFn: async () => { + const raw = await apiFetch( + `/api/guilds/${guildId}/permission-registry`, + ); + return PermissionRegistrySchema.parse(raw); + }, + staleTime: Infinity, + enabled: Boolean(guildId), + }); +} +``` + +In `permissions.tsx`: remove `PERMISSION_REGISTRY` from the `@fluxcore/types` import, call `const { data: registry = [] } = usePermissionRegistry(guildId);` in both components that referenced it, and replace the two usages: + +```typescript + const modulePerms = registry.find((m) => m.key === moduleKey); +``` + +```tsx + {registry.map((mod) => { +``` + +Labels become translations — inside the module header: + +```tsx + + {t(mod.labelKey)} + +``` + +and per permission, replacing `perm.label` / `perm.description`: + +```tsx +
+ + {t("roleEditor.permissionLabel", { + action: t(perm.actionKey), + resource: t(perm.resourceKey), + })} + +

{perm.key}

+
+``` + +Update the `aria-label`s in that grid the same way (they currently interpolate `mod.label` and `perm.label`). + +The component's `useTranslation` call must include the `permissions` namespace so `t("permissions:...")` keys resolve. + +- [ ] **Step 4: Run the tests** + +```bash +pnpm --filter @fluxcore/dashboard test +pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client apps/dashboard/tests/client +git commit -m "feat(permissions): render the permission grid from the served registry" +``` + +--- + +### Task 11: Registry i18n across 48 locales + +**Files:** + +- Modify: `packages/i18n/src/locales//permissions.json` × 48 + +**Interfaces:** + +- Consumes: the i18n keys emitted by Task 8 (`permissions:permissionCategories.`, `permissions:resources.`, `permissions:permissionActions.`). + +`permissionCategories` already exists in all 48 locales with 15 entries, but keyed for the old grouping — it has `settings` and no `dashboard`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/i18n/tests/permission-keys.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const LOCALES_DIR = join(__dirname, "../src/locales"); + +const REQUIRED_MODULES = [ + "dashboard", "moderation", "actions", "logging", "welcome", "leveling", + "tickets", "giveaways", "starboard", "suggestions", "roles", "tempvoice", + "security", "scheduled", "commands", +]; + +const REQUIRED_RESOURCES = [ + "roles", "audit", "settings", "lookups", "cases", "warnings", "punishments", + "rules", "analytics", "entries", "config", "test", "leaderboard", "users", + "rewards", "list", "panels", "messages", "events", +]; + +const REQUIRED_ACTIONS = ["view", "manage", "execute", "purge"]; + +function readPermissions(lang: string): Record> { + const raw = readFileSync(join(LOCALES_DIR, lang, "permissions.json"), "utf8"); + return JSON.parse(raw) as Record>; +} + +describe("permission registry i18n", () => { + const languages = readdirSync(LOCALES_DIR); + + it("covers all 48 locales", () => { + expect(languages).toHaveLength(48); + }); + + it.each(languages)("%s has every module, resource, and action label", (lang) => { + const perms = readPermissions(lang); + + for (const key of REQUIRED_MODULES) { + expect(perms.permissionCategories?.[key], `${lang} permissionCategories.${key}`).toBeTruthy(); + } + for (const key of REQUIRED_RESOURCES) { + expect(perms.resources?.[key], `${lang} resources.${key}`).toBeTruthy(); + } + for (const key of REQUIRED_ACTIONS) { + expect(perms.permissionActions?.[key], `${lang} permissionActions.${key}`).toBeTruthy(); + } + }); +}); +``` + +Also add `roleEditor.permissionLabel` (used in Task 10) to the required set for the `roleEditor` block, e.g. `"permissionLabel": "{{action}} {{resource}}"` — the same interpolation shape in every locale, word order adjusted per language. + +- [ ] **Step 2: Run it to verify it fails** + +```bash +pnpm --filter @fluxcore/i18n test -- tests/permission-keys.test.ts +``` + +Expected: FAIL for all 48 locales — `resources` and `permissionActions` do not exist. + +- [ ] **Step 3: Add and translate the keys** + +For each of the 48 locales, `permissions.json` gains: + +- `permissionCategories.dashboard` (new; the other 14 module keys already exist) +- a `resources` object with the 19 nouns above +- a `permissionActions` object with the 4 verbs +- `roleEditor.permissionLabel` + +**Editing procedure — formatting matters.** These files are not uniformly formatted, and 17 of them contain `\u` escapes that a naive round-trip would rewrite. For each file: + +1. Read it, `JSON.parse` it, `JSON.stringify(parsed, null, 2)` it, and compare to the original bytes. +2. If byte-identical, it is safe to write back a re-serialized version with the new keys. +3. If not, splice the new blocks in as text: insert after the closing brace of the `permissionCategories` object, matching the file's existing indentation and escape style. + +Verify no unrelated lines moved: + +```bash +git diff --numstat packages/i18n/src/locales | awk '$2 != 0 { print "deleted lines in " $3 }' +``` + +Expected: no output — additions only (plus the one `permissionCategories.dashboard` line per file). + +English values: + +```json + "permissionCategories": { "dashboard": "Dashboard" }, + "resources": { + "roles": "Roles", "audit": "Audit Log", "settings": "Settings", + "lookups": "Pickers", "cases": "Cases", "warnings": "Warnings", + "punishments": "Punishments", "rules": "Rules", "analytics": "Analytics", + "entries": "Entries", "config": "Configuration", "test": "Test Messages", + "leaderboard": "Leaderboard", "users": "Users", "rewards": "Rewards", + "list": "List", "panels": "Panels", "messages": "Messages", "events": "Events" + }, + "permissionActions": { + "view": "View", "manage": "Manage", "execute": "Execute", "purge": "Purge" + } +``` + +Translate all of them per locale — no English placeholders. RTL locales need no special handling here; these are plain nouns. + +- [ ] **Step 4: Run the tests** + +```bash +pnpm --filter @fluxcore/i18n test +pnpm --filter @fluxcore/dashboard test +``` + +Expected: PASS in all 48 locales. + +- [ ] **Step 5: Commit** + +```bash +git add packages/i18n/src/locales packages/i18n/tests/permission-keys.test.ts +git commit -m "i18n(permissions): translate registry module, resource, and action labels" +``` + +--- + +### Task 12: Overview survives without analytics permission + +`OverviewPage` calls `useAnalytics` unconditionally and renders `CardGridSkeleton` whenever `isLoading || !analytics` — on a 403 that skeleton never resolves. Overview is the landing route for every delegated user. + +**Files:** + +- Modify: `apps/dashboard/src/client/routes/guild/$guildId/overview.tsx` +- Create: `apps/dashboard/src/client/features/overview/components/AccessSummary.tsx` +- Modify: `packages/i18n/src/locales//overview.json` × 48 +- Test: `apps/dashboard/tests/client/routes/guild/OverviewPage.test.tsx` (create) + +**Interfaces:** + +- Consumes: `usePermissions` (`can`, `roles`, `isLoading`), `navItems`. +- Produces: ``. + +- [ ] **Step 1: Write the failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ReactNode } from "react"; +import { render, screen, within } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ guildId: "g1" }), + Link: ({ children }: { children: ReactNode }) => {children}, +})); + +const mockCan = vi.fn(); +vi.mock("../../../../src/client/features/permissions/hooks/usePermissions", () => ({ + usePermissions: () => ({ can: mockCan, roles: [], isLoading: false }), +})); + +const mockUseAnalytics = vi.fn(); +vi.mock("../../../../src/client/features/overview/hooks/useAnalytics", () => ({ + useAnalytics: (...args: unknown[]) => mockUseAnalytics(...args), +})); + +vi.mock("../../../../src/client/shared/hooks/useConstants", () => ({ + useConstants: () => ({ data: undefined }), +})); + +import { OverviewPage } from "../../../../src/client/routes/guild/$guildId/overview"; + +describe("OverviewPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAnalytics.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + isFetching: false, + }); + }); + + it("shows the access summary instead of analytics without actions.analytics.view", () => { + mockCan.mockImplementation((key: string) => key === "tickets.list.view"); + + render(); + + expect(screen.getByTestId("access-summary")).toBeInTheDocument(); + expect(screen.queryByTestId("analytics-stats")).not.toBeInTheDocument(); + }); + + it("does not fetch analytics the user cannot see", () => { + mockCan.mockReturnValue(false); + + render(); + + expect(mockUseAnalytics).toHaveBeenCalledWith("g1", 7, false); + }); + + it("lists only the pages the user can open", () => { + mockCan.mockImplementation((key: string) => key === "tickets.list.view"); + + render(); + + const summary = screen.getByTestId("access-summary"); + expect(within(summary).getByText("nav.tickets")).toBeInTheDocument(); + expect(within(summary).queryByText("nav.moderation")).not.toBeInTheDocument(); + }); + + it("renders an error state rather than an endless skeleton when analytics fails", () => { + mockCan.mockReturnValue(true); + mockUseAnalytics.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + isFetching: false, + }); + + render(); + + expect(screen.getByTestId("analytics-error")).toBeInTheDocument(); + }); +}); +``` + +`t` is mocked to return its key, so the nav assertions match `nav.tickets` / `nav.moderation` — the +`i18nKey` values in `navigation.ts`. + +- [ ] **Step 2: Run to verify they fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/client/OverviewPage.test.tsx +``` + +Expected: FAIL — the page renders a skeleton in all three cases. + +- [ ] **Step 3: Implement** + +Create `AccessSummary.tsx`: + +```tsx +import { Link } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { Card } from "../../../shared/ui/card"; +import { Badge } from "../../../shared/ui/badge"; +import { Icon } from "../../../shared/components/Icon"; +import { navItems } from "../../../shared/lib/navigation"; +import { usePermissions } from "../../permissions/hooks/usePermissions"; + +/** + * Landing content for someone whose access is delegated: overview analytics are + * permission-gated, so without them the page would otherwise be empty. Shows + * what they can actually open, and which dashboard roles got them here. + */ +export function AccessSummary({ guildId }: { guildId: string }) { + const { t } = useTranslation(["overview", "common"]); + const { can, roles } = usePermissions(guildId); + + const available = navItems.filter( + (item) => item.permission && can(item.permission), + ); + + return ( + +

{t("overview:access.title")}

+

{t("overview:access.subtitle")}

+ + {roles.length > 0 && ( +
+ {roles.map((role) => ( + + {role.name} + + ))} +
+ )} + + {available.length === 0 ? ( +

{t("overview:access.empty")}

+ ) : ( +
    + {available.map((item) => ( +
  • + + + {t(item.i18nKey)} + +
  • + ))} +
+ )} +
+ ); +} +``` + +In `overview.tsx`: + +```tsx + const { can, isLoading: permissionsLoading } = usePermissions(guildId); + const canViewAnalytics = can("actions.analytics.view"); + const { data: analytics, isLoading, isError, isFetching } = useAnalytics(guildId, days, canViewAnalytics); + + if (permissionsLoading) return ; + + if (!canViewAnalytics) { + return ( +
+ + +
+ ); + } + + if (isLoading) return ; + + if (isError || !analytics) { + return ( +
+ + + {t("errors.analyticsUnavailable")} + +
+ ); + } +``` + +Give the stats grid `data-testid="analytics-stats"`. Add the `enabled` parameter to `useAnalytics` so the query does not fire without permission: + +```typescript +export function useAnalytics(guildId: string, days: number = 7, enabled = true) { + return useQuery({ + // ... + enabled: enabled && Boolean(guildId), + }); +} +``` + +Add to `overview.json` (all 48 locales, same procedure as Task 11): + +```json + "access": { + "title": "Your access", + "subtitle": "You have been given access to parts of this server's dashboard.", + "empty": "You don't have access to any modules yet." + }, + "errors": { "analyticsUnavailable": "Analytics could not be loaded." } +``` + +- [ ] **Step 4: Run the tests** + +```bash +pnpm --filter @fluxcore/dashboard test +pnpm --filter @fluxcore/i18n test +pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client apps/dashboard/tests/client packages/i18n/src/locales +git commit -m "fix(overview): render for users without analytics permission" +``` + +--- + +### Task 13: Role editor warns about missing picker access + +**Files:** + +- Modify: `apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx` (role editor) +- Modify: `packages/i18n/src/locales//permissions.json` × 48 +- Test: `apps/dashboard/tests/client/features/permissions/lookupsWarning.test.ts` (create) + +The warning's condition is pure logic over a permission set, so test it as a function rather than +driving the whole role editor through a stubbed registry. Extract it in Step 3 and import it. + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, it, expect } from "vitest"; +import { needsLookupsPermission } from "../../../../src/client/features/permissions/lookupsWarning"; + +describe("needsLookupsPermission", () => { + it("warns when a role can configure things but cannot use pickers", () => { + expect(needsLookupsPermission(new Set(["tickets.panels.manage"]))).toBe(true); + }); + + it("stays quiet once lookups are granted", () => { + expect( + needsLookupsPermission(new Set(["tickets.panels.manage", "dashboard.lookups.view"])), + ).toBe(false); + }); + + it("stays quiet for a view-only role", () => { + expect(needsLookupsPermission(new Set(["tickets.list.view"]))).toBe(false); + }); + + it("stays quiet for a role whose wildcard already covers lookups", () => { + expect(needsLookupsPermission(new Set(["dashboard.*"]))).toBe(false); + expect(needsLookupsPermission(new Set(["*"]))).toBe(false); + }); + + it("warns for a module wildcard that does not cover lookups", () => { + expect(needsLookupsPermission(new Set(["tickets.*"]))).toBe(true); + }); + + it("stays quiet for an empty role", () => { + expect(needsLookupsPermission(new Set())).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +pnpm --filter @fluxcore/dashboard test -- tests/client/RoleEditorLookupsWarning.test.tsx +``` + +Expected: FAIL — no warning element exists. + +- [ ] **Step 3: Implement** + +First export the client matcher so there is one implementation, not two: in +`usePermissions.ts`, change `function matchPermission(...)` to `export function matchPermission(...)`. + +Create `apps/dashboard/src/client/features/permissions/lookupsWarning.ts`: + +```typescript +import { matchPermission } from "./hooks/usePermissions"; + +/** + * Channel/role/member pickers on nearly every page call the Discord lookup + * routes, which require dashboard.lookups.view. A role that can configure + * things but cannot use pickers renders empty dropdowns — worth warning about + * while the role is being edited rather than after it is assigned. + */ +export function needsLookupsPermission(granted: Set): boolean { + if (matchPermission(granted, "dashboard.lookups.view")) return false; + + return [...granted].some( + (perm) => perm.endsWith(".manage") || perm.endsWith(".*") || perm === "*", + ); +} +``` + +In the role editor component, above the permission grid: + +```tsx + const needsLookups = needsLookupsPermission(permissions); + + {needsLookups && ( + + +
+ {t("roleEditor.lookupsWarning")} + +
+
+ )} +``` + +Also label what `dashboard.roles.manage` really grants, since delegated access makes it the power to +admit new people to the dashboard. In the permission grid, when rendering the key +`dashboard.roles.manage`, render `t("roleEditor.admissionNote")` beneath it in +`text-xs text-warning`. + +Add to `permissions.json` (48 locales, same procedure as Task 11): + +```json + "lookupsWarning": "This role can configure modules but cannot look up channels, roles, or members — its pickers will be empty.", + "grantLookups": "Grant picker access", + "admissionNote": "Also lets holders give other members dashboard access." +``` + +- [ ] **Step 4: Run the tests** + +```bash +pnpm --filter @fluxcore/dashboard test +pnpm --filter @fluxcore/i18n test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client apps/dashboard/tests/client packages/i18n/src/locales +git commit -m "feat(permissions): warn when a role lacks picker access" +``` + +--- + +### Task 14: Integration test — delegated access end to end + +**Files:** + +- Create: `packages/systems/tests/integration/dashboard-delegated-access.test.ts` + +**Interfaces:** + +- Consumes: the real test PostgreSQL, `setupTestDatabase` / `cleanTestData` / `teardownTestDatabase`, and the factories in `packages/systems/tests/helpers/`. + +- [ ] **Step 1: Write the test** + +```typescript +import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest"; +import { getPrisma } from "@fluxcore/database"; +import { + setupTestDatabase, + cleanTestData, + teardownTestDatabase, +} from "../helpers/database"; + +const GUILD_ID = "900000000000000001"; +const USER_ID = "900000000000000002"; + +describe("delegated dashboard access", () => { + beforeAll(() => setupTestDatabase()); + beforeEach(() => cleanTestData()); + afterAll(() => teardownTestDatabase()); + + it("resolves a role assignment into exactly that role's permissions", async () => { + const prisma = getPrisma(); + const role = await prisma.dashboardRole.create({ + data: { + guildId: GUILD_ID, + name: "Ticket Staff", + permissions: JSON.stringify(["tickets.list.view", "tickets.list.manage"]), + }, + }); + await prisma.dashboardRoleAssignment.create({ + data: { guildId: GUILD_ID, userId: USER_ID, roleId: role.id, assignedBy: "owner" }, + }); + + const assignments = await prisma.dashboardRoleAssignment.findMany({ + where: { guildId: GUILD_ID, userId: USER_ID }, + include: { role: true }, + }); + + expect(JSON.parse(assignments[0].role.permissions)).toEqual([ + "tickets.list.view", + "tickets.list.manage", + ]); + }); + + it("does not hand a default role to a user with no assignment", async () => { + const prisma = getPrisma(); + await prisma.dashboardRole.create({ + data: { + guildId: GUILD_ID, + name: "Baseline", + isDefault: true, + permissions: JSON.stringify(["logging.entries.view"]), + }, + }); + + const assignments = await prisma.dashboardRoleAssignment.findMany({ + where: { guildId: GUILD_ID, userId: USER_ID }, + }); + + expect(assignments).toEqual([]); + }); + + it("drops the assignment when the role is deleted", async () => { + const prisma = getPrisma(); + const role = await prisma.dashboardRole.create({ + data: { + guildId: GUILD_ID, + name: "Temp", + permissions: JSON.stringify(["tickets.list.view"]), + }, + }); + await prisma.dashboardRoleAssignment.create({ + data: { guildId: GUILD_ID, userId: USER_ID, roleId: role.id, assignedBy: "owner" }, + }); + + await prisma.dashboardRole.delete({ where: { id: role.id } }); + + expect( + await prisma.dashboardRoleAssignment.findMany({ where: { guildId: GUILD_ID, userId: USER_ID } }), + ).toEqual([]); + }); +}); +``` + +Use whatever the helpers module is actually named in `packages/systems/tests/helpers/` — match the imports used by the existing integration tests. + +- [ ] **Step 2: Run it** + +```bash +pnpm test:integration +``` + +Expected: PASS. The suite runs with `fileParallelism: false`; do not add `singleFork`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/systems/tests/integration/dashboard-delegated-access.test.ts +git commit -m "test(permissions): cover delegated grant resolution against the real DB" +``` + +--- + +### Task 15: Full verification + +- [ ] **Step 1: Run everything** + +```bash +pnpm typecheck +pnpm test +pnpm test:integration +``` + +All three must pass. `pnpm typecheck` does not cover dashboard test files — the test run is the only evidence for those. + +- [ ] **Step 2: Manual smoke check** + +```bash +pnpm dev +``` + +1. As the guild owner, enable the permission system, create a "Ticket Staff" role with `tickets.*` + `dashboard.lookups.view`, and assign it to an account that has **no** Discord admin rights. +2. Log in as that account: the server appears on the servers page with the delegated badge. +3. Open it: the sidebar shows only Tickets; Overview shows the access summary, not an endless skeleton. +4. Navigating directly to `/guild//moderation` shows the permission-denied page. +5. Remove the assignment; within a minute the guild disappears from that account's list. + +- [ ] **Step 3: Update the feature spec** + +`docs/features/dashboard-permissions.md` line 12 and line 21 still say the dashboard is admin-only. Replace both with the model in this plan and link to the design spec. Commit: + +```bash +git add docs/features/dashboard-permissions.md +git commit -m "docs(permissions): record delegated access in the feature spec" +``` diff --git a/docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md b/docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md index e61037d4..0e065537 100644 --- a/docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md +++ b/docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md @@ -118,13 +118,21 @@ role pickers. Mitigations, both required: ### Security gaps closed in this work -**Privilege escalation.** `permissions/roles-routes.ts` has no escalation guard today, despite the -feature spec calling for one. With non-admins in scope this is a path from "delegated moderator" to -full control. Add: when creating or updating a dashboard role, or writing a user permission -override, every key in the payload must be one the actor already holds (`matchPermission` against -the actor's resolved set). The guild owner bypasses this. The same check applies to assigning a -role — you cannot assign a role holding permissions you lack. Violations return 403 with the -offending key. +**Privilege escalation via role assignment.** Escalation guards already exist on role create +(`roles-routes.ts:129`), role update (`:250`), preset create (`:555`), and user permission +overrides (`routes.ts:161`, which additionally blocks self-grants and refuses wildcards from +non-owners). `POST /dashboard-roles/:roleId/members` has **no check at all** — a +`dashboard.roles.manage` holder can assign themselves, or anyone, an existing role that holds +permissions they lack. Today that is contained because only `MANAGE_GUILD` admins reach it; once +delegated users can, it is a direct path from "delegated moderator" to `*`. + +Fix, matching the strictness already used for user overrides: + +- Non-owners cannot assign a role holding any permission they do not themselves hold + (`matchPermission` against the actor's resolved set), → 403 with the offending key. +- Non-owners cannot assign a role to themselves at all, mirroring the existing self-grant block. +- `DELETE .../members/:userId` is deliberately left open to any `dashboard.roles.manage` holder — + removing an assignment reduces privilege and cannot escalate. **Admission power.** `dashboard.roles.manage` now means "can admit people to the dashboard". It stays a single key, but the permissions page labels it that way so an owner delegating it From ea411de3ad7e9ae1182cb19e895de1e79bf14bbd Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 17:04:30 +0300 Subject: [PATCH 03/36] docs(permissions): correct plan test commands for Docker and target the existing resolve test Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-07-28-delegated-dashboard-access.md | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/docs/superpowers/plans/2026-07-28-delegated-dashboard-access.md b/docs/superpowers/plans/2026-07-28-delegated-dashboard-access.md index c06ed705..6f076ebf 100644 --- a/docs/superpowers/plans/2026-07-28-delegated-dashboard-access.md +++ b/docs/superpowers/plans/2026-07-28-delegated-dashboard-access.md @@ -20,7 +20,9 @@ - `JSON.stringify(obj, null, 2)` is **not** format-preserving for these locale files (some are semi-compact; 17 contain `\u` escapes). Only round-trip a file when a no-op round-trip is byte-identical; otherwise splice text. - Dashboard UI uses existing shadcn/ui wrappers in `apps/dashboard/src/client/shared/ui/`. Lucide icons via the `Icon` component. Never fill Lucide icons. - Commit after every task. Branch: `feat/delegated-dashboard-access`. -- Verification commands: `pnpm typecheck`, `pnpm test`, `pnpm test:integration`. Note `pnpm typecheck` does **not** cover `apps/dashboard/tests/**` — a green typecheck says nothing about test files; run the tests. +- Verification commands: `pnpm typecheck`, `pnpm test`, `pnpm test:integration` — all three already run inside Docker via docker-compose. Note `pnpm typecheck` does **not** cover `apps/dashboard/tests/**` — a green typecheck says nothing about test files; run the tests. +- To run one package's suite, use the Docker form: `docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test`. Never run bare `pnpm` on the host — `node_modules` is root-owned. A trailing vitest file filter is **not** honored through the workspace filter, so this runs the package's whole suite (~18s for the dashboard, 107 files / 1314 tests). +- Baseline before this branch: dashboard suite fully green (107 files, 1314 tests). Any failure you see is yours. --- @@ -107,7 +109,7 @@ Add `getGuildAuthority` to the existing import from `../../../src/server/shared/ - [ ] **Step 2: Run the tests to verify they fail** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/shared/guildAuthz.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — `getGuildAuthority is not a function`. @@ -173,7 +175,7 @@ export async function isUserGuildAdmin( - [ ] **Step 4: Run the tests to verify they pass** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/shared/guildAuthz.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test pnpm typecheck ``` @@ -193,7 +195,7 @@ git commit -m "refactor(dashboard): answer owner/admin/member in one authority l **Files:** - Modify: `apps/dashboard/src/server/shared/permissions.ts:33-150` -- Test: `apps/dashboard/tests/server/shared/permissions-resolve.test.ts` (create) +- Test: `apps/dashboard/tests/server/shared/resolveUserPermissions.test.ts` (**exists** — update it; do not create a second file) **Interfaces:** @@ -214,7 +216,18 @@ Resolution table (from the spec): - [ ] **Step 1: Write the failing tests** -Create `apps/dashboard/tests/server/shared/permissions-resolve.test.ts`: +`apps/dashboard/tests/server/shared/resolveUserPermissions.test.ts` already covers this function +with four tests. Rework **that** file rather than adding a parallel one: + +- Its mock of `../../../src/server/shared/guildAuthz.js` currently exposes `isUserGuildAdmin`; + replace that with `getGuildAuthority` (returning `{ isOwner, isAdmin, isMember }`) and update the + four existing tests to drive the new mock. Its `getGuildOwnerId` mock can go — ownership now comes + from `getGuildAuthority`. +- Keep its "unique guild per test" counter pattern, which is how it avoids the 60s permission cache + leaking between cases. Use it for the new tests too instead of calling `invalidatePermissionCache`. + +The cases to end up with (existing four, reworked, plus the new ones) are below; the mock names +follow that file's existing style: ```typescript import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -371,7 +384,7 @@ describe("resolveUserPermissions", () => { - [ ] **Step 2: Run the tests to verify they fail** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/shared/permissions-resolve.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — `getGuildAuthority` is not called (the module still imports `isUserGuildAdmin`), and `isGuildMember` is undefined. @@ -512,7 +525,7 @@ async function loadGrantedPermissions( - [ ] **Step 4: Run the tests to verify they pass** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/shared/permissions-resolve.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test pnpm typecheck ``` @@ -606,7 +619,7 @@ The existing cases (bot not in guild → 403 `botNotInGuild`; admin → pass) st - [ ] **Step 2: Run the tests to verify they fail** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/shared/middleware.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — `requireGuildAccess` is not exported. @@ -667,7 +680,7 @@ grep -rn "requireGuildAdmin" apps/dashboard || echo "no references left" - [ ] **Step 4: Run the full dashboard suite** ```bash -pnpm --filter @fluxcore/dashboard test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test pnpm typecheck ``` @@ -719,7 +732,7 @@ Add to `apps/dashboard/tests/server/features/discord/discord.test.ts` (follow th - [ ] **Step 2: Run it to verify it fails** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/features/discord/discord.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — returns 200 because no permission is required. @@ -743,8 +756,7 @@ and add `"dashboard.lookups.view"` to the `permissions` array of the `moderator` - [ ] **Step 4: Run the tests** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/features/discord/discord.test.ts -pnpm --filter @fluxcore/dashboard test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: PASS. @@ -881,7 +893,7 @@ Use whatever mock names that file already defines for `prisma.dashboardRole.find - [ ] **Step 2: Run them to verify they fail** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/features/permissions/dashboardRoles.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — assignment returns 201 in the first two cases. @@ -932,7 +944,7 @@ function safeParsePermissions(json: string): string[] { - [ ] **Step 4: Run the tests** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/features/permissions/dashboardRoles.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test pnpm typecheck ``` @@ -1055,7 +1067,7 @@ Use `res.json()` with the generic — never a cast. - [ ] **Step 2: Run them to verify they fail** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/features/guilds/guilds.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — delegated guilds are filtered out; `access` is undefined. @@ -1153,7 +1165,7 @@ Add `access: { type: "string" }` to `guildListResponseSchema`'s item properties, - [ ] **Step 4: Run the tests** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/features/guilds/ +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test pnpm typecheck ``` @@ -1219,7 +1231,7 @@ describe("GuildCard", () => { - [ ] **Step 2: Run it to verify it fails** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/client/GuildCard.test.tsx +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — no badge rendered, and a type error on `access`. @@ -1263,7 +1275,7 @@ Translate that one key in the other 47 locales (see the i18n procedure in Task 1 - [ ] **Step 4: Run the tests** ```bash -pnpm --filter @fluxcore/dashboard test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test pnpm typecheck ``` @@ -1384,7 +1396,7 @@ Add to `apps/dashboard/tests/server/shared/middleware.test.ts`: - [ ] **Step 2: Run them to verify they fail** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/shared/permissionRegistry.test.ts tests/server/shared/middleware.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — module not found / `getDeclaredPermissions` not exported. @@ -1538,8 +1550,7 @@ with imports from `./shared/permissionRegistry.js` and `./shared/middleware.js`. - [ ] **Step 4: Run the tests** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/shared/ -pnpm --filter @fluxcore/dashboard test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test pnpm typecheck ``` @@ -1701,7 +1712,7 @@ nothing would otherwise make both pass vacuously. - [ ] **Step 2: Run to verify they fail** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/server/shared/permissions.test.ts tests/server/shared/permissionDrift.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — the helpers take one argument. @@ -1779,7 +1790,7 @@ and drop the `ALL_PERMISSION_KEYS` import. - [ ] **Step 4: Run everything** ```bash -pnpm --filter @fluxcore/dashboard test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test pnpm typecheck ``` @@ -1870,7 +1881,7 @@ describe("usePermissionRegistry", () => { - [ ] **Step 2: Run it to verify it fails** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/client/usePermissionRegistry.test.tsx +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — `usePermissionRegistry` is not exported. @@ -1958,7 +1969,7 @@ The component's `useTranslation` call must include the `permissions` namespace s - [ ] **Step 4: Run the tests** ```bash -pnpm --filter @fluxcore/dashboard test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test pnpm typecheck ``` @@ -2043,7 +2054,7 @@ Also add `roleEditor.permissionLabel` (used in Task 10) to the required set for - [ ] **Step 2: Run it to verify it fails** ```bash -pnpm --filter @fluxcore/i18n test -- tests/permission-keys.test.ts +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/i18n test ``` Expected: FAIL for all 48 locales — `resources` and `permissionActions` do not exist. @@ -2093,8 +2104,8 @@ Translate all of them per locale — no English placeholders. RTL locales need n - [ ] **Step 4: Run the tests** ```bash -pnpm --filter @fluxcore/i18n test -pnpm --filter @fluxcore/dashboard test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/i18n test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: PASS in all 48 locales. @@ -2217,7 +2228,7 @@ describe("OverviewPage", () => { - [ ] **Step 2: Run to verify they fail** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/client/OverviewPage.test.tsx +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — the page renders a skeleton in all three cases. @@ -2343,8 +2354,8 @@ Add to `overview.json` (all 48 locales, same procedure as Task 11): - [ ] **Step 4: Run the tests** ```bash -pnpm --filter @fluxcore/dashboard test -pnpm --filter @fluxcore/i18n test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/i18n test pnpm typecheck ``` @@ -2409,7 +2420,7 @@ describe("needsLookupsPermission", () => { - [ ] **Step 2: Run to verify it fails** ```bash -pnpm --filter @fluxcore/dashboard test -- tests/client/RoleEditorLookupsWarning.test.tsx +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test ``` Expected: FAIL — no warning element exists. @@ -2477,8 +2488,8 @@ Add to `permissions.json` (48 locales, same procedure as Task 11): - [ ] **Step 4: Run the tests** ```bash -pnpm --filter @fluxcore/dashboard test -pnpm --filter @fluxcore/i18n test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard test +docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/i18n test ``` Expected: PASS. From 1204c1bf922781615840b5f0a1238e73621329ff Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 17:16:34 +0300 Subject: [PATCH 04/36] refactor(dashboard): answer owner/admin/member in one authority lookup --- .../dashboard/src/server/shared/guildAuthz.ts | 51 ++++++++++---- .../tests/server/shared/guildAuthz.test.ts | 67 ++++++++++++++++++- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/server/shared/guildAuthz.ts b/apps/dashboard/src/server/shared/guildAuthz.ts index ed72b1e3..8b2dee96 100644 --- a/apps/dashboard/src/server/shared/guildAuthz.ts +++ b/apps/dashboard/src/server/shared/guildAuthz.ts @@ -26,27 +26,54 @@ function computeBasePermissions( return perms; } +/** A user's live authority in a guild, from the bot's view of Discord. */ +export interface GuildAuthority { + isOwner: boolean; + /** Owner, Administrator, or Manage Server. */ + isAdmin: boolean; + /** Currently in the guild at all. */ + isMember: boolean; +} + /** - * Authoritative, LIVE check of whether a user currently has admin authority - * (owner, Administrator, or Manage Server) in a guild, computed from the bot's - * view of Discord. + * Authoritative, LIVE authority check, computed from the bot's view of Discord + * rather than the OAuth session snapshot, so access revoked on Discord is + * honored — subject only to the short discordApi cache TTL. * - * Unlike the OAuth session snapshot (captured at login and refreshed lazily), - * this reflects Discord's current state, so revoked access is honored — subject - * only to the short discordApi cache TTL. This is the source of truth for the - * dashboard's guild-admin gate. + * Answers owner / admin / member in one member fetch, because the delegated + * (non-admin) permission path needs membership and the admin path needs both. */ -export async function isUserGuildAdmin( +export async function getGuildAuthority( guildId: string, userId: string, -): Promise { +): Promise { const ownerId = await getGuildOwnerId(guildId); - if (ownerId === userId) return true; + if (ownerId === userId) { + return { isOwner: true, isAdmin: true, isMember: true }; + } const member = await getGuildMember(guildId, userId); - if (!member) return false; // left/kicked → no authority + if (!member) { + return { isOwner: false, isAdmin: false, isMember: false }; + } const roles = await getGuildRoles(guildId); const perms = computeBasePermissions(guildId, member.roles, roles); - return canManageGuild(perms.toString()); + return { + isOwner: false, + isAdmin: canManageGuild(perms.toString()), + isMember: true, + }; +} + +/** + * True when the user currently has admin authority (owner, Administrator, or + * Manage Server) in the guild. Thin wrapper over {@link getGuildAuthority}. + */ +export async function isUserGuildAdmin( + guildId: string, + userId: string, +): Promise { + const { isAdmin } = await getGuildAuthority(guildId, userId); + return isAdmin; } diff --git a/apps/dashboard/tests/server/shared/guildAuthz.test.ts b/apps/dashboard/tests/server/shared/guildAuthz.test.ts index 08b9a642..9abcb168 100644 --- a/apps/dashboard/tests/server/shared/guildAuthz.test.ts +++ b/apps/dashboard/tests/server/shared/guildAuthz.test.ts @@ -9,7 +9,7 @@ vi.mock("../../../src/server/shared/discordApi.js", () => ({ getGuildRoles: (...a: unknown[]) => mockGetGuildRoles(...a), })); -const { isUserGuildAdmin } = await import( +const { isUserGuildAdmin, getGuildAuthority } = await import( "../../../src/server/shared/guildAuthz.js" ); @@ -95,3 +95,68 @@ describe("isUserGuildAdmin", () => { expect(mockGetGuildRoles).not.toHaveBeenCalled(); }); }); + +describe("getGuildAuthority", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetGuildOwnerId.mockResolvedValue("owner-x"); + mockGetGuildMember.mockResolvedValue({ roles: [] }); + mockGetGuildRoles.mockResolvedValue([]); + }); + + it("reports the owner as owner, admin, and member without fetching the member", async () => { + mockGetGuildOwnerId.mockResolvedValue("user-1"); + + const authority = await getGuildAuthority("guild-1", "user-1"); + + expect(authority).toEqual({ isOwner: true, isAdmin: true, isMember: true }); + expect(mockGetGuildMember).not.toHaveBeenCalled(); + }); + + it("reports a non-member as nothing", async () => { + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockGetGuildMember.mockResolvedValue(null); + + const authority = await getGuildAuthority("guild-1", "user-1"); + + expect(authority).toEqual({ isOwner: false, isAdmin: false, isMember: false }); + }); + + it("reports a plain member as a member but not an admin", async () => { + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockGetGuildMember.mockResolvedValue({ roles: ["role-1"] }); + mockGetGuildRoles.mockResolvedValue([ + { id: "guild-1", name: "@everyone", permissions: "0" }, + { id: "role-1", name: "Member", permissions: "0" }, + ]); + + const authority = await getGuildAuthority("guild-1", "user-1"); + + expect(authority).toEqual({ isOwner: false, isAdmin: false, isMember: true }); + }); + + it("reports a member with Manage Server as an admin", async () => { + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockGetGuildMember.mockResolvedValue({ roles: ["role-1"] }); + mockGetGuildRoles.mockResolvedValue([ + { id: "guild-1", name: "@everyone", permissions: "0" }, + { id: "role-1", name: "Staff", permissions: BigInt(0x20).toString() }, + ]); + + const authority = await getGuildAuthority("guild-1", "user-1"); + + expect(authority).toEqual({ isOwner: false, isAdmin: true, isMember: true }); + }); + + it("fetches the member only once per call", async () => { + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockGetGuildMember.mockResolvedValue({ roles: [] }); + mockGetGuildRoles.mockResolvedValue([ + { id: "guild-1", name: "@everyone", permissions: "0" }, + ]); + + await getGuildAuthority("guild-1", "user-1"); + + expect(mockGetGuildMember).toHaveBeenCalledTimes(1); + }); +}); From 213a38aee541cb20959f9a27bc86478004a521f7 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 17:23:24 +0300 Subject: [PATCH 05/36] feat(permissions): resolve explicit grants for non-admin guild members resolveUserPermissions now calls getGuildAuthority instead of isUserGuildAdmin + getGuildOwnerId, and no longer short-circuits to an empty set for non-admin members. A member with a DashboardRoleAssignment or DashboardUserPermission row now gets those grants regardless of the requirePermissions toggle, which continues to govern only whether admins are constrained. isDefault roles still merge only on the admin path, so enabling the setting never admits the whole server at once. ResolvedPermissions gains isGuildMember; middleware.test.ts mocks updated to stay type-clean. --- .../src/server/shared/permissions.ts | 104 +++++++---- .../tests/server/shared/middleware.test.ts | 5 + .../shared/resolveUserPermissions.test.ts | 164 ++++++++++++------ 3 files changed, 185 insertions(+), 88 deletions(-) diff --git a/apps/dashboard/src/server/shared/permissions.ts b/apps/dashboard/src/server/shared/permissions.ts index d6af722c..2a935177 100644 --- a/apps/dashboard/src/server/shared/permissions.ts +++ b/apps/dashboard/src/server/shared/permissions.ts @@ -1,8 +1,7 @@ import { getPrisma } from "@fluxcore/database"; import { matchPermission } from "@fluxcore/types"; import { logger } from "@fluxcore/utils"; -import { getGuildOwnerId } from "./discordApi.js"; -import { isUserGuildAdmin } from "./guildAuthz.js"; +import { getGuildAuthority } from "./guildAuthz.js"; // ─── Cache ─── @@ -10,6 +9,7 @@ interface CachedPermissions { permissions: Set; isOwner: boolean; isGuildAdmin: boolean; + isGuildMember: boolean; expiresAt: number; } @@ -35,6 +35,8 @@ export interface ResolvedPermissions { isOwner: boolean; /** Whether the user currently has live Discord admin authority in the guild. */ isGuildAdmin: boolean; + /** Whether the user is currently in the guild at all. */ + isGuildMember: boolean; } function cacheResult( @@ -45,6 +47,7 @@ function cacheResult( permissions: result.permissions, isOwner: result.isOwner, isGuildAdmin: result.isGuildAdmin, + isGuildMember: result.isGuildMember, expiresAt: Date.now() + CACHE_TTL, }); return result; @@ -54,10 +57,17 @@ function cacheResult( * Resolve a user's effective permission set for a guild. * Returns all granted permission keys (may include wildcards). * - * Authorization is anchored to the user's LIVE Discord admin authority (owner, - * Administrator, or Manage Server) via {@link isUserGuildAdmin} — NOT the cached - * OAuth session snapshot. A user whose admin access was revoked on Discord - * resolves to an empty permission set within the short cache window. + * Authorization is anchored to the user's LIVE Discord authority (owner, + * admin, or plain membership) via {@link getGuildAuthority} — NOT the cached + * OAuth session snapshot. A user whose admin access was revoked on Discord, + * or who has left the guild, resolves to an empty permission set within the + * short cache window. + * + * `requirePermissions` governs whether ADMINS are constrained to explicit + * role/user grants. It never gates a non-admin member's explicit grants — + * those resolve the same way regardless of the setting. `isDefault` roles + * are merged only on the admin path; applying them to every member would + * turn the toggle into a server-wide grant. */ export async function resolveUserPermissions( userId: string, @@ -70,75 +80,95 @@ export async function resolveUserPermissions( permissions: cached.permissions, isOwner: cached.isOwner, isGuildAdmin: cached.isGuildAdmin, + isGuildMember: cached.isGuildMember, }; } permissionCache.delete(key); - const prisma = getPrisma(); + const authority = await getGuildAuthority(guildId, userId); - // Check if user is guild owner - const ownerId = await getGuildOwnerId(guildId); - if (ownerId === userId) { + if (authority.isOwner) { return cacheResult(key, { permissions: new Set(["*"]), isOwner: true, isGuildAdmin: true, + isGuildMember: true, }); } - // Live authority check — revoked Discord admin is honored here, so a stale - // OAuth session can no longer grant dashboard access. - const isGuildAdmin = await isUserGuildAdmin(guildId, userId); - if (!isGuildAdmin) { + // Not in the guild → no authority, and no reason to read grant rows. + if (!authority.isMember) { return cacheResult(key, { permissions: new Set(), isOwner: false, isGuildAdmin: false, + isGuildMember: false, }); } - // Check if permissions are enabled for this guild - const guildSettings = await prisma.dashboardGuildSettings.findUnique({ - where: { guildId }, - }); + const prisma = getPrisma(); - if (!guildSettings?.requirePermissions) { - // Legacy mode: all guild admins have full access - return cacheResult(key, { - permissions: new Set(["*"]), - isOwner: false, - isGuildAdmin: true, + // `requirePermissions` governs whether ADMINS are constrained. It never gates + // explicit grants, which resolve the same way in both modes. + if (authority.isAdmin) { + const guildSettings = await prisma.dashboardGuildSettings.findUnique({ + where: { guildId }, }); + if (!guildSettings?.requirePermissions) { + return cacheResult(key, { + permissions: new Set(["*"]), + isOwner: false, + isGuildAdmin: true, + isGuildMember: true, + }); + } } - // Gather permissions from roles + const permissions = await loadGrantedPermissions(guildId, userId, { + // Default roles are an admin baseline only. Applying them to every member + // would turn the requirePermissions toggle into a server-wide grant. + includeDefaultRoles: authority.isAdmin, + }); + + return cacheResult(key, { + permissions, + isOwner: false, + isGuildAdmin: authority.isAdmin, + isGuildMember: true, + }); +} + +/** + * Merge a user's dashboard role permissions and per-user overrides into one set. + */ +async function loadGrantedPermissions( + guildId: string, + userId: string, + options: { includeDefaultRoles: boolean }, +): Promise> { + const prisma = getPrisma(); + const assignments = await prisma.dashboardRoleAssignment.findMany({ where: { guildId, userId }, include: { role: true }, }); - // Also include default roles - const defaultRoles = await prisma.dashboardRole.findMany({ - where: { guildId, isDefault: true }, - }); + const defaultRoles = options.includeDefaultRoles + ? await prisma.dashboardRole.findMany({ where: { guildId, isDefault: true } }) + : []; const allRoles = [ ...assignments.map((a) => a.role), - ...defaultRoles.filter( - (dr) => !assignments.some((a) => a.roleId === dr.id), - ), + ...defaultRoles.filter((dr) => !assignments.some((a) => a.roleId === dr.id)), ]; const permissions = new Set(); - for (const role of allRoles) { - const rolePerms = safeJsonParse(role.permissions, []); - for (const perm of rolePerms) { + for (const perm of safeJsonParse(role.permissions, [])) { permissions.add(perm); } } - // Add per-user permission overrides const userPerms = await prisma.dashboardUserPermission.findMany({ where: { guildId, userId }, }); @@ -146,7 +176,7 @@ export async function resolveUserPermissions( permissions.add(up.permission); } - return cacheResult(key, { permissions, isOwner: false, isGuildAdmin: true }); + return permissions; } /** diff --git a/apps/dashboard/tests/server/shared/middleware.test.ts b/apps/dashboard/tests/server/shared/middleware.test.ts index 58ff0875..087ff627 100644 --- a/apps/dashboard/tests/server/shared/middleware.test.ts +++ b/apps/dashboard/tests/server/shared/middleware.test.ts @@ -63,6 +63,7 @@ describe("middleware", () => { permissions: new Set(["*"]), isOwner: false, isGuildAdmin: true, + isGuildMember: true, }); }); @@ -132,6 +133,7 @@ describe("middleware", () => { permissions: new Set(), isOwner: false, isGuildAdmin: false, + isGuildMember: true, }); const request = adminRequest(); const reply = createMockReply(); @@ -152,6 +154,7 @@ describe("middleware", () => { permissions: new Set(), isOwner: false, isGuildAdmin: false, + isGuildMember: true, }); const request = createMockRequest({ session: { @@ -184,6 +187,7 @@ describe("middleware", () => { permissions: new Set(["*"]), isOwner: true, isGuildAdmin: true, + isGuildMember: true, }); const request = adminRequest(); const reply = createMockReply(); @@ -198,6 +202,7 @@ describe("middleware", () => { permissions: new Set(["actions.rules.manage"]), isOwner: false, isGuildAdmin: true, + isGuildMember: true, }); const request = adminRequest(); const reply = createMockReply(); diff --git a/apps/dashboard/tests/server/shared/resolveUserPermissions.test.ts b/apps/dashboard/tests/server/shared/resolveUserPermissions.test.ts index 8ebee6e8..62807d55 100644 --- a/apps/dashboard/tests/server/shared/resolveUserPermissions.test.ts +++ b/apps/dashboard/tests/server/shared/resolveUserPermissions.test.ts @@ -1,29 +1,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("@fluxcore/utils", () => ({ - logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +vi.mock("@fluxcore/config", () => ({ + config: { token: "test-token", clientId: "test-client-id", logLevel: "info" }, })); -const mockGetGuildOwnerId = vi.fn(); -vi.mock("../../../src/server/shared/discordApi.js", () => ({ - getGuildOwnerId: (...a: unknown[]) => mockGetGuildOwnerId(...a), +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -const mockIsUserGuildAdmin = vi.fn(); +const mockGetGuildAuthority = vi.fn(); vi.mock("../../../src/server/shared/guildAuthz.js", () => ({ - isUserGuildAdmin: (...a: unknown[]) => mockIsUserGuildAdmin(...a), + getGuildAuthority: (...args: unknown[]) => mockGetGuildAuthority(...args), + isUserGuildAdmin: vi.fn(), })); const mockFindGuildSettings = vi.fn(); -const mockFindRoleAssignments = vi.fn(); +const mockFindAssignments = vi.fn(); const mockFindDefaultRoles = vi.fn(); -const mockFindUserPerms = vi.fn(); +const mockFindUserPermissions = vi.fn(); vi.mock("@fluxcore/database", () => ({ getPrisma: () => ({ - dashboardGuildSettings: { findUnique: (...a: unknown[]) => mockFindGuildSettings(...a) }, - dashboardRoleAssignment: { findMany: (...a: unknown[]) => mockFindRoleAssignments(...a) }, - dashboardRole: { findMany: (...a: unknown[]) => mockFindDefaultRoles(...a) }, - dashboardUserPermission: { findMany: (...a: unknown[]) => mockFindUserPerms(...a) }, + dashboardGuildSettings: { findUnique: mockFindGuildSettings }, + dashboardRoleAssignment: { findMany: mockFindAssignments }, + dashboardRole: { findMany: mockFindDefaultRoles }, + dashboardUserPermission: { findMany: mockFindUserPermissions }, }), })); @@ -31,68 +31,130 @@ const { resolveUserPermissions } = await import( "../../../src/server/shared/permissions.js" ); +const TICKET_ROLE = { + id: "role-1", + permissions: JSON.stringify(["tickets.list.view", "tickets.list.manage"]), +}; + // Unique guild per test so the 60s permission cache never leaks across cases. let counter = 0; -describe("resolveUserPermissions (live authorization)", () => { +describe("resolveUserPermissions", () => { beforeEach(() => { vi.clearAllMocks(); counter++; - mockGetGuildOwnerId.mockResolvedValue("someone-else"); - mockIsUserGuildAdmin.mockResolvedValue(false); - mockFindGuildSettings.mockResolvedValue(null); - mockFindRoleAssignments.mockResolvedValue([]); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: false }); + mockFindAssignments.mockResolvedValue([]); mockFindDefaultRoles.mockResolvedValue([]); - mockFindUserPerms.mockResolvedValue([]); + mockFindUserPermissions.mockResolvedValue([]); }); - it("grants full access to the guild owner", async () => { + it("grants the owner everything", async () => { const guild = `g-owner-${counter}`; - mockGetGuildOwnerId.mockResolvedValueOnce("user-1"); + mockGetGuildAuthority.mockResolvedValue({ isOwner: true, isAdmin: true, isMember: true }); - const res = await resolveUserPermissions("user-1", guild); + const resolved = await resolveUserPermissions("user-1", guild); - expect(res.isOwner).toBe(true); - expect(res.isGuildAdmin).toBe(true); - expect(res.permissions.has("*")).toBe(true); - // Owner short-circuits — no live role computation needed. - expect(mockIsUserGuildAdmin).not.toHaveBeenCalled(); + expect([...resolved.permissions]).toEqual(["*"]); + expect(resolved.isOwner).toBe(true); + expect(resolved.isGuildMember).toBe(true); }); - it("denies a user whose Discord admin was revoked (empty set)", async () => { - const guild = `g-revoked-${counter}`; - mockIsUserGuildAdmin.mockResolvedValueOnce(false); + it("grants an admin everything in legacy mode", async () => { + const guild = `g-legacy-${counter}`; + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: true, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: false }); - const res = await resolveUserPermissions("user-1", guild); + const resolved = await resolveUserPermissions("user-1", guild); - expect(res.isOwner).toBe(false); - expect(res.isGuildAdmin).toBe(false); - expect(res.permissions.size).toBe(0); + expect([...resolved.permissions]).toEqual(["*"]); + expect(resolved.isGuildAdmin).toBe(true); }); - it("grants full access to a live admin in legacy mode", async () => { - const guild = `g-legacy-${counter}`; - mockIsUserGuildAdmin.mockResolvedValueOnce(true); - mockFindGuildSettings.mockResolvedValueOnce({ requirePermissions: false }); + it("restricts an admin to role grants plus default roles when the system is on", async () => { + const guild = `g-admin-rbac-${counter}`; + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: true, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: true }); + mockFindAssignments.mockResolvedValue([{ roleId: "role-1", role: TICKET_ROLE }]); + mockFindDefaultRoles.mockResolvedValue([ + { id: "role-2", permissions: JSON.stringify(["logging.entries.view"]) }, + ]); + + const resolved = await resolveUserPermissions("user-1", guild); + + expect([...resolved.permissions].sort()).toEqual([ + "logging.entries.view", + "tickets.list.manage", + "tickets.list.view", + ]); + }); + + it("grants a non-admin member exactly their explicit grants", async () => { + const guild = `g-member-grants-${counter}`; + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: true }); + mockFindAssignments.mockResolvedValue([{ roleId: "role-1", role: TICKET_ROLE }]); + mockFindUserPermissions.mockResolvedValue([{ permission: "logging.entries.view" }]); + + const resolved = await resolveUserPermissions("user-1", guild); + + expect([...resolved.permissions].sort()).toEqual([ + "logging.entries.view", + "tickets.list.view", + "tickets.list.manage", + ].sort()); + expect(resolved.isGuildAdmin).toBe(false); + expect(resolved.isGuildMember).toBe(true); + }); + + it("grants a non-admin member their grants in legacy mode too", async () => { + const guild = `g-member-legacy-${counter}`; + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: false }); + mockFindAssignments.mockResolvedValue([{ roleId: "role-1", role: TICKET_ROLE }]); + + const resolved = await resolveUserPermissions("user-1", guild); + + expect([...resolved.permissions].sort()).toEqual([ + "tickets.list.manage", + "tickets.list.view", + ]); + }); + + it("never applies default roles to a non-admin member", async () => { + const guild = `g-member-no-default-${counter}`; + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: true }); + mockFindGuildSettings.mockResolvedValue({ requirePermissions: true }); + mockFindDefaultRoles.mockResolvedValue([ + { id: "role-2", permissions: JSON.stringify(["logging.entries.view"]) }, + ]); + + const resolved = await resolveUserPermissions("user-1", guild); + + expect(resolved.permissions.size).toBe(0); + }); + + it("gives a non-member nothing even with a stale grant row", async () => { + const guild = `g-non-member-${counter}`; + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: false }); + mockFindAssignments.mockResolvedValue([{ roleId: "role-1", role: TICKET_ROLE }]); - const res = await resolveUserPermissions("user-1", guild); + const resolved = await resolveUserPermissions("user-1", guild); - expect(res.isGuildAdmin).toBe(true); - expect(res.permissions.has("*")).toBe(true); + expect(resolved.permissions.size).toBe(0); + expect(resolved.isGuildMember).toBe(false); + expect(mockFindAssignments).not.toHaveBeenCalled(); }); - it("resolves role-based permissions for a live admin in RBAC mode", async () => { - const guild = `g-rbac-${counter}`; - mockIsUserGuildAdmin.mockResolvedValueOnce(true); - mockFindGuildSettings.mockResolvedValueOnce({ requirePermissions: true }); - mockFindRoleAssignments.mockResolvedValueOnce([ - { roleId: "r1", role: { id: "r1", permissions: JSON.stringify(["actions.rules.manage"]) } }, + it("tolerates a role whose permissions column is not valid JSON", async () => { + const guild = `g-bad-json-${counter}`; + mockGetGuildAuthority.mockResolvedValue({ isOwner: false, isAdmin: false, isMember: true }); + mockFindAssignments.mockResolvedValue([ + { roleId: "role-1", role: { id: "role-1", permissions: "not json" } }, ]); - const res = await resolveUserPermissions("user-1", guild); + const resolved = await resolveUserPermissions("user-1", guild); - expect(res.isGuildAdmin).toBe(true); - expect(res.permissions.has("actions.rules.manage")).toBe(true); - expect(res.permissions.has("*")).toBe(false); + expect(resolved.permissions.size).toBe(0); }); }); From 859d19f8e5da94dfffbb1f247bda8393b7d3971b Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 17:32:22 +0300 Subject: [PATCH 06/36] feat(permissions): gate guild routes on any authority, not admin-ness --- .../shared/command-palette/sources/servers.ts | 2 +- .../src/server/features/actions/routes.ts | 22 +++--- .../src/server/features/commands/routes.ts | 10 +-- .../src/server/features/discord/routes.ts | 10 +-- .../src/server/features/giveaways/routes.ts | 10 +-- .../src/server/features/guilds/routes.ts | 2 +- .../src/server/features/leveling/routes.ts | 18 ++--- .../src/server/features/logging/routes.ts | 10 +-- .../src/server/features/moderation/routes.ts | 14 ++-- .../features/moderation/warnings-routes.ts | 20 ++--- .../features/permissions/roles-routes.ts | 20 ++--- .../src/server/features/permissions/routes.ts | 18 ++--- .../src/server/features/roles/routes.ts | 12 +-- .../src/server/features/scheduled/routes.ts | 14 ++-- .../src/server/features/security/routes.ts | 8 +- .../src/server/features/starboard/routes.ts | 8 +- .../src/server/features/suggestions/routes.ts | 14 ++-- .../src/server/features/tempvoice/routes.ts | 10 +-- .../src/server/features/tickets/routes.ts | 22 +++--- .../src/server/features/welcome/routes.ts | 14 ++-- .../dashboard/src/server/shared/middleware.ts | 20 +++-- apps/dashboard/src/server/shared/session.ts | 2 +- .../features/suggestions/suggestions.test.ts | 2 +- .../tests/server/shared/middleware.test.ts | 74 +++++++++++++++++-- 24 files changed, 210 insertions(+), 146 deletions(-) diff --git a/apps/dashboard/src/client/shared/command-palette/sources/servers.ts b/apps/dashboard/src/client/shared/command-palette/sources/servers.ts index 3af54dee..60ac43cd 100644 --- a/apps/dashboard/src/client/shared/command-palette/sources/servers.ts +++ b/apps/dashboard/src/client/shared/command-palette/sources/servers.ts @@ -5,7 +5,7 @@ export function serverCommands(opts: { guilds: Guild[] }): Command[] { const { guilds } = opts; // Bot-less guilds are deliberately excluded: every dashboard page for such a - // guild 403s (requireGuildAdmin checks isBotInGuild first), so offering them + // guild 403s (requireGuildAccess checks isBotInGuild first), so offering them // here would be offering a dead end. They remain visible on the server list, // where the invite affordance lives. return guilds diff --git a/apps/dashboard/src/server/features/actions/routes.ts b/apps/dashboard/src/server/features/actions/routes.ts index 0105ea68..e89b5b7e 100644 --- a/apps/dashboard/src/server/features/actions/routes.ts +++ b/apps/dashboard/src/server/features/actions/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { createRule, updateRule, @@ -287,7 +287,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/actions/rules", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.rules.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.rules.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Actions", response: { 200: { type: "array", items: { type: "object", additionalProperties: true } } }, @@ -310,7 +310,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/actions/rules", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.rules.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.rules.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -376,7 +376,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/actions/rules/:ruleId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.rules.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.rules.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -440,7 +440,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/actions/rules/:ruleId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.rules.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.rules.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Actions", response: { 200: { type: "object", properties: { success: { type: "boolean" } } } }, @@ -463,7 +463,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.patch( "/api/guilds/:guildId/actions/rules/bulk", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.rules.execute")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.rules.execute")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -538,7 +538,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/actions/rules/:ruleId/analytics", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.analytics.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.analytics.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -569,7 +569,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/actions/settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.settings.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Actions", response: { @@ -593,7 +593,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/actions/settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.settings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -653,7 +653,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/actions/analytics", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.analytics.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.analytics.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -681,7 +681,7 @@ export function registerActionRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/actions/logs", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("actions.analytics.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("actions.analytics.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, diff --git a/apps/dashboard/src/server/features/commands/routes.ts b/apps/dashboard/src/server/features/commands/routes.ts index a0d28066..21404052 100644 --- a/apps/dashboard/src/server/features/commands/routes.ts +++ b/apps/dashboard/src/server/features/commands/routes.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; import safeRegex from "safe-regex"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { rateLimits } from "../../shared/rateLimit.js"; import { getCustomCommands, @@ -40,7 +40,7 @@ export function registerCustomCommandRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/custom-commands", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("commands.list.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("commands.list.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "CustomCommands", response: { 200: { type: "array", items: {} } } }), }, async (request, reply) => { @@ -54,7 +54,7 @@ export function registerCustomCommandRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/custom-commands", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("commands.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("commands.list.manage")], config: rateLimits.create, schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -168,7 +168,7 @@ export function registerCustomCommandRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/custom-commands/:id", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("commands.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("commands.list.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, body: { @@ -264,7 +264,7 @@ export function registerCustomCommandRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/custom-commands/:id", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("commands.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("commands.list.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "CustomCommands", response: { 200: { type: "object", properties: { success: { type: "boolean" } } } } }), }, async (request, reply) => { diff --git a/apps/dashboard/src/server/features/discord/routes.ts b/apps/dashboard/src/server/features/discord/routes.ts index 23ff706a..09f9bdcf 100644 --- a/apps/dashboard/src/server/features/discord/routes.ts +++ b/apps/dashboard/src/server/features/discord/routes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { requireAuth, requireGuildAdmin } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess } from "../../shared/middleware.js"; import { getGuildChannels, getGuildRoles, @@ -42,7 +42,7 @@ export function registerDiscordRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/members", { - preHandler: [requireAuth, requireGuildAdmin], + preHandler: [requireAuth, requireGuildAccess], config: rateLimits.discordRead, schema: withDocs( { @@ -78,7 +78,7 @@ export function registerDiscordRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/channels", { - preHandler: [requireAuth, requireGuildAdmin], + preHandler: [requireAuth, requireGuildAccess], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -132,7 +132,7 @@ export function registerDiscordRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/roles", { - preHandler: [requireAuth, requireGuildAdmin], + preHandler: [requireAuth, requireGuildAccess], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -181,7 +181,7 @@ export function registerDiscordRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/refresh", { - preHandler: [requireAuth, requireGuildAdmin], + preHandler: [requireAuth, requireGuildAccess], // Busts the 60s Discord API cache in shared/discordApi.ts. config: rateLimits.external, schema: withDocs( diff --git a/apps/dashboard/src/server/features/giveaways/routes.ts b/apps/dashboard/src/server/features/giveaways/routes.ts index d67761f5..1c24f6e4 100644 --- a/apps/dashboard/src/server/features/giveaways/routes.ts +++ b/apps/dashboard/src/server/features/giveaways/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { rateLimits } from "../../shared/rateLimit.js"; import { createGiveaway, @@ -27,7 +27,7 @@ export function registerGiveawayRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/giveaways", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("giveaways.list.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("giveaways.list.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, querystring: { type: "object", properties: { page: { type: "integer", minimum: 1, default: 1 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }, sort: { type: "string" } } } }, { tag: "Giveaways", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { @@ -54,7 +54,7 @@ export function registerGiveawayRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/giveaways", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("giveaways.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("giveaways.list.manage")], config: rateLimits.create, schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -117,7 +117,7 @@ export function registerGiveawayRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/giveaways/:id/end", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("giveaways.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("giveaways.list.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Giveaways", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { @@ -149,7 +149,7 @@ export function registerGiveawayRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/giveaways/:id/reroll", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("giveaways.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("giveaways.list.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Giveaways", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { diff --git a/apps/dashboard/src/server/features/guilds/routes.ts b/apps/dashboard/src/server/features/guilds/routes.ts index 4ba2beae..54200a30 100644 --- a/apps/dashboard/src/server/features/guilds/routes.ts +++ b/apps/dashboard/src/server/features/guilds/routes.ts @@ -12,7 +12,7 @@ import { rateLimits } from "../../shared/rateLimit.js"; * * Guilds the bot has NOT been added to are included, flagged with * `botPresent: false`, so the dashboard can offer a preselected invite for them - * instead of hiding them. This grants no access on its own — `requireGuildAdmin` + * instead of hiding them. This grants no access on its own — `requireGuildAccess` * still rejects guild-scoped requests with `botNotInGuild`. * * Bot-present guilds sort first so the actionable cards lead the grid. diff --git a/apps/dashboard/src/server/features/leveling/routes.ts b/apps/dashboard/src/server/features/leveling/routes.ts index 73260b59..d5ec2b8c 100644 --- a/apps/dashboard/src/server/features/leveling/routes.ts +++ b/apps/dashboard/src/server/features/leveling/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { getLevelSettings, upsertLevelSettings, @@ -26,7 +26,7 @@ export function registerLevelingRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/leaderboard", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("leveling.leaderboard.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("leveling.leaderboard.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, querystring: { type: "object", properties: { page: { type: "integer", minimum: 1, default: 1 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }, sort: { type: "string" } } } }, { tag: "Leveling", response: { 200: { type: "object", additionalProperties: true } } }, @@ -51,7 +51,7 @@ export function registerLevelingRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/levels/:userId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("leveling.leaderboard.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("leveling.leaderboard.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Leveling", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { @@ -80,7 +80,7 @@ export function registerLevelingRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/levels/:userId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("leveling.users.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("leveling.users.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -108,7 +108,7 @@ export function registerLevelingRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/level-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("leveling.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("leveling.settings.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Leveling", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { @@ -122,7 +122,7 @@ export function registerLevelingRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/level-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("leveling.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("leveling.settings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -193,7 +193,7 @@ export function registerLevelingRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/level-rewards", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("leveling.rewards.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("leveling.rewards.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Leveling", response: { 200: { type: "array", items: {} } } }), }, async (request, reply) => { @@ -207,7 +207,7 @@ export function registerLevelingRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/level-rewards", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("leveling.rewards.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("leveling.rewards.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -254,7 +254,7 @@ export function registerLevelingRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/level-rewards/:id", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("leveling.rewards.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("leveling.rewards.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Leveling", response: { 200: { type: "object", properties: { success: { type: "boolean" } } } } }), }, async (request, reply) => { diff --git a/apps/dashboard/src/server/features/logging/routes.ts b/apps/dashboard/src/server/features/logging/routes.ts index 2cf1b815..9955d3c8 100644 --- a/apps/dashboard/src/server/features/logging/routes.ts +++ b/apps/dashboard/src/server/features/logging/routes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { loadLogConfigs, upsertLogConfig } from "@fluxcore/systems/logging/config"; import { getLogEntries, cleanOldLogEntries } from "@fluxcore/systems/logging/persistence"; import { LOG_CATEGORIES, EVENT_TYPES_BY_CATEGORY } from "@fluxcore/systems/logging/constants"; @@ -11,7 +11,7 @@ export function registerLoggingRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/logs", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("logging.entries.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("logging.entries.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, querystring: { type: "object", properties: { page: { type: "integer", minimum: 1, default: 1 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }, sort: { type: "string" } } } }, { tag: "Logging", response: { 200: { type: "object", additionalProperties: true } } }, @@ -52,7 +52,7 @@ export function registerLoggingRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/log-config", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("logging.config.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("logging.config.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -81,7 +81,7 @@ export function registerLoggingRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/log-config/:category", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("logging.config.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("logging.config.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -133,7 +133,7 @@ export function registerLoggingRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/logs", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("logging.entries.purge")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("logging.entries.purge")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { diff --git a/apps/dashboard/src/server/features/moderation/routes.ts b/apps/dashboard/src/server/features/moderation/routes.ts index 81553dc3..b627b1a4 100644 --- a/apps/dashboard/src/server/features/moderation/routes.ts +++ b/apps/dashboard/src/server/features/moderation/routes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { getModCases, getModCaseById, @@ -21,7 +21,7 @@ export function registerModerationRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/cases", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.cases.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.cases.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, querystring: { type: "object", properties: { page: { type: "integer", minimum: 1, default: 1 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }, sort: { type: "string" } } } }, { tag: "Moderation", response: { 200: { type: "object", additionalProperties: true } } }, @@ -59,7 +59,7 @@ export function registerModerationRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/cases/:caseId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.cases.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.cases.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Moderation", response: { 200: { type: "object", additionalProperties: true } } }, @@ -87,7 +87,7 @@ export function registerModerationRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/cases/:caseId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.cases.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.cases.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -128,7 +128,7 @@ export function registerModerationRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/cases/:caseId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.cases.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.cases.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -160,7 +160,7 @@ export function registerModerationRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/mod-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.settings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Moderation", response: { 200: { type: "object", additionalProperties: true } } }, @@ -177,7 +177,7 @@ export function registerModerationRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/mod-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.settings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, diff --git a/apps/dashboard/src/server/features/moderation/warnings-routes.ts b/apps/dashboard/src/server/features/moderation/warnings-routes.ts index 3b0058d1..3e168ffa 100644 --- a/apps/dashboard/src/server/features/moderation/warnings-routes.ts +++ b/apps/dashboard/src/server/features/moderation/warnings-routes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { createWarning, getWarnings, @@ -26,7 +26,7 @@ export function registerWarningRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/warnings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.warnings.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.warnings.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, querystring: { type: "object", properties: { page: { type: "integer", minimum: 1, default: 1 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }, sort: { type: "string" } } } }, { tag: "Warnings", response: { 200: { type: "object", additionalProperties: true } } }, @@ -52,7 +52,7 @@ export function registerWarningRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/warnings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.warnings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.warnings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -88,7 +88,7 @@ export function registerWarningRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/warnings/:warningId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.warnings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.warnings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -113,7 +113,7 @@ export function registerWarningRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/warnings/user/:userId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.warnings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.warnings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -138,7 +138,7 @@ export function registerWarningRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/warn-punishments", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.punishments.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.punishments.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Warnings", response: { 200: { type: "array", items: { type: "object", additionalProperties: true } } } }, @@ -155,7 +155,7 @@ export function registerWarningRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/warn-punishments", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.punishments.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.punishments.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -190,7 +190,7 @@ export function registerWarningRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/warn-punishments/:id", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.punishments.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.punishments.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -215,7 +215,7 @@ export function registerWarningRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/warn-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.punishments.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.punishments.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Warnings", response: { 200: { type: "object", additionalProperties: true } } }, @@ -232,7 +232,7 @@ export function registerWarningRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/warn-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("moderation.punishments.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("moderation.punishments.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, diff --git a/apps/dashboard/src/server/features/permissions/roles-routes.ts b/apps/dashboard/src/server/features/permissions/roles-routes.ts index 2eac01e4..b43ab228 100644 --- a/apps/dashboard/src/server/features/permissions/roles-routes.ts +++ b/apps/dashboard/src/server/features/permissions/roles-routes.ts @@ -6,7 +6,7 @@ import { ROLE_PRESETS, matchPermission, } from "@fluxcore/types"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { createDashboardAuditLog, invalidatePermissionCache, @@ -39,7 +39,7 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "DashboardRoles", response: { 200: { type: "array", items: { type: "object", additionalProperties: true } } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.view")], }, async (request, reply) => { const { guildId } = request.params as { guildId: string }; @@ -71,7 +71,7 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/dashboard-roles", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -193,7 +193,7 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/dashboard-roles/:roleId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -315,7 +315,7 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { response: { 200: { type: "object", properties: { success: { type: "boolean" } } } }, }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], }, async (request, reply) => { const { guildId, roleId } = request.params as { guildId: string; roleId: string }; @@ -357,7 +357,7 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "DashboardRoles", response: { 200: { type: "array", items: { type: "object", additionalProperties: true } } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.view")], }, async (request, reply) => { const { guildId, roleId } = request.params as { guildId: string; roleId: string }; @@ -389,7 +389,7 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/dashboard-roles/:roleId/members", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -462,7 +462,7 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { response: { 200: { type: "object", properties: { success: { type: "boolean" } } } }, }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], }, async (request, reply) => { const { guildId, roleId, userId } = request.params as { @@ -507,7 +507,7 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "DashboardRoles", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], }, async (_request, reply) => { reply.send(ROLE_PRESETS); @@ -518,7 +518,7 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/dashboard-roles/from-preset", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, diff --git a/apps/dashboard/src/server/features/permissions/routes.ts b/apps/dashboard/src/server/features/permissions/routes.ts index e660e80d..c58ffdd7 100644 --- a/apps/dashboard/src/server/features/permissions/routes.ts +++ b/apps/dashboard/src/server/features/permissions/routes.ts @@ -6,7 +6,7 @@ import { ALL_PERMISSION_KEYS, resolveEffectivePermissions, } from "@fluxcore/types"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { resolveUserPermissions, createDashboardAuditLog, @@ -25,7 +25,7 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "DashboardPermissions", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin], + preHandler: [requireAuth, requireGuildAccess], }, async (request, reply) => { const { guildId } = request.params as { guildId: string }; @@ -54,7 +54,7 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "DashboardPermissions", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin], + preHandler: [requireAuth, requireGuildAccess], }, async (_request, reply) => { reply.send(PERMISSION_REGISTRY); @@ -71,7 +71,7 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "DashboardPermissions", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], }, async (request, reply) => { const { guildId, userId } = request.params as { guildId: string; userId: string }; @@ -102,7 +102,7 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/user-permissions/:userId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -219,7 +219,7 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { response: { 200: { type: "object", properties: { success: { type: "boolean" } } } }, }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.roles.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.roles.manage")], }, async (request, reply) => { const { guildId, userId } = request.params as { guildId: string; userId: string }; @@ -252,7 +252,7 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "DashboardPermissions", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.settings.manage")], }, async (request, reply) => { const { guildId } = request.params as { guildId: string }; @@ -276,7 +276,7 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/dashboard-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.settings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -348,7 +348,7 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { }, { tag: "DashboardPermissions", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("dashboard.audit.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.audit.view")], }, async (request, reply) => { const { guildId } = request.params as { guildId: string }; diff --git a/apps/dashboard/src/server/features/roles/routes.ts b/apps/dashboard/src/server/features/roles/routes.ts index bc849b2d..d456ff07 100644 --- a/apps/dashboard/src/server/features/roles/routes.ts +++ b/apps/dashboard/src/server/features/roles/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { getRolePanels, getRolePanel, @@ -24,7 +24,7 @@ export function registerRolePanelRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/role-panels", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("roles.panels.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("roles.panels.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "RolePanels", response: { 200: { type: "array", items: {} } } }), }, async (request, reply) => { @@ -38,7 +38,7 @@ export function registerRolePanelRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/role-panels", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("roles.panels.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("roles.panels.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -136,7 +136,7 @@ export function registerRolePanelRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/role-panels/:panelId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("roles.panels.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("roles.panels.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -242,7 +242,7 @@ export function registerRolePanelRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/role-panels/:panelId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("roles.panels.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("roles.panels.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "RolePanels", response: { 200: { type: "object", properties: { success: { type: "boolean" } } } } }), }, async (request, reply) => { @@ -267,7 +267,7 @@ export function registerRolePanelRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/role-panels/:panelId/send", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("roles.panels.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("roles.panels.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { diff --git a/apps/dashboard/src/server/features/scheduled/routes.ts b/apps/dashboard/src/server/features/scheduled/routes.ts index 49683c14..e7eb19de 100644 --- a/apps/dashboard/src/server/features/scheduled/routes.ts +++ b/apps/dashboard/src/server/features/scheduled/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { rateLimits } from "../../shared/rateLimit.js"; import { getScheduledMessages, @@ -21,7 +21,7 @@ export function registerScheduledMessageRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/scheduled-messages", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("scheduled.messages.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("scheduled.messages.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, querystring: { type: "object", properties: { page: { type: "integer", minimum: 1, default: 1 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }, sort: { type: "string" } } } }, { tag: "ScheduledMessages", response: { 200: { type: "object", additionalProperties: true } } }, @@ -46,7 +46,7 @@ export function registerScheduledMessageRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/scheduled-messages", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("scheduled.messages.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("scheduled.messages.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -123,7 +123,7 @@ export function registerScheduledMessageRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/scheduled-messages/:id", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("scheduled.messages.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("scheduled.messages.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -189,7 +189,7 @@ export function registerScheduledMessageRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/scheduled-messages/:id", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("scheduled.messages.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("scheduled.messages.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "ScheduledMessages", response: { 200: { type: "object", properties: { success: { type: "boolean" } } } } }), }, async (request, reply) => { @@ -213,7 +213,7 @@ export function registerScheduledMessageRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/scheduled-messages/:id/test", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("scheduled.messages.execute")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("scheduled.messages.execute")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -258,7 +258,7 @@ export function registerScheduledMessageRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/scheduled-messages/preview-cron", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("scheduled.messages.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("scheduled.messages.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { diff --git a/apps/dashboard/src/server/features/security/routes.ts b/apps/dashboard/src/server/features/security/routes.ts index 0001a0d5..01323d11 100644 --- a/apps/dashboard/src/server/features/security/routes.ts +++ b/apps/dashboard/src/server/features/security/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { getAntiRaidConfig, upsertAntiRaidConfig, @@ -14,7 +14,7 @@ export function registerAntiRaidRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/antiraid-config", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("security.config.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("security.config.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "AntiRaid", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { @@ -28,7 +28,7 @@ export function registerAntiRaidRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/antiraid-config", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("security.config.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("security.config.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, body: { @@ -75,7 +75,7 @@ export function registerAntiRaidRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/raid-events", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("security.events.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("security.events.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, querystring: { type: "object", properties: { page: { type: "integer", minimum: 1, default: 1 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }, sort: { type: "string" } } } }, { tag: "AntiRaid", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { diff --git a/apps/dashboard/src/server/features/starboard/routes.ts b/apps/dashboard/src/server/features/starboard/routes.ts index 15094a3c..0de6d6de 100644 --- a/apps/dashboard/src/server/features/starboard/routes.ts +++ b/apps/dashboard/src/server/features/starboard/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { getStarboardSettings, upsertStarboardSettings } from "@fluxcore/systems/starboard/config"; import { getStarboardEntries } from "@fluxcore/systems/starboard/persistence"; import { STARBOARD_PAGE_SIZE } from "@fluxcore/systems/starboard/constants"; @@ -17,7 +17,7 @@ export function registerStarboardRoutes(app: FastifyInstance): void { }, { tag: "Starboard", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("starboard.entries.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("starboard.entries.view")], }, async (request, reply) => { const { guildId } = request.params as { guildId: string }; @@ -42,7 +42,7 @@ export function registerStarboardRoutes(app: FastifyInstance): void { { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Starboard", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("starboard.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("starboard.settings.manage")], }, async (request, reply) => { const { guildId } = request.params as { guildId: string }; @@ -55,7 +55,7 @@ export function registerStarboardRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/starboard-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("starboard.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("starboard.settings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, diff --git a/apps/dashboard/src/server/features/suggestions/routes.ts b/apps/dashboard/src/server/features/suggestions/routes.ts index 32ec0134..088f4195 100644 --- a/apps/dashboard/src/server/features/suggestions/routes.ts +++ b/apps/dashboard/src/server/features/suggestions/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { getSuggestionSettings, upsertSuggestionSettings, @@ -26,7 +26,7 @@ export function registerSuggestionRoutes(app: FastifyInstance): void { }, { tag: "Suggestions", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("suggestions.list.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("suggestions.list.view")], }, async (request, reply) => { const { guildId } = request.params as { guildId: string }; @@ -51,7 +51,7 @@ export function registerSuggestionRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/suggestions", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("suggestions.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("suggestions.list.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -81,7 +81,7 @@ export function registerSuggestionRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/suggestions/:id/status", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("suggestions.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("suggestions.list.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -137,7 +137,7 @@ export function registerSuggestionRoutes(app: FastifyInstance): void { response: { 200: { type: "object", properties: { success: { type: "boolean" } } } }, }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("suggestions.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("suggestions.list.manage")], }, async (request, reply) => { const { guildId, id } = request.params as { guildId: string; id: string }; @@ -165,7 +165,7 @@ export function registerSuggestionRoutes(app: FastifyInstance): void { { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Suggestions", response: { 200: { type: "object", additionalProperties: true } } }, ), - preHandler: [requireAuth, requireGuildAdmin, requirePermission("suggestions.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("suggestions.settings.manage")], }, async (request, reply) => { const { guildId } = request.params as { guildId: string }; @@ -178,7 +178,7 @@ export function registerSuggestionRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/suggestion-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("suggestions.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("suggestions.settings.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, diff --git a/apps/dashboard/src/server/features/tempvoice/routes.ts b/apps/dashboard/src/server/features/tempvoice/routes.ts index d247027f..5fe89c71 100644 --- a/apps/dashboard/src/server/features/tempvoice/routes.ts +++ b/apps/dashboard/src/server/features/tempvoice/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { fetchGuildConfigs, addGuildConfig, @@ -31,7 +31,7 @@ export function registerTempVoiceRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/tempvoice", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tempvoice.config.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tempvoice.config.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "TempVoice", response: { 200: { type: "array", items: { type: "object", additionalProperties: true } } }, @@ -48,7 +48,7 @@ export function registerTempVoiceRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/tempvoice", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tempvoice.config.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tempvoice.config.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -139,7 +139,7 @@ export function registerTempVoiceRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/tempvoice/:configId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tempvoice.config.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tempvoice.config.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -226,7 +226,7 @@ export function registerTempVoiceRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/tempvoice/:configId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tempvoice.config.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tempvoice.config.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "TempVoice", response: { 200: { type: "object", properties: { success: { type: "boolean" } } } }, diff --git a/apps/dashboard/src/server/features/tickets/routes.ts b/apps/dashboard/src/server/features/tickets/routes.ts index daf408ea..9f72db8e 100644 --- a/apps/dashboard/src/server/features/tickets/routes.ts +++ b/apps/dashboard/src/server/features/tickets/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { getTicketSettings, upsertTicketSettings, @@ -30,7 +30,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/tickets", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.list.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.list.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, querystring: { type: "object", properties: { page: { type: "integer", minimum: 1, default: 1 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }, sort: { type: "string" } } } }, { tag: "Tickets", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { @@ -67,7 +67,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/tickets/:ticketId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.list.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.list.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Tickets", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { @@ -92,7 +92,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/tickets/:ticketId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.list.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.list.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Tickets", response: { 200: { type: "object", properties: { success: { type: "boolean" } } } } }), }, async (request, reply) => { @@ -120,7 +120,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/ticket-panels", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.panels.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.panels.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Tickets", response: { 200: { type: "array", items: {} } } }), }, async (request, reply) => { @@ -134,7 +134,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/ticket-panels", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.panels.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.panels.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, body: { @@ -212,7 +212,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/ticket-panels/:panelId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.panels.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.panels.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, body: { @@ -286,7 +286,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/ticket-panels/:panelId", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.panels.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.panels.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Tickets", response: { 200: { type: "object", properties: { success: { type: "boolean" } } } } }), }, async (request, reply) => { @@ -306,7 +306,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/ticket-panels/:panelId/send", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.panels.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.panels.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Tickets", response: { 200: { type: "object", properties: { success: { type: "boolean" }, panelId: { type: "integer" } } } } }), }, async (request, reply) => { @@ -335,7 +335,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/ticket-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.settings.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Tickets", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { @@ -349,7 +349,7 @@ export function registerTicketRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/ticket-settings", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("tickets.settings.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("tickets.settings.manage")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, body: { diff --git a/apps/dashboard/src/server/features/welcome/routes.ts b/apps/dashboard/src/server/features/welcome/routes.ts index 099ed482..ab762567 100644 --- a/apps/dashboard/src/server/features/welcome/routes.ts +++ b/apps/dashboard/src/server/features/welcome/routes.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; import { randomUUID } from "node:crypto"; -import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { rateLimits } from "../../shared/rateLimit.js"; import { getWelcomeConfig, upsertWelcomeConfig } from "@fluxcore/systems/welcome/config"; import { @@ -34,7 +34,7 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/welcome", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("welcome.config.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("welcome.config.view")], schema: withDocs({ params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { tag: "Welcome", response: { 200: { type: "object", additionalProperties: true } } }), }, async (request, reply) => { @@ -67,7 +67,7 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { app.put( "/api/guilds/:guildId/welcome", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("welcome.config.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("welcome.config.manage")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] }, @@ -173,7 +173,7 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/welcome/test", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("welcome.test.execute")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("welcome.test.execute")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -214,7 +214,7 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/welcome/image/preview", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("welcome.config.view")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("welcome.config.view")], // Full canvas render with a synchronous PNG encode plus a Discord CDN // avatar fetch. The editor re-fires this on every settings change behind // a 400ms debounce, so the ceiling sits above a slider drag's ~25/min. @@ -283,7 +283,7 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/welcome/image/background", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("welcome.config.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("welcome.config.manage")], // Decodes up to 3MB of base64 and writes it to storage. config: rateLimits.upload, bodyLimit: BACKGROUND_BODY_LIMIT, @@ -363,7 +363,7 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { app.delete( "/api/guilds/:guildId/welcome/image/background", { - preHandler: [requireAuth, requireGuildAdmin, requirePermission("welcome.config.manage")], + preHandler: [requireAuth, requireGuildAccess, requirePermission("welcome.config.manage")], // Storage mutation; pairs with the upload route. config: rateLimits.upload, schema: withDocs( diff --git a/apps/dashboard/src/server/shared/middleware.ts b/apps/dashboard/src/server/shared/middleware.ts index c6b9c26f..8871ff21 100644 --- a/apps/dashboard/src/server/shared/middleware.ts +++ b/apps/dashboard/src/server/shared/middleware.ts @@ -55,7 +55,15 @@ export async function requireAuth( }); } -export async function requireGuildAdmin( +/** + * Gate for every guild-scoped route: does this user have ANY authority here? + * + * Authority comes from three places — guild ownership, live Discord admin + * authority, or explicit dashboard grants (a dashboard role assignment or a + * per-user override). A member with grants but no MANAGE_GUILD passes here and + * is then narrowed by `requirePermission` on each route. + */ +export async function requireGuildAccess( request: FastifyRequest, reply: FastifyReply, ): Promise { @@ -70,13 +78,11 @@ export async function requireGuildAdmin( return; } - // Authorize from the user's LIVE Discord authority, not the cached OAuth - // session snapshot — so admin access revoked on Discord is honored here. - // resolveUserPermissions returns an empty set + isGuildAdmin=false when the - // user is no longer a guild admin. + // Authorize from LIVE Discord authority + DB grants, not the cached OAuth + // session snapshot — so access revoked on Discord is honored here. const resolved = await resolveUserPermissions(session.userId, guildId); const authorized = - resolved.isOwner || resolved.isGuildAdmin || resolved.permissions.has("*"); + resolved.isOwner || resolved.isGuildAdmin || resolved.permissions.size > 0; if (!authorized) { reply.code(403).send({ error: request.t("errors:permissions.noGuildPermission"), @@ -90,7 +96,7 @@ export async function requireGuildAdmin( /** * Require specific dashboard permissions. - * Must be used AFTER requireGuildAdmin (which resolves permissions). + * Must be used AFTER requireGuildAccess (which resolves permissions). * Accepts one or more permission keys — ALL must be granted. */ export function requirePermission(...keys: string[]) { diff --git a/apps/dashboard/src/server/shared/session.ts b/apps/dashboard/src/server/shared/session.ts index 5fc32809..c0e8eb7b 100644 --- a/apps/dashboard/src/server/shared/session.ts +++ b/apps/dashboard/src/server/shared/session.ts @@ -268,7 +268,7 @@ const FRESH_GUILD_THRESHOLD = 5 * 60 * 1000; // 5 minutes /** * Ensure session.guilds is no older than FRESH_GUILD_THRESHOLD. - * Used by requireGuildAdmin to fail closed for revoked admins quickly. + * Used by requireGuildAccess to fail closed for revoked admins quickly. */ export async function ensureFreshGuilds( id: string, diff --git a/apps/dashboard/tests/server/features/suggestions/suggestions.test.ts b/apps/dashboard/tests/server/features/suggestions/suggestions.test.ts index f0f99138..4dee9be3 100644 --- a/apps/dashboard/tests/server/features/suggestions/suggestions.test.ts +++ b/apps/dashboard/tests/server/features/suggestions/suggestions.test.ts @@ -54,7 +54,7 @@ vi.mock("../../../../src/server/shared/middleware.js", () => ({ requireAuth: vi.fn(async (request: { session: typeof mockSession }) => { request.session = mockSession; }), - requireGuildAdmin: vi.fn(async () => {}), + requireGuildAccess: vi.fn(async () => {}), requirePermission: vi.fn((..._keys: string[]) => async () => {}), })); diff --git a/apps/dashboard/tests/server/shared/middleware.test.ts b/apps/dashboard/tests/server/shared/middleware.test.ts index 087ff627..9da83e6b 100644 --- a/apps/dashboard/tests/server/shared/middleware.test.ts +++ b/apps/dashboard/tests/server/shared/middleware.test.ts @@ -28,7 +28,7 @@ vi.mock("../../../src/server/shared/permissions.js", () => ({ createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), })); -const { requireAuth, requireGuildAdmin } = await import( +const { requireAuth, requireGuildAccess } = await import( "../../../src/server/shared/middleware.js" ); @@ -106,7 +106,7 @@ describe("middleware", () => { }); }); - describe("requireGuildAdmin", () => { + describe("requireGuildAccess", () => { function adminRequest() { return createMockRequest({ session: { userId: "user-1", guilds: [] }, @@ -119,7 +119,7 @@ describe("middleware", () => { const request = adminRequest(); const reply = createMockReply(); - await requireGuildAdmin(request as never, reply as never); + await requireGuildAccess(request as never, reply as never); expect(reply.code).toHaveBeenCalledWith(403); expect(reply.send).toHaveBeenCalledWith( @@ -138,7 +138,7 @@ describe("middleware", () => { const request = adminRequest(); const reply = createMockReply(); - await requireGuildAdmin(request as never, reply as never); + await requireGuildAccess(request as never, reply as never); expect(reply.code).toHaveBeenCalledWith(403); expect(reply.send).toHaveBeenCalledWith( @@ -165,7 +165,7 @@ describe("middleware", () => { }); const reply = createMockReply(); - await requireGuildAdmin(request as never, reply as never); + await requireGuildAccess(request as never, reply as never); expect(reply.code).toHaveBeenCalledWith(403); }); @@ -174,7 +174,7 @@ describe("middleware", () => { const request = adminRequest(); const reply = createMockReply(); - await requireGuildAdmin(request as never, reply as never); + await requireGuildAccess(request as never, reply as never); expect(reply.code).not.toHaveBeenCalled(); expect( @@ -192,7 +192,7 @@ describe("middleware", () => { const request = adminRequest(); const reply = createMockReply(); - await requireGuildAdmin(request as never, reply as never); + await requireGuildAccess(request as never, reply as never); expect(reply.code).not.toHaveBeenCalled(); }); @@ -207,9 +207,67 @@ describe("middleware", () => { const request = adminRequest(); const reply = createMockReply(); - await requireGuildAdmin(request as never, reply as never); + await requireGuildAccess(request as never, reply as never); expect(reply.code).not.toHaveBeenCalled(); }); + + it("allows a non-admin member holding explicit grants", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["tickets.list.view"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + const request = createMockRequest({ + session: { userId: "user-1" }, + params: { guildId: "guild-1" }, + }); + const reply = createMockReply(); + + await requireGuildAccess(request as never, reply as never); + + expect(reply.code).not.toHaveBeenCalled(); + expect( + (request as { resolvedPermissions?: { permissions: Set } }) + .resolvedPermissions?.permissions.has("tickets.list.view"), + ).toBe(true); + }); + + it("rejects a member holding no grants", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + const request = createMockRequest({ + session: { userId: "user-1" }, + params: { guildId: "guild-1" }, + }); + const reply = createMockReply(); + + await requireGuildAccess(request as never, reply as never); + + expect(reply.code).toHaveBeenCalledWith(403); + }); + + it("rejects a non-member", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(), + isOwner: false, + isGuildAdmin: false, + isGuildMember: false, + }); + const request = createMockRequest({ + session: { userId: "user-1" }, + params: { guildId: "guild-1" }, + }); + const reply = createMockReply(); + + await requireGuildAccess(request as never, reply as never); + + expect(reply.code).toHaveBeenCalledWith(403); + }); }); }); From 7715ec0fa90ecb20b5b2fff9d2f3831b7a376c7b Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 17:41:51 +0300 Subject: [PATCH 07/36] fix(permissions): drop new test cast, name the size>0 discriminator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the requireGuildAccess rename: - Type createMockRequest's return (MockRequest, with an optional resolvedPermissions: ResolvedPermissions field) so tests can read request.resolvedPermissions directly instead of casting it back with `as`. Cleaned up two pre-existing reads of the same kind while here. - "rejects a member holding no grants" and "rejects a non-member" both use an empty permission set, which 403s identically under the old has("*") gate and the new size>0 gate — they're regression guards, not proof of the rewrite. "allows a non-admin member holding explicit grants" already covers the discriminating case (non-empty set, no "*"); renamed it and added a comment explaining why it's the one that actually distinguishes old from new behavior, instead of adding a duplicate. --- .../tests/server/shared/middleware.test.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/tests/server/shared/middleware.test.ts b/apps/dashboard/tests/server/shared/middleware.test.ts index 9da83e6b..5a8f1a0f 100644 --- a/apps/dashboard/tests/server/shared/middleware.test.ts +++ b/apps/dashboard/tests/server/shared/middleware.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ResolvedPermissions } from "../../../src/server/shared/permissions.js"; vi.mock("@fluxcore/config", () => ({ config: { @@ -32,11 +33,22 @@ const { requireAuth, requireGuildAccess } = await import( "../../../src/server/shared/middleware.js" ); +interface MockRequest { + cookies: Record; + unsignCookie: (value: string) => { valid: boolean; value: string; renew: boolean }; + t: (key: string) => string; + session: unknown; + params: Record; + // Populated by requireGuildAccess on success — declared here (rather than + // read back via a cast) so assertions can access it directly. + resolvedPermissions?: ResolvedPermissions; +} + function createMockRequest({ sessionCookie = undefined as string | undefined, session = undefined as unknown, params = {} as Record, -} = {}) { +} = {}): MockRequest { return { cookies: sessionCookie ? { session: sessionCookie } : {}, unsignCookie: (value: string) => ({ valid: true, value, renew: false }), @@ -101,7 +113,7 @@ describe("middleware", () => { await requireAuth(request as never, reply as never); - expect((request as Record).session).toEqual(session); + expect(request.session).toEqual(session); expect(reply.code).not.toHaveBeenCalled(); }); }); @@ -177,9 +189,7 @@ describe("middleware", () => { await requireGuildAccess(request as never, reply as never); expect(reply.code).not.toHaveBeenCalled(); - expect( - (request as Record).resolvedPermissions, - ).toBeDefined(); + expect(request.resolvedPermissions).toBeDefined(); }); it("passes for the guild owner", async () => { @@ -212,7 +222,14 @@ describe("middleware", () => { expect(reply.code).not.toHaveBeenCalled(); }); - it("allows a non-admin member holding explicit grants", async () => { + // This is the discriminator for the `permissions.size > 0` rewrite: the + // set below is non-empty but contains no "*", so the OLD gate + // (isOwner || isGuildAdmin || permissions.has("*")) would 403 this + // request, while the NEW gate allows it. The "rejects a member holding + // no grants" / "rejects a non-member" cases below both use an empty set, + // which fails identically under old and new — they're regression guards, + // not discriminators. This is the one that actually proves the rewrite. + it("allows a non-admin member holding explicit grants (size>0 discriminator)", async () => { mockResolveUserPermissions.mockResolvedValue({ permissions: new Set(["tickets.list.view"]), isOwner: false, @@ -229,8 +246,7 @@ describe("middleware", () => { expect(reply.code).not.toHaveBeenCalled(); expect( - (request as { resolvedPermissions?: { permissions: Set } }) - .resolvedPermissions?.permissions.has("tickets.list.view"), + request.resolvedPermissions?.permissions.has("tickets.list.view"), ).toBe(true); }); From a04bcd1b34ca45d8ba66441e5915785d60fe5542 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 17:51:32 +0300 Subject: [PATCH 08/36] feat(permissions): require dashboard.lookups.view for Discord passthroughs Co-Authored-By: Claude Opus 5 (1M context) --- .../src/server/features/discord/routes.ts | 10 +++++----- .../server/features/discord/discord.test.ts | 17 ++++++++++++++++- packages/types/src/dashboard-permissions.ts | 3 +++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/server/features/discord/routes.ts b/apps/dashboard/src/server/features/discord/routes.ts index 09f9bdcf..3020fc4f 100644 --- a/apps/dashboard/src/server/features/discord/routes.ts +++ b/apps/dashboard/src/server/features/discord/routes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { requireAuth, requireGuildAccess } from "../../shared/middleware.js"; +import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; import { getGuildChannels, getGuildRoles, @@ -42,7 +42,7 @@ export function registerDiscordRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/members", { - preHandler: [requireAuth, requireGuildAccess], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.lookups.view")], config: rateLimits.discordRead, schema: withDocs( { @@ -78,7 +78,7 @@ export function registerDiscordRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/channels", { - preHandler: [requireAuth, requireGuildAccess], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.lookups.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -132,7 +132,7 @@ export function registerDiscordRoutes(app: FastifyInstance): void { app.get( "/api/guilds/:guildId/roles", { - preHandler: [requireAuth, requireGuildAccess], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.lookups.view")], schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, { @@ -181,7 +181,7 @@ export function registerDiscordRoutes(app: FastifyInstance): void { app.post( "/api/guilds/:guildId/refresh", { - preHandler: [requireAuth, requireGuildAccess], + preHandler: [requireAuth, requireGuildAccess, requirePermission("dashboard.lookups.view")], // Busts the 60s Discord API cache in shared/discordApi.ts. config: rateLimits.external, schema: withDocs( diff --git a/apps/dashboard/tests/server/features/discord/discord.test.ts b/apps/dashboard/tests/server/features/discord/discord.test.ts index 34a6a10f..ee39a38b 100644 --- a/apps/dashboard/tests/server/features/discord/discord.test.ts +++ b/apps/dashboard/tests/server/features/discord/discord.test.ts @@ -38,9 +38,10 @@ vi.mock("../../../../src/server/shared/discordApi.js", () => ({ invalidateGuildCache: vi.fn(), })); +const mockHasPermission = vi.fn().mockReturnValue(true); vi.mock("../../../../src/server/shared/permissions.js", () => ({ resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: false }), - hasPermission: vi.fn().mockReturnValue(true), + hasPermission: (...args: unknown[]) => mockHasPermission(...args), invalidatePermissionCache: vi.fn(), createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), })); @@ -68,6 +69,7 @@ describe("discord routes", () => { vi.clearAllMocks(); mockGetSession.mockResolvedValue(mockSession); mockIsBotInGuild.mockResolvedValue(true); + mockHasPermission.mockReturnValue(true); app = await buildApp(); }); @@ -206,4 +208,17 @@ describe("discord routes", () => { }); }); + it("returns 403 when the caller lacks dashboard.lookups.view", async () => { + mockHasPermission.mockReturnValue(false); + mockGetSession.mockResolvedValue({ userId: "user-1", username: "u", guilds: [] }); + + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/channels", + cookies: { session: app.signCookie("sid") }, + }); + + expect(res.statusCode).toBe(403); + }); + }); diff --git a/packages/types/src/dashboard-permissions.ts b/packages/types/src/dashboard-permissions.ts index f67efb49..744e2781 100644 --- a/packages/types/src/dashboard-permissions.ts +++ b/packages/types/src/dashboard-permissions.ts @@ -25,6 +25,7 @@ export const PERMISSION_REGISTRY: PermissionModule[] = [ { key: "dashboard.roles.manage", label: "Manage Roles", description: "Create/edit/delete dashboard roles" }, { key: "dashboard.audit.view", label: "View Audit Log", description: "View dashboard audit log" }, { key: "dashboard.settings.manage", label: "Manage Settings", description: "Manage guild-wide dashboard settings" }, + { key: "dashboard.lookups.view", label: "Use Pickers", description: "Look up channels, roles, and members for pickers" }, ], }, { @@ -197,6 +198,7 @@ export const ROLE_PRESETS: Record = { "tickets.list.manage", "suggestions.list.manage", "security.events.view", + "dashboard.lookups.view", ], }, "content-manager": { @@ -210,6 +212,7 @@ export const ROLE_PRESETS: Record = { "roles.panels.*", "scheduled.messages.*", "commands.list.*", + "dashboard.lookups.view", ], }, "full-admin": { From 0381607b324bb9284643ef038bc9d14c0789066b Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 17:58:09 +0300 Subject: [PATCH 09/36] fix(tests): strengthen dashboard.lookups.view 403 test to assert the key Assert hasPermission was called with the exact "dashboard.lookups.view" key (not just that some 403 occurred) and cover all four Discord passthrough routes via it.each, not just /channels. Also drops a session guilds:[] override that played no role in the outcome. Co-Authored-By: Claude Opus 5 (1M context) --- .../server/features/discord/discord.test.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/tests/server/features/discord/discord.test.ts b/apps/dashboard/tests/server/features/discord/discord.test.ts index ee39a38b..972612e9 100644 --- a/apps/dashboard/tests/server/features/discord/discord.test.ts +++ b/apps/dashboard/tests/server/features/discord/discord.test.ts @@ -208,17 +208,27 @@ describe("discord routes", () => { }); }); - it("returns 403 when the caller lacks dashboard.lookups.view", async () => { - mockHasPermission.mockReturnValue(false); - mockGetSession.mockResolvedValue({ userId: "user-1", username: "u", guilds: [] }); - - const res = await app.inject({ - method: "GET", - url: "/api/guilds/guild-1/channels", - cookies: { session: app.signCookie("sid") }, - }); + describe("dashboard.lookups.view enforcement", () => { + it.each<{ label: string; method: "GET" | "POST"; url: string }>([ + { label: "GET /members", method: "GET", url: "/api/guilds/guild-1/members" }, + { label: "GET /channels", method: "GET", url: "/api/guilds/guild-1/channels" }, + { label: "GET /roles", method: "GET", url: "/api/guilds/guild-1/roles" }, + { label: "POST /refresh", method: "POST", url: "/api/guilds/guild-1/refresh" }, + ])("returns 403 and checks dashboard.lookups.view for $label", async ({ method, url }) => { + mockHasPermission.mockReturnValue(false); - expect(res.statusCode).toBe(403); + const res = await app.inject({ + method, + url, + cookies: { session: app.signCookie("sid") }, + }); + + expect(res.statusCode).toBe(403); + expect(mockHasPermission).toHaveBeenCalledWith( + expect.anything(), + "dashboard.lookups.view", + ); + }); }); }); From a9d2f319c97994a468dee4a80bea000db9dc91e6 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 18:07:18 +0300 Subject: [PATCH 10/36] fix(permissions): block privilege escalation via role assignment --- .../features/permissions/roles-routes.ts | 33 +++++ .../permissions/dashboardRoles.test.ts | 121 +++++++++++++++++- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/server/features/permissions/roles-routes.ts b/apps/dashboard/src/server/features/permissions/roles-routes.ts index b43ab228..ead7c8a9 100644 --- a/apps/dashboard/src/server/features/permissions/roles-routes.ts +++ b/apps/dashboard/src/server/features/permissions/roles-routes.ts @@ -30,6 +30,17 @@ function isValidPermissionKey(key: string): boolean { return false; } +function safeParsePermissions(json: string): string[] { + try { + const parsed: unknown = JSON.parse(json); + return Array.isArray(parsed) + ? parsed.filter((p): p is string => typeof p === "string") + : []; + } catch { + return []; + } +} + export function registerDashboardRoleRoutes(app: FastifyInstance): void { // GET all dashboard roles for a guild app.get( @@ -417,6 +428,28 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { return; } + // Assignment grants everything the role holds, so it is an escalation + // vector in its own right: without this a `dashboard.roles.manage` holder + // could hand themselves an existing Full Admin role. + if (!request.resolvedPermissions?.isOwner) { + if (userId === session.userId) { + reply.code(403).send({ error: "Cannot assign a role to yourself" }); + return; + } + + const callerPerms = request.resolvedPermissions!.permissions; + const rolePerms = safeParsePermissions(role.permissions); + for (const perm of rolePerms) { + if (!matchPermission(callerPerms, perm)) { + reply.code(403).send({ + error: "Cannot grant permissions you don't have", + permission: perm, + }); + return; + } + } + } + try { const assignment = await prisma.dashboardRoleAssignment.create({ data: { diff --git a/apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts b/apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts index 34e939f9..e8634bc4 100644 --- a/apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts +++ b/apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts @@ -29,8 +29,9 @@ vi.mock("../../../../src/server/shared/discordApi.js", () => ({ getGuildOwnerId: (...args: unknown[]) => mockGetGuildOwnerId(...args), })); +const mockResolveUserPermissions = vi.fn(); vi.mock("../../../../src/server/shared/permissions.js", () => ({ - resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + resolveUserPermissions: (...args: unknown[]) => mockResolveUserPermissions(...args), hasPermission: vi.fn().mockReturnValue(true), invalidatePermissionCache: vi.fn(), createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), @@ -85,6 +86,12 @@ describe("dashboard role routes", () => { vi.clearAllMocks(); mockGetSession.mockResolvedValue(mockSession); mockIsBotInGuild.mockResolvedValue(true); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + isGuildAdmin: true, + isGuildMember: true, + }); mockPrisma.dashboardRole.count.mockResolvedValue(0); mockPrisma.dashboardRole.aggregate.mockResolvedValue({ _max: { position: 0 } }); app = await buildApp(); @@ -246,4 +253,116 @@ describe("dashboard role routes", () => { expect(res.statusCode).toBe(400); }); }); + + describe("POST /api/guilds/:guildId/dashboard-roles/:roleId/members", () => { + it("refuses to assign a role holding permissions the caller lacks", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + mockPrisma.dashboardRole.findUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Full Admin", + permissions: JSON.stringify(["*"]), + }); + + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/dashboard-roles/role-1/members", + cookies: { session: app.signCookie("valid") }, + payload: { userId: "user-2" }, + }); + + expect(res.statusCode).toBe(403); + expect(mockPrisma.dashboardRoleAssignment.create).not.toHaveBeenCalled(); + }); + + it("refuses to assign any role to yourself", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage", "tickets.list.view"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + mockPrisma.dashboardRole.findUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Ticket Staff", + permissions: JSON.stringify(["tickets.list.view"]), + }); + + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/dashboard-roles/role-1/members", + cookies: { session: app.signCookie("valid") }, + payload: { userId: "user-1" }, + }); + + expect(res.statusCode).toBe(403); + expect(mockPrisma.dashboardRoleAssignment.create).not.toHaveBeenCalled(); + }); + + it("lets the owner assign anything, including to themselves", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + isGuildAdmin: true, + isGuildMember: true, + }); + mockPrisma.dashboardRole.findUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Full Admin", + permissions: JSON.stringify(["*"]), + }); + mockPrisma.dashboardRoleAssignment.create.mockResolvedValue({ + id: "assignment-1", + userId: "user-1", + assignedBy: "user-1", + createdAt: new Date(), + }); + + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/dashboard-roles/role-1/members", + cookies: { session: app.signCookie("valid") }, + payload: { userId: "user-1" }, + }); + + expect(res.statusCode).toBe(201); + }); + + it("allows assigning a role whose permissions the caller holds", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage", "tickets.*"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + mockPrisma.dashboardRole.findUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Ticket Staff", + permissions: JSON.stringify(["tickets.list.view"]), + }); + mockPrisma.dashboardRoleAssignment.create.mockResolvedValue({ + id: "assignment-2", + userId: "user-2", + assignedBy: "user-1", + createdAt: new Date(), + }); + + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/dashboard-roles/role-1/members", + cookies: { session: app.signCookie("valid") }, + payload: { userId: "user-2" }, + }); + + expect(res.statusCode).toBe(201); + }); + }); }); From 2370a42a858bb805af6f4f9243ae33429a4d94a1 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 18:18:47 +0300 Subject: [PATCH 11/36] feat(guilds): list guilds where the user holds dashboard grants Co-Authored-By: Claude Opus 5 (1M context) --- .../src/server/features/guilds/routes.ts | 66 ++++++++--- .../server/features/guilds/guilds.test.ts | 107 +++++++++++++++++- 2 files changed, 156 insertions(+), 17 deletions(-) diff --git a/apps/dashboard/src/server/features/guilds/routes.ts b/apps/dashboard/src/server/features/guilds/routes.ts index 54200a30..d332f579 100644 --- a/apps/dashboard/src/server/features/guilds/routes.ts +++ b/apps/dashboard/src/server/features/guilds/routes.ts @@ -1,4 +1,5 @@ import type { FastifyInstance } from "fastify"; +import { getPrisma } from "@fluxcore/database"; import { withDocs } from "../../shared/openapi-schemas.js"; import { requireAuth } from "../../shared/middleware.js"; import { isBotInGuild } from "../../shared/discordApi.js"; @@ -7,25 +8,60 @@ import { forceRefreshSessionGuilds, type OAuthGuild } from "../../shared/session import { rateLimits } from "../../shared/rateLimit.js"; /** - * Filter the user's OAuth guilds down to the ones they can manage from the - * dashboard: they own it or have Administrator/Manage Server. + * Guild IDs where this user holds an explicit dashboard grant — a role + * assignment or a per-user override. These admit a user who has no Discord + * MANAGE_GUILD at all. + */ +async function guildIdsWithGrants(userId: string): Promise> { + const prisma = getPrisma(); + const [assignments, overrides] = await Promise.all([ + prisma.dashboardRoleAssignment.findMany({ + where: { userId }, + select: { guildId: true }, + distinct: ["guildId"], + }), + prisma.dashboardUserPermission.findMany({ + where: { userId }, + select: { guildId: true }, + distinct: ["guildId"], + }), + ]); + + return new Set([ + ...assignments.map((a) => a.guildId), + ...overrides.map((o) => o.guildId), + ]); +} + +/** + * Filter the user's OAuth guilds down to the ones they can open in the + * dashboard: they own it, have Administrator/Manage Server, or hold an explicit + * dashboard grant there. + * + * Intersecting grants with the OAuth guild list is also the membership check — + * a grant row for a guild the user has left cannot resurface it. * * Guilds the bot has NOT been added to are included, flagged with - * `botPresent: false`, so the dashboard can offer a preselected invite for them - * instead of hiding them. This grants no access on its own — `requireGuildAccess` - * still rejects guild-scoped requests with `botNotInGuild`. + * `botPresent: false`, so the dashboard can offer a preselected invite instead + * of hiding them. This grants no access on its own — `requireGuildAccess` still + * rejects guild-scoped requests with `botNotInGuild`. * * Bot-present guilds sort first so the actionable cards lead the grid. */ -async function buildManageableGuilds(guilds: OAuthGuild[]) { - const manageable = guilds.filter( - (g) => g.owner || canManageGuild(g.permissions), - ); +async function buildManageableGuilds(userId: string, guilds: OAuthGuild[]) { + const grantedIds = await guildIdsWithGrants(userId); + + const visible = guilds + .map((guild) => ({ + guild, + isAdmin: guild.owner || canManageGuild(guild.permissions), + })) + .filter((entry) => entry.isAdmin || grantedIds.has(entry.guild.id)); const checks = await Promise.all( - manageable.map(async (g) => ({ - guild: g, - botPresent: await isBotInGuild(g.id), + visible.map(async (entry) => ({ + ...entry, + botPresent: await isBotInGuild(entry.guild.id), })), ); @@ -35,6 +71,7 @@ async function buildManageableGuilds(guilds: OAuthGuild[]) { name: c.guild.name, icon: c.guild.icon, botPresent: c.botPresent, + access: c.isAdmin ? "admin" : "delegated", })) .sort( (a, b) => @@ -53,6 +90,7 @@ const guildListResponseSchema = { name: { type: "string" }, icon: { type: ["string", "null"] }, botPresent: { type: "boolean" }, + access: { type: "string" }, }, }, }, @@ -70,7 +108,7 @@ export function registerGuildRoutes(app: FastifyInstance): void { }, async (request, reply) => { const session = request.session!; - reply.send(await buildManageableGuilds(session.guilds)); + reply.send(await buildManageableGuilds(session.userId, session.guilds)); }, ); @@ -90,7 +128,7 @@ export function registerGuildRoutes(app: FastifyInstance): void { }, async (request, reply) => { const guilds = await forceRefreshSessionGuilds(request.sessionId!); - reply.send(await buildManageableGuilds(guilds)); + reply.send(await buildManageableGuilds(request.session!.userId, guilds)); }, ); } diff --git a/apps/dashboard/tests/server/features/guilds/guilds.test.ts b/apps/dashboard/tests/server/features/guilds/guilds.test.ts index c74c7086..7b4b099d 100644 --- a/apps/dashboard/tests/server/features/guilds/guilds.test.ts +++ b/apps/dashboard/tests/server/features/guilds/guilds.test.ts @@ -36,6 +36,15 @@ vi.mock("@fluxcore/utils", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); +const mockAssignmentFindMany = vi.fn().mockResolvedValue([]); +const mockUserPermissionFindMany = vi.fn().mockResolvedValue([]); +vi.mock("@fluxcore/database", () => ({ + getPrisma: () => ({ + dashboardRoleAssignment: { findMany: mockAssignmentFindMany }, + dashboardUserPermission: { findMany: mockUserPermissionFindMany }, + }), +})); + import Fastify from "fastify"; import fastifyCookie from "@fastify/cookie"; import { registerGuildRoutes } from "../../../../src/server/features/guilds/routes.js"; @@ -111,8 +120,8 @@ describe("guild routes", () => { expect(res.statusCode).toBe(200); expect(res.json()).toEqual([ - { id: "g1", name: "Has Bot", icon: "abc", botPresent: true }, - { id: "g2", name: "No Bot", icon: null, botPresent: false }, + { id: "g1", name: "Has Bot", icon: "abc", botPresent: true, access: "admin" }, + { id: "g2", name: "No Bot", icon: null, botPresent: false, access: "admin" }, ]); }); @@ -203,6 +212,98 @@ describe("guild routes", () => { expect(res.statusCode).toBe(200); expect(res.json()).toEqual([]); }); + + it("includes a guild the user cannot manage but holds a dashboard grant in", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [ + { id: "g1", name: "Guild 1", icon: null, permissions: "0" }, + { id: "g2", name: "Guild 2", icon: null, permissions: "0" }, + ], + }); + mockAssignmentFindMany.mockResolvedValueOnce([{ guildId: "g1" }]); + + const res = await app.inject({ + method: "GET", + url: "/api/guilds", + cookies: { session: app.signCookie("valid-id") }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json>()).toEqual([ + expect.objectContaining({ id: "g1", access: "delegated" }), + ]); + }); + + it("includes a guild granted only through a per-user override", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [{ id: "g1", name: "Guild 1", icon: null, permissions: "0" }], + }); + mockUserPermissionFindMany.mockResolvedValueOnce([{ guildId: "g1" }]); + + const res = await app.inject({ + method: "GET", + url: "/api/guilds", + cookies: { session: app.signCookie("valid-id") }, + }); + + expect(res.json>()).toHaveLength(1); + }); + + it("marks manageable guilds as admin access", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [{ id: "g1", name: "Guild 1", icon: null, permissions: MANAGE_GUILD.toString() }], + }); + + const res = await app.inject({ + method: "GET", + url: "/api/guilds", + cookies: { session: app.signCookie("valid-id") }, + }); + + expect(res.json>()[0].access).toBe("admin"); + }); + + it("ignores a grant for a guild the user is no longer in", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [{ id: "g1", name: "Guild 1", icon: null, permissions: "0" }], + }); + mockAssignmentFindMany.mockResolvedValueOnce([{ guildId: "g-gone" }]); + + const res = await app.inject({ + method: "GET", + url: "/api/guilds", + cookies: { session: app.signCookie("valid-id") }, + }); + + expect(res.json>()).toEqual([]); + }); + + it("counts a guild once when the user is both an admin and a grantee", async () => { + mockGetSession.mockResolvedValueOnce({ + userId: "user-1", + username: "testuser", + guilds: [{ id: "g1", name: "Guild 1", icon: null, permissions: MANAGE_GUILD.toString() }], + }); + mockAssignmentFindMany.mockResolvedValueOnce([{ guildId: "g1" }]); + + const res = await app.inject({ + method: "GET", + url: "/api/guilds", + cookies: { session: app.signCookie("valid-id") }, + }); + + expect(res.json>()).toEqual([ + expect.objectContaining({ id: "g1", access: "admin" }), + ]); + }); }); describe("POST /api/guilds/refresh", () => { @@ -252,7 +353,7 @@ describe("guild routes", () => { expect(res.statusCode).toBe(200); expect(res.json()).toEqual([ - { id: "g9", name: "Newly Admin", icon: null, botPresent: false }, + { id: "g9", name: "Newly Admin", icon: null, botPresent: false, access: "admin" }, ]); }); }); From eeac7b24a06385c38d4bb3b909e30fe8d5b69603 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 18:30:05 +0300 Subject: [PATCH 12/36] feat(guilds): badge servers reached through delegated access --- .../client/shared/components/GuildCard.tsx | 7 +++++++ .../src/client/shared/lib/schemas.ts | 2 ++ .../features/tempvoice/HubCard.test.tsx | 2 +- .../tempvoice/TempVoiceHubList.cap.test.tsx | 2 +- .../tempvoice/TempVoiceHubList.empty.test.tsx | 2 +- .../TempVoiceHubList.focusFallback.test.tsx | 2 +- .../TempVoiceHubList.staleExpanded.test.tsx | 2 +- .../tempvoice/TempVoiceHubList.test.tsx | 2 +- .../shared/command-palette/sources.test.ts | 4 ++-- .../shared/components/GuildCard.test.tsx | 19 ++++++++++++++++++- .../shared/components/GuildSearch.test.tsx | 2 +- packages/i18n/src/locales/af/guilds.json | 1 + packages/i18n/src/locales/ar/guilds.json | 1 + packages/i18n/src/locales/bg/guilds.json | 1 + packages/i18n/src/locales/bn/guilds.json | 1 + packages/i18n/src/locales/ca/guilds.json | 1 + packages/i18n/src/locales/cs/guilds.json | 1 + packages/i18n/src/locales/da/guilds.json | 1 + packages/i18n/src/locales/de/guilds.json | 1 + packages/i18n/src/locales/el/guilds.json | 1 + packages/i18n/src/locales/en/guilds.json | 1 + packages/i18n/src/locales/es/guilds.json | 1 + packages/i18n/src/locales/et/guilds.json | 1 + packages/i18n/src/locales/eu/guilds.json | 1 + packages/i18n/src/locales/fa/guilds.json | 1 + packages/i18n/src/locales/fi/guilds.json | 1 + packages/i18n/src/locales/fil/guilds.json | 1 + packages/i18n/src/locales/fr/guilds.json | 1 + packages/i18n/src/locales/gl/guilds.json | 1 + packages/i18n/src/locales/he/guilds.json | 1 + packages/i18n/src/locales/hi/guilds.json | 1 + packages/i18n/src/locales/hr/guilds.json | 1 + packages/i18n/src/locales/hu/guilds.json | 1 + packages/i18n/src/locales/id/guilds.json | 1 + packages/i18n/src/locales/it/guilds.json | 1 + packages/i18n/src/locales/ja/guilds.json | 1 + packages/i18n/src/locales/ko/guilds.json | 1 + packages/i18n/src/locales/lt/guilds.json | 1 + packages/i18n/src/locales/lv/guilds.json | 1 + packages/i18n/src/locales/ms/guilds.json | 1 + packages/i18n/src/locales/nl/guilds.json | 1 + packages/i18n/src/locales/no/guilds.json | 1 + packages/i18n/src/locales/pl/guilds.json | 1 + packages/i18n/src/locales/pt/guilds.json | 1 + packages/i18n/src/locales/ro/guilds.json | 1 + packages/i18n/src/locales/ru/guilds.json | 1 + packages/i18n/src/locales/sk/guilds.json | 1 + packages/i18n/src/locales/sl/guilds.json | 1 + packages/i18n/src/locales/sr/guilds.json | 1 + packages/i18n/src/locales/sv/guilds.json | 1 + packages/i18n/src/locales/sw/guilds.json | 1 + packages/i18n/src/locales/ta/guilds.json | 1 + packages/i18n/src/locales/th/guilds.json | 1 + packages/i18n/src/locales/tr/guilds.json | 1 + packages/i18n/src/locales/uk/guilds.json | 1 + packages/i18n/src/locales/ur/guilds.json | 1 + packages/i18n/src/locales/vi/guilds.json | 1 + packages/i18n/src/locales/zh-CN/guilds.json | 1 + packages/i18n/src/locales/zh-TW/guilds.json | 1 + 59 files changed, 84 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/src/client/shared/components/GuildCard.tsx b/apps/dashboard/src/client/shared/components/GuildCard.tsx index 30ccc5b9..236da27c 100644 --- a/apps/dashboard/src/client/shared/components/GuildCard.tsx +++ b/apps/dashboard/src/client/shared/components/GuildCard.tsx @@ -104,6 +104,8 @@ export function GuildCard({ guild: Guild; inviteUrl?: string; }) { + const { t } = useTranslation("guilds"); + if (!guild.botPresent) { return ; } @@ -122,6 +124,11 @@ export function GuildCard({

{guild.name}

+ {guild.access === "delegated" && ( +
+ {t("badge.delegated")} +
+ )} ); diff --git a/apps/dashboard/src/client/shared/lib/schemas.ts b/apps/dashboard/src/client/shared/lib/schemas.ts index cdd7fb1a..fe54d3df 100644 --- a/apps/dashboard/src/client/shared/lib/schemas.ts +++ b/apps/dashboard/src/client/shared/lib/schemas.ts @@ -15,6 +15,8 @@ export const GuildSchema = z.object({ icon: z.string().nullable(), /** False when the user administers the guild but the bot has not been added. */ botPresent: z.boolean(), + /** "delegated" = access comes from dashboard grants, not Discord admin rights. */ + access: z.enum(["admin", "delegated"]), }); export type Guild = z.infer; diff --git a/apps/dashboard/tests/client/features/tempvoice/HubCard.test.tsx b/apps/dashboard/tests/client/features/tempvoice/HubCard.test.tsx index 10682727..91495d62 100644 --- a/apps/dashboard/tests/client/features/tempvoice/HubCard.test.tsx +++ b/apps/dashboard/tests/client/features/tempvoice/HubCard.test.tsx @@ -33,7 +33,7 @@ vi.mock("../../../../src/client/shared/hooks/useAuth", () => ({ })); vi.mock("../../../../src/client/shared/hooks/useGuilds", () => ({ useGuilds: () => ({ - data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true }], + data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true, access: "admin" }], }), })); diff --git a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.cap.test.tsx b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.cap.test.tsx index e0920e45..b82e8736 100644 --- a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.cap.test.tsx +++ b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.cap.test.tsx @@ -55,7 +55,7 @@ vi.mock("../../../../src/client/shared/hooks/useAuth", () => ({ })); vi.mock("../../../../src/client/shared/hooks/useGuilds", () => ({ useGuilds: () => ({ - data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true }], + data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true, access: "admin" }], }), })); diff --git a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.empty.test.tsx b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.empty.test.tsx index dc0e53f3..f53eb63c 100644 --- a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.empty.test.tsx +++ b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.empty.test.tsx @@ -57,7 +57,7 @@ vi.mock("../../../../src/client/shared/hooks/useAuth", () => ({ })); vi.mock("../../../../src/client/shared/hooks/useGuilds", () => ({ useGuilds: () => ({ - data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true }], + data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true, access: "admin" }], }), })); diff --git a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.focusFallback.test.tsx b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.focusFallback.test.tsx index b4106adf..b9d82edd 100644 --- a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.focusFallback.test.tsx +++ b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.focusFallback.test.tsx @@ -74,7 +74,7 @@ vi.mock("../../../../src/client/shared/hooks/useAuth", () => ({ })); vi.mock("../../../../src/client/shared/hooks/useGuilds", () => ({ useGuilds: () => ({ - data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true }], + data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true, access: "admin" }], }), })); diff --git a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.staleExpanded.test.tsx b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.staleExpanded.test.tsx index 682a4974..3e5108a4 100644 --- a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.staleExpanded.test.tsx +++ b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.staleExpanded.test.tsx @@ -64,7 +64,7 @@ vi.mock("../../../../src/client/shared/hooks/useAuth", () => ({ })); vi.mock("../../../../src/client/shared/hooks/useGuilds", () => ({ useGuilds: () => ({ - data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true }], + data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true, access: "admin" }], }), })); diff --git a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.test.tsx b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.test.tsx index ea79ac06..9057f8f1 100644 --- a/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.test.tsx +++ b/apps/dashboard/tests/client/features/tempvoice/TempVoiceHubList.test.tsx @@ -68,7 +68,7 @@ vi.mock("../../../../src/client/shared/hooks/useAuth", () => ({ })); vi.mock("../../../../src/client/shared/hooks/useGuilds", () => ({ useGuilds: () => ({ - data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true }], + data: [{ id: "g1", name: "Test Guild", icon: null, botPresent: true, access: "admin" }], }), })); diff --git a/apps/dashboard/tests/client/shared/command-palette/sources.test.ts b/apps/dashboard/tests/client/shared/command-palette/sources.test.ts index 998029e9..ddd8fc06 100644 --- a/apps/dashboard/tests/client/shared/command-palette/sources.test.ts +++ b/apps/dashboard/tests/client/shared/command-palette/sources.test.ts @@ -43,8 +43,8 @@ describe("pageCommands", () => { describe("serverCommands", () => { const guilds: Guild[] = [ - { id: "g1", name: "Etqan", icon: null, botPresent: true }, - { id: "g2", name: "No Bot", icon: null, botPresent: false }, + { id: "g1", name: "Etqan", icon: null, botPresent: true, access: "admin" }, + { id: "g2", name: "No Bot", icon: null, botPresent: false, access: "admin" }, ]; it("offers only servers the bot is actually in", () => { diff --git a/apps/dashboard/tests/client/shared/components/GuildCard.test.tsx b/apps/dashboard/tests/client/shared/components/GuildCard.test.tsx index 291326c1..31a0efb1 100644 --- a/apps/dashboard/tests/client/shared/components/GuildCard.test.tsx +++ b/apps/dashboard/tests/client/shared/components/GuildCard.test.tsx @@ -41,7 +41,14 @@ const INVITE = "https://discord.com/oauth2/authorize?client_id=abc&permissions=8&scope=bot%20applications.commands"; function makeGuild(over: Partial = {}): Guild { - return { id: "123", name: "Test Guild", icon: null, botPresent: true, ...over }; + return { + id: "123", + name: "Test Guild", + icon: null, + botPresent: true, + access: "admin", + ...over, + }; } describe("GuildCard", () => { @@ -80,4 +87,14 @@ describe("GuildCard", () => { expect(screen.getByText("badge.botNotAdded")).toBeInTheDocument(); expect(screen.getByText("Test Guild")).toBeInTheDocument(); }); + + it("badges a delegated guild", () => { + render(); + expect(screen.getByText("badge.delegated")).toBeInTheDocument(); + }); + + it("does not badge an admin guild", () => { + render(); + expect(screen.queryByText("badge.delegated")).not.toBeInTheDocument(); + }); }); diff --git a/apps/dashboard/tests/client/shared/components/GuildSearch.test.tsx b/apps/dashboard/tests/client/shared/components/GuildSearch.test.tsx index e1c685d9..548866f1 100644 --- a/apps/dashboard/tests/client/shared/components/GuildSearch.test.tsx +++ b/apps/dashboard/tests/client/shared/components/GuildSearch.test.tsx @@ -18,7 +18,7 @@ vi.mock("react-i18next", () => ({ })); function g(name: string, over: Partial = {}): Guild { - return { id: name, name, icon: null, botPresent: true, ...over }; + return { id: name, name, icon: null, botPresent: true, access: "admin", ...over }; } describe("filterGuilds", () => { diff --git a/packages/i18n/src/locales/af/guilds.json b/packages/i18n/src/locales/af/guilds.json index a258fc02..315dc54c 100644 --- a/packages/i18n/src/locales/af/guilds.json +++ b/packages/i18n/src/locales/af/guilds.json @@ -8,6 +8,7 @@ "description": "Maak seker jy het die Bestuur Bediener-toestemming in ten minste een Discord-bediener, en herlaai dan." }, "badge": { + "delegated": "Gedelegeerde toegang", "botNotAdded": "Bot nie bygevoeg nie" }, "addBot": "Voeg FluxCore by", diff --git a/packages/i18n/src/locales/ar/guilds.json b/packages/i18n/src/locales/ar/guilds.json index abafc332..3cd0f66e 100644 --- a/packages/i18n/src/locales/ar/guilds.json +++ b/packages/i18n/src/locales/ar/guilds.json @@ -8,6 +8,7 @@ "description": "تأكد من امتلاكك صلاحية إدارة الخادم في خادم Discord واحد على الأقل، ثم قم بالتحديث." }, "badge": { + "delegated": "وصول مفوَّض", "botNotAdded": "البوت غير مُضاف" }, "addBot": "أضف FluxCore", diff --git a/packages/i18n/src/locales/bg/guilds.json b/packages/i18n/src/locales/bg/guilds.json index d0873fb4..b5794889 100644 --- a/packages/i18n/src/locales/bg/guilds.json +++ b/packages/i18n/src/locales/bg/guilds.json @@ -8,6 +8,7 @@ "description": "Уверете се, че имате разрешение „Управление на сървъра“ поне в един Discord сървър, след което обновете." }, "badge": { + "delegated": "Делегиран достъп", "botNotAdded": "Ботът не е добавен" }, "addBot": "Добави FluxCore", diff --git a/packages/i18n/src/locales/bn/guilds.json b/packages/i18n/src/locales/bn/guilds.json index 1074d059..45ba3346 100644 --- a/packages/i18n/src/locales/bn/guilds.json +++ b/packages/i18n/src/locales/bn/guilds.json @@ -8,6 +8,7 @@ "description": "নিশ্চিত করুন যে অন্তত একটি Discord সার্ভারে আপনার সার্ভার পরিচালনার অনুমতি আছে, তারপর রিফ্রেশ করুন।" }, "badge": { + "delegated": "অর্পিত অ্যাক্সেস", "botNotAdded": "বট যোগ করা হয়নি" }, "addBot": "FluxCore যোগ করুন", diff --git a/packages/i18n/src/locales/ca/guilds.json b/packages/i18n/src/locales/ca/guilds.json index 65fd8f5d..bafdfb6e 100644 --- a/packages/i18n/src/locales/ca/guilds.json +++ b/packages/i18n/src/locales/ca/guilds.json @@ -8,6 +8,7 @@ "description": "Assegura't que tens el permís Gestionar el servidor en almenys un servidor de Discord i després actualitza." }, "badge": { + "delegated": "Accés delegat", "botNotAdded": "Bot no afegit" }, "addBot": "Afegeix FluxCore", diff --git a/packages/i18n/src/locales/cs/guilds.json b/packages/i18n/src/locales/cs/guilds.json index d5ec2242..3e61a14c 100644 --- a/packages/i18n/src/locales/cs/guilds.json +++ b/packages/i18n/src/locales/cs/guilds.json @@ -8,6 +8,7 @@ "description": "Ujisti se, že máš oprávnění Spravovat server alespoň na jednom Discord serveru, a poté obnov." }, "badge": { + "delegated": "Delegovaný přístup", "botNotAdded": "Bot není přidán" }, "addBot": "Přidat FluxCore", diff --git a/packages/i18n/src/locales/da/guilds.json b/packages/i18n/src/locales/da/guilds.json index a9a726dd..11e1f232 100644 --- a/packages/i18n/src/locales/da/guilds.json +++ b/packages/i18n/src/locales/da/guilds.json @@ -8,6 +8,7 @@ "description": "Sørg for, at du har tilladelsen Administrer server på mindst én Discord-server, og opdater derefter." }, "badge": { + "delegated": "Delegeret adgang", "botNotAdded": "Bot ikke tilføjet" }, "addBot": "Tilføj FluxCore", diff --git a/packages/i18n/src/locales/de/guilds.json b/packages/i18n/src/locales/de/guilds.json index eaa56c3a..81d56b85 100644 --- a/packages/i18n/src/locales/de/guilds.json +++ b/packages/i18n/src/locales/de/guilds.json @@ -8,6 +8,7 @@ "description": "Stelle sicher, dass du auf mindestens einem Discord-Server die Berechtigung „Server verwalten“ hast, und aktualisiere dann." }, "badge": { + "delegated": "Delegierter Zugriff", "botNotAdded": "Bot nicht hinzugefügt" }, "addBot": "FluxCore hinzufügen", diff --git a/packages/i18n/src/locales/el/guilds.json b/packages/i18n/src/locales/el/guilds.json index b737466e..583cabbc 100644 --- a/packages/i18n/src/locales/el/guilds.json +++ b/packages/i18n/src/locales/el/guilds.json @@ -8,6 +8,7 @@ "description": "Βεβαιώσου ότι έχεις το δικαίωμα Διαχείριση διακομιστή σε τουλάχιστον έναν διακομιστή Discord και μετά ανανέωσε." }, "badge": { + "delegated": "Παραχωρημένη πρόσβαση", "botNotAdded": "Το bot δεν έχει προστεθεί" }, "addBot": "Προσθήκη FluxCore", diff --git a/packages/i18n/src/locales/en/guilds.json b/packages/i18n/src/locales/en/guilds.json index 1469c2fa..6d5f0c32 100644 --- a/packages/i18n/src/locales/en/guilds.json +++ b/packages/i18n/src/locales/en/guilds.json @@ -8,6 +8,7 @@ "description": "Make sure you have the Manage Server permission in at least one Discord server, then refresh." }, "badge": { + "delegated": "Delegated access", "botNotAdded": "Bot not added" }, "addBot": "Add FluxCore", diff --git a/packages/i18n/src/locales/es/guilds.json b/packages/i18n/src/locales/es/guilds.json index 70cb8d72..c553de1f 100644 --- a/packages/i18n/src/locales/es/guilds.json +++ b/packages/i18n/src/locales/es/guilds.json @@ -8,6 +8,7 @@ "description": "Asegúrate de tener el permiso Gestionar servidor en al menos un servidor de Discord y luego actualiza." }, "badge": { + "delegated": "Acceso delegado", "botNotAdded": "Bot no añadido" }, "addBot": "Añadir FluxCore", diff --git a/packages/i18n/src/locales/et/guilds.json b/packages/i18n/src/locales/et/guilds.json index 04a3b287..561d238b 100644 --- a/packages/i18n/src/locales/et/guilds.json +++ b/packages/i18n/src/locales/et/guilds.json @@ -8,6 +8,7 @@ "description": "Veendu, et sul on vähemalt ühes Discordi serveris õigus Halda serverit, ja seejärel värskenda." }, "badge": { + "delegated": "Delegeeritud juurdepääs", "botNotAdded": "Bot pole lisatud" }, "addBot": "Lisa FluxCore", diff --git a/packages/i18n/src/locales/eu/guilds.json b/packages/i18n/src/locales/eu/guilds.json index 2a7c0490..a327b8ef 100644 --- a/packages/i18n/src/locales/eu/guilds.json +++ b/packages/i18n/src/locales/eu/guilds.json @@ -8,6 +8,7 @@ "description": "Ziurtatu Discord zerbitzari batean gutxienez Zerbitzaria kudeatzeko baimena duzula, eta gero freskatu." }, "badge": { + "delegated": "Delegatutako sarbidea", "botNotAdded": "Bota ez da gehitu" }, "addBot": "Gehitu FluxCore", diff --git a/packages/i18n/src/locales/fa/guilds.json b/packages/i18n/src/locales/fa/guilds.json index 859d6242..6c14e2fd 100644 --- a/packages/i18n/src/locales/fa/guilds.json +++ b/packages/i18n/src/locales/fa/guilds.json @@ -8,6 +8,7 @@ "description": "مطمئن شوید که در حداقل یک سرور Discord دسترسی مدیریت سرور دارید، سپس به‌روزرسانی کنید." }, "badge": { + "delegated": "دسترسی تفویضی", "botNotAdded": "ربات اضافه نشده است" }, "addBot": "افزودن FluxCore", diff --git a/packages/i18n/src/locales/fi/guilds.json b/packages/i18n/src/locales/fi/guilds.json index f398472a..7a984cd7 100644 --- a/packages/i18n/src/locales/fi/guilds.json +++ b/packages/i18n/src/locales/fi/guilds.json @@ -8,6 +8,7 @@ "description": "Varmista, että sinulla on Hallitse palvelinta -oikeus vähintään yhdellä Discord-palvelimella, ja päivitä sitten." }, "badge": { + "delegated": "Delegoitu käyttöoikeus", "botNotAdded": "Bottia ei ole lisätty" }, "addBot": "Lisää FluxCore", diff --git a/packages/i18n/src/locales/fil/guilds.json b/packages/i18n/src/locales/fil/guilds.json index 3e198974..f328a92e 100644 --- a/packages/i18n/src/locales/fil/guilds.json +++ b/packages/i18n/src/locales/fil/guilds.json @@ -8,6 +8,7 @@ "description": "Siguraduhing mayroon kang pahintulot na Manage Server sa kahit isang Discord server, pagkatapos ay i-refresh." }, "badge": { + "delegated": "Delegadong access", "botNotAdded": "Hindi pa naidaragdag ang bot" }, "addBot": "Idagdag ang FluxCore", diff --git a/packages/i18n/src/locales/fr/guilds.json b/packages/i18n/src/locales/fr/guilds.json index d6ef56e9..c28a3809 100644 --- a/packages/i18n/src/locales/fr/guilds.json +++ b/packages/i18n/src/locales/fr/guilds.json @@ -8,6 +8,7 @@ "description": "Assurez-vous d'avoir la permission Gérer le serveur sur au moins un serveur Discord, puis actualisez." }, "badge": { + "delegated": "Accès délégué", "botNotAdded": "Bot non ajouté" }, "addBot": "Ajouter FluxCore", diff --git a/packages/i18n/src/locales/gl/guilds.json b/packages/i18n/src/locales/gl/guilds.json index cbb6b19d..e9d7a1bb 100644 --- a/packages/i18n/src/locales/gl/guilds.json +++ b/packages/i18n/src/locales/gl/guilds.json @@ -8,6 +8,7 @@ "description": "Asegúrate de ter o permiso Xestionar o servidor en polo menos un servidor de Discord e despois actualiza." }, "badge": { + "delegated": "Acceso delegado", "botNotAdded": "Bot non engadido" }, "addBot": "Engadir FluxCore", diff --git a/packages/i18n/src/locales/he/guilds.json b/packages/i18n/src/locales/he/guilds.json index b0b61597..c107fff6 100644 --- a/packages/i18n/src/locales/he/guilds.json +++ b/packages/i18n/src/locales/he/guilds.json @@ -8,6 +8,7 @@ "description": "ודא שיש לך הרשאת ניהול שרת בשרת Discord אחד לפחות, ולאחר מכן רענן." }, "badge": { + "delegated": "גישה מואצלת", "botNotAdded": "הבוט לא נוסף" }, "addBot": "הוסף את FluxCore", diff --git a/packages/i18n/src/locales/hi/guilds.json b/packages/i18n/src/locales/hi/guilds.json index 7b226ff1..d4cda40a 100644 --- a/packages/i18n/src/locales/hi/guilds.json +++ b/packages/i18n/src/locales/hi/guilds.json @@ -8,6 +8,7 @@ "description": "सुनिश्चित करें कि कम से कम एक Discord सर्वर पर आपके पास सर्वर प्रबंधित करने की अनुमति है, फिर रीफ़्रेश करें।" }, "badge": { + "delegated": "प्रत्यायोजित पहुँच", "botNotAdded": "बॉट नहीं जोड़ा गया" }, "addBot": "FluxCore जोड़ें", diff --git a/packages/i18n/src/locales/hr/guilds.json b/packages/i18n/src/locales/hr/guilds.json index b2ee54f3..58226d33 100644 --- a/packages/i18n/src/locales/hr/guilds.json +++ b/packages/i18n/src/locales/hr/guilds.json @@ -8,6 +8,7 @@ "description": "Provjeri imaš li dozvolu Upravljanje poslužiteljem na barem jednom Discord poslužitelju, a zatim osvježi." }, "badge": { + "delegated": "Delegirani pristup", "botNotAdded": "Bot nije dodan" }, "addBot": "Dodaj FluxCore", diff --git a/packages/i18n/src/locales/hu/guilds.json b/packages/i18n/src/locales/hu/guilds.json index f9a5beb5..ffcd6b5f 100644 --- a/packages/i18n/src/locales/hu/guilds.json +++ b/packages/i18n/src/locales/hu/guilds.json @@ -8,6 +8,7 @@ "description": "Győződj meg róla, hogy legalább egy Discord szerveren rendelkezel a Szerver kezelése jogosultsággal, majd frissíts." }, "badge": { + "delegated": "Átruházott hozzáférés", "botNotAdded": "A bot nincs hozzáadva" }, "addBot": "FluxCore hozzáadása", diff --git a/packages/i18n/src/locales/id/guilds.json b/packages/i18n/src/locales/id/guilds.json index 917adcaf..c85ac062 100644 --- a/packages/i18n/src/locales/id/guilds.json +++ b/packages/i18n/src/locales/id/guilds.json @@ -8,6 +8,7 @@ "description": "Pastikan kamu memiliki izin Kelola Server di setidaknya satu server Discord, lalu segarkan." }, "badge": { + "delegated": "Akses yang didelegasikan", "botNotAdded": "Bot belum ditambahkan" }, "addBot": "Tambahkan FluxCore", diff --git a/packages/i18n/src/locales/it/guilds.json b/packages/i18n/src/locales/it/guilds.json index b7d68175..6791e2af 100644 --- a/packages/i18n/src/locales/it/guilds.json +++ b/packages/i18n/src/locales/it/guilds.json @@ -8,6 +8,7 @@ "description": "Assicurati di avere il permesso Gestisci server in almeno un server Discord, quindi aggiorna." }, "badge": { + "delegated": "Accesso delegato", "botNotAdded": "Bot non aggiunto" }, "addBot": "Aggiungi FluxCore", diff --git a/packages/i18n/src/locales/ja/guilds.json b/packages/i18n/src/locales/ja/guilds.json index 99c1ed0a..af80589c 100644 --- a/packages/i18n/src/locales/ja/guilds.json +++ b/packages/i18n/src/locales/ja/guilds.json @@ -8,6 +8,7 @@ "description": "少なくとも1つのDiscordサーバーで「サーバー管理」権限を持っていることを確認してから、更新してください。" }, "badge": { + "delegated": "委任されたアクセス", "botNotAdded": "Bot未追加" }, "addBot": "FluxCoreを追加", diff --git a/packages/i18n/src/locales/ko/guilds.json b/packages/i18n/src/locales/ko/guilds.json index 2ba669df..ea0236e9 100644 --- a/packages/i18n/src/locales/ko/guilds.json +++ b/packages/i18n/src/locales/ko/guilds.json @@ -8,6 +8,7 @@ "description": "최소 한 개의 Discord 서버에서 서버 관리 권한이 있는지 확인한 후 새로고침하세요." }, "badge": { + "delegated": "위임된 액세스", "botNotAdded": "봇이 추가되지 않음" }, "addBot": "FluxCore 추가", diff --git a/packages/i18n/src/locales/lt/guilds.json b/packages/i18n/src/locales/lt/guilds.json index f6a953da..5d383af0 100644 --- a/packages/i18n/src/locales/lt/guilds.json +++ b/packages/i18n/src/locales/lt/guilds.json @@ -8,6 +8,7 @@ "description": "Įsitikink, kad bent viename Discord serveryje turi leidimą Tvarkyti serverį, tada atnaujink." }, "badge": { + "delegated": "Deleguota prieiga", "botNotAdded": "Botas nepridėtas" }, "addBot": "Pridėti FluxCore", diff --git a/packages/i18n/src/locales/lv/guilds.json b/packages/i18n/src/locales/lv/guilds.json index 70513e5a..550264ec 100644 --- a/packages/i18n/src/locales/lv/guilds.json +++ b/packages/i18n/src/locales/lv/guilds.json @@ -8,6 +8,7 @@ "description": "Pārliecinies, ka vismaz vienā Discord serverī tev ir atļauja Pārvaldīt serveri, un pēc tam atsvaidzini." }, "badge": { + "delegated": "Deleģēta piekļuve", "botNotAdded": "Bots nav pievienots" }, "addBot": "Pievienot FluxCore", diff --git a/packages/i18n/src/locales/ms/guilds.json b/packages/i18n/src/locales/ms/guilds.json index d84aeb5b..63d95ecb 100644 --- a/packages/i18n/src/locales/ms/guilds.json +++ b/packages/i18n/src/locales/ms/guilds.json @@ -8,6 +8,7 @@ "description": "Pastikan anda mempunyai kebenaran Urus Pelayan pada sekurang-kurangnya satu pelayan Discord, kemudian segar semula." }, "badge": { + "delegated": "Akses yang didelegasikan", "botNotAdded": "Bot belum ditambah" }, "addBot": "Tambah FluxCore", diff --git a/packages/i18n/src/locales/nl/guilds.json b/packages/i18n/src/locales/nl/guilds.json index c67c6655..edec89a2 100644 --- a/packages/i18n/src/locales/nl/guilds.json +++ b/packages/i18n/src/locales/nl/guilds.json @@ -8,6 +8,7 @@ "description": "Zorg dat je op minstens één Discord-server het recht Server beheren hebt en vernieuw daarna." }, "badge": { + "delegated": "Gedelegeerde toegang", "botNotAdded": "Bot niet toegevoegd" }, "addBot": "FluxCore toevoegen", diff --git a/packages/i18n/src/locales/no/guilds.json b/packages/i18n/src/locales/no/guilds.json index 3a8ad336..07dfe315 100644 --- a/packages/i18n/src/locales/no/guilds.json +++ b/packages/i18n/src/locales/no/guilds.json @@ -8,6 +8,7 @@ "description": "Sørg for at du har tillatelsen Administrer server på minst én Discord-server, og oppdater deretter." }, "badge": { + "delegated": "Delegert tilgang", "botNotAdded": "Bot ikke lagt til" }, "addBot": "Legg til FluxCore", diff --git a/packages/i18n/src/locales/pl/guilds.json b/packages/i18n/src/locales/pl/guilds.json index d6c8a68c..339122de 100644 --- a/packages/i18n/src/locales/pl/guilds.json +++ b/packages/i18n/src/locales/pl/guilds.json @@ -8,6 +8,7 @@ "description": "Upewnij się, że masz uprawnienie Zarządzanie serwerem na co najmniej jednym serwerze Discord, a następnie odśwież." }, "badge": { + "delegated": "Delegowany dostęp", "botNotAdded": "Bot nie został dodany" }, "addBot": "Dodaj FluxCore", diff --git a/packages/i18n/src/locales/pt/guilds.json b/packages/i18n/src/locales/pt/guilds.json index 6b88313d..c661fe7c 100644 --- a/packages/i18n/src/locales/pt/guilds.json +++ b/packages/i18n/src/locales/pt/guilds.json @@ -8,6 +8,7 @@ "description": "Certifica-te de que tens a permissão Gerir servidor em pelo menos um servidor do Discord e depois atualiza." }, "badge": { + "delegated": "Acesso delegado", "botNotAdded": "Bot não adicionado" }, "addBot": "Adicionar FluxCore", diff --git a/packages/i18n/src/locales/ro/guilds.json b/packages/i18n/src/locales/ro/guilds.json index 29bb86d8..42848cfc 100644 --- a/packages/i18n/src/locales/ro/guilds.json +++ b/packages/i18n/src/locales/ro/guilds.json @@ -8,6 +8,7 @@ "description": "Asigură-te că ai permisiunea Gestionare server pe cel puțin un server Discord, apoi reîmprospătează." }, "badge": { + "delegated": "Acces delegat", "botNotAdded": "Botul nu este adăugat" }, "addBot": "Adaugă FluxCore", diff --git a/packages/i18n/src/locales/ru/guilds.json b/packages/i18n/src/locales/ru/guilds.json index e39ac4d9..7b1d2dfb 100644 --- a/packages/i18n/src/locales/ru/guilds.json +++ b/packages/i18n/src/locales/ru/guilds.json @@ -8,6 +8,7 @@ "description": "Убедитесь, что у вас есть право «Управление сервером» хотя бы на одном сервере Discord, затем обновите." }, "badge": { + "delegated": "Делегированный доступ", "botNotAdded": "Бот не добавлен" }, "addBot": "Добавить FluxCore", diff --git a/packages/i18n/src/locales/sk/guilds.json b/packages/i18n/src/locales/sk/guilds.json index 41a820ee..7d7d5abf 100644 --- a/packages/i18n/src/locales/sk/guilds.json +++ b/packages/i18n/src/locales/sk/guilds.json @@ -8,6 +8,7 @@ "description": "Uisti sa, že máš oprávnenie Spravovať server aspoň na jednom Discord serveri, a potom obnov." }, "badge": { + "delegated": "Delegovaný prístup", "botNotAdded": "Bot nie je pridaný" }, "addBot": "Pridať FluxCore", diff --git a/packages/i18n/src/locales/sl/guilds.json b/packages/i18n/src/locales/sl/guilds.json index 60be222f..645e743d 100644 --- a/packages/i18n/src/locales/sl/guilds.json +++ b/packages/i18n/src/locales/sl/guilds.json @@ -8,6 +8,7 @@ "description": "Prepričaj se, da imaš dovoljenje Upravljanje strežnika vsaj na enem strežniku Discord, nato osveži." }, "badge": { + "delegated": "Delegiran dostop", "botNotAdded": "Bot ni dodan" }, "addBot": "Dodaj FluxCore", diff --git a/packages/i18n/src/locales/sr/guilds.json b/packages/i18n/src/locales/sr/guilds.json index 90babd33..8e9120b0 100644 --- a/packages/i18n/src/locales/sr/guilds.json +++ b/packages/i18n/src/locales/sr/guilds.json @@ -8,6 +8,7 @@ "description": "Uverite se da imate dozvolu Upravljanje serverom na bar jednom Discord serveru, a zatim osvežite." }, "badge": { + "delegated": "Delegirani pristup", "botNotAdded": "Bot nije dodat" }, "addBot": "Dodaj FluxCore", diff --git a/packages/i18n/src/locales/sv/guilds.json b/packages/i18n/src/locales/sv/guilds.json index f8eb817a..0778084f 100644 --- a/packages/i18n/src/locales/sv/guilds.json +++ b/packages/i18n/src/locales/sv/guilds.json @@ -8,6 +8,7 @@ "description": "Se till att du har behörigheten Hantera server på minst en Discord-server och uppdatera sedan." }, "badge": { + "delegated": "Delegerad åtkomst", "botNotAdded": "Bot inte tillagd" }, "addBot": "Lägg till FluxCore", diff --git a/packages/i18n/src/locales/sw/guilds.json b/packages/i18n/src/locales/sw/guilds.json index 64dbc5c4..a95af1a6 100644 --- a/packages/i18n/src/locales/sw/guilds.json +++ b/packages/i18n/src/locales/sw/guilds.json @@ -8,6 +8,7 @@ "description": "Hakikisha una ruhusa ya Kudhibiti Seva katika angalau seva moja ya Discord, kisha onyesha upya." }, "badge": { + "delegated": "Ufikiaji uliokabidhiwa", "botNotAdded": "Boti haijaongezwa" }, "addBot": "Ongeza FluxCore", diff --git a/packages/i18n/src/locales/ta/guilds.json b/packages/i18n/src/locales/ta/guilds.json index 2587a194..25a27aaa 100644 --- a/packages/i18n/src/locales/ta/guilds.json +++ b/packages/i18n/src/locales/ta/guilds.json @@ -8,6 +8,7 @@ "description": "குறைந்தது ஒரு Discord சேவையகத்தில் சேவையகத்தை நிர்வகிக்கும் அனுமதி உங்களிடம் உள்ளதா என்பதை உறுதிசெய்து, பிறகு புதுப்பிக்கவும்." }, "badge": { + "delegated": "ஒப்படைக்கப்பட்ட அணுகல்", "botNotAdded": "பாட் சேர்க்கப்படவில்லை" }, "addBot": "FluxCore ஐச் சேர்", diff --git a/packages/i18n/src/locales/th/guilds.json b/packages/i18n/src/locales/th/guilds.json index b3aa789e..e88d2ead 100644 --- a/packages/i18n/src/locales/th/guilds.json +++ b/packages/i18n/src/locales/th/guilds.json @@ -8,6 +8,7 @@ "description": "ตรวจสอบว่าคุณมีสิทธิ์จัดการเซิร์ฟเวอร์ในเซิร์ฟเวอร์ Discord อย่างน้อยหนึ่งแห่ง จากนั้นรีเฟรช" }, "badge": { + "delegated": "การเข้าถึงที่ได้รับมอบหมาย", "botNotAdded": "ยังไม่ได้เพิ่มบอท" }, "addBot": "เพิ่ม FluxCore", diff --git a/packages/i18n/src/locales/tr/guilds.json b/packages/i18n/src/locales/tr/guilds.json index 74cb5cce..3af60f56 100644 --- a/packages/i18n/src/locales/tr/guilds.json +++ b/packages/i18n/src/locales/tr/guilds.json @@ -8,6 +8,7 @@ "description": "En az bir Discord sunucusunda Sunucuyu Yönet iznine sahip olduğundan emin ol, ardından yenile." }, "badge": { + "delegated": "Devredilmiş erişim", "botNotAdded": "Bot eklenmedi" }, "addBot": "FluxCore ekle", diff --git a/packages/i18n/src/locales/uk/guilds.json b/packages/i18n/src/locales/uk/guilds.json index 5ad20284..d0bb5d04 100644 --- a/packages/i18n/src/locales/uk/guilds.json +++ b/packages/i18n/src/locales/uk/guilds.json @@ -8,6 +8,7 @@ "description": "Переконайтеся, що ви маєте право «Керування сервером» щонайменше на одному сервері Discord, а потім оновіть." }, "badge": { + "delegated": "Делегований доступ", "botNotAdded": "Бот не доданий" }, "addBot": "Додати FluxCore", diff --git a/packages/i18n/src/locales/ur/guilds.json b/packages/i18n/src/locales/ur/guilds.json index 99b3852e..11e32b9c 100644 --- a/packages/i18n/src/locales/ur/guilds.json +++ b/packages/i18n/src/locales/ur/guilds.json @@ -8,6 +8,7 @@ "description": "یقینی بنائیں کہ کم از کم ایک Discord سرور پر آپ کے پاس سرور کے انتظام کی اجازت ہے، پھر تازہ کریں۔" }, "badge": { + "delegated": "تفویض شدہ رسائی", "botNotAdded": "بوٹ شامل نہیں ہے" }, "addBot": "FluxCore شامل کریں", diff --git a/packages/i18n/src/locales/vi/guilds.json b/packages/i18n/src/locales/vi/guilds.json index ab71360a..f8448ffa 100644 --- a/packages/i18n/src/locales/vi/guilds.json +++ b/packages/i18n/src/locales/vi/guilds.json @@ -8,6 +8,7 @@ "description": "Hãy đảm bảo bạn có quyền Quản lý máy chủ trên ít nhất một máy chủ Discord, sau đó làm mới." }, "badge": { + "delegated": "Quyền truy cập được ủy quyền", "botNotAdded": "Chưa thêm bot" }, "addBot": "Thêm FluxCore", diff --git a/packages/i18n/src/locales/zh-CN/guilds.json b/packages/i18n/src/locales/zh-CN/guilds.json index e9386a27..0f58decf 100644 --- a/packages/i18n/src/locales/zh-CN/guilds.json +++ b/packages/i18n/src/locales/zh-CN/guilds.json @@ -8,6 +8,7 @@ "description": "请确认你在至少一个 Discord 服务器中拥有“管理服务器”权限,然后刷新。" }, "badge": { + "delegated": "委派访问权限", "botNotAdded": "机器人未添加" }, "addBot": "添加 FluxCore", diff --git a/packages/i18n/src/locales/zh-TW/guilds.json b/packages/i18n/src/locales/zh-TW/guilds.json index 4f45c1f9..5caf70df 100644 --- a/packages/i18n/src/locales/zh-TW/guilds.json +++ b/packages/i18n/src/locales/zh-TW/guilds.json @@ -8,6 +8,7 @@ "description": "請確認你在至少一個 Discord 伺服器中擁有「管理伺服器」權限,然後重新整理。" }, "badge": { + "delegated": "委派存取權限", "botNotAdded": "尚未新增機器人" }, "addBot": "新增 FluxCore", From 232a9b5862b707d7bac2e1c95a2774f4e462a0b2 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 18:40:30 +0300 Subject: [PATCH 13/36] feat(permissions): derive the permission registry from the route table --- apps/dashboard/src/server/index.ts | 6 ++ .../dashboard/src/server/shared/middleware.ts | 13 +++ .../src/server/shared/permissionRegistry.ts | 102 ++++++++++++++++++ .../tests/server/shared/middleware.test.ts | 16 ++- .../server/shared/permissionRegistry.test.ts | 61 +++++++++++ 5 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/src/server/shared/permissionRegistry.ts create mode 100644 apps/dashboard/tests/server/shared/permissionRegistry.test.ts diff --git a/apps/dashboard/src/server/index.ts b/apps/dashboard/src/server/index.ts index 30aa3e31..d4aa2196 100644 --- a/apps/dashboard/src/server/index.ts +++ b/apps/dashboard/src/server/index.ts @@ -41,6 +41,8 @@ import { helmetOptions } from "./shared/security.js"; import { registerOpenApi } from "./shared/openapi.js"; import { withDocs } from "./shared/openapi-schemas.js"; import { globalRateLimitOptions } from "./shared/rateLimit.js"; +import { getDeclaredPermissions } from "./shared/middleware.js"; +import { validatePermissionRegistry } from "./shared/permissionRegistry.js"; /** * Build the fully-configured Fastify application (plugins, routes, OpenAPI @@ -178,6 +180,10 @@ export async function createApp(): Promise { registerDashboardRoleRoutes(app); registerDashboardPermissionRoutes(app); + // Routes are registered, so every requirePermission() has run — the declared + // key set is now complete and can be checked. + validatePermissionRegistry(getDeclaredPermissions()); + // SPA fallback: serve index.html for non-API/auth routes in production if (process.env.NODE_ENV === "production") { const indexHtmlPath = join(__dirname, "../client/index.html"); diff --git a/apps/dashboard/src/server/shared/middleware.ts b/apps/dashboard/src/server/shared/middleware.ts index 8871ff21..f425f87e 100644 --- a/apps/dashboard/src/server/shared/middleware.ts +++ b/apps/dashboard/src/server/shared/middleware.ts @@ -94,12 +94,25 @@ export async function requireGuildAccess( request.resolvedPermissions = resolved; } +/** + * Every permission key any route enforces. Populated when `requirePermission` + * runs at route-registration time, which makes the route table — not a + * hand-maintained list — the source of truth for what permissions exist. + */ +const declaredPermissions = new Set(); + +export function getDeclaredPermissions(): ReadonlySet { + return declaredPermissions; +} + /** * Require specific dashboard permissions. * Must be used AFTER requireGuildAccess (which resolves permissions). * Accepts one or more permission keys — ALL must be granted. */ export function requirePermission(...keys: string[]) { + for (const key of keys) declaredPermissions.add(key); + return async (request: FastifyRequest, reply: FastifyReply): Promise => { const resolved = request.resolvedPermissions; if (!resolved) { diff --git a/apps/dashboard/src/server/shared/permissionRegistry.ts b/apps/dashboard/src/server/shared/permissionRegistry.ts new file mode 100644 index 00000000..c4aefdb3 --- /dev/null +++ b/apps/dashboard/src/server/shared/permissionRegistry.ts @@ -0,0 +1,102 @@ +/** + * The permission registry is derived from the route table: `requirePermission` + * records each key it enforces, and this module turns that flat set into the + * module → permission tree the dashboard renders. A key therefore cannot appear + * in the UI without a route enforcing it, nor the reverse. + * + * Only presentation lives here. Labels are i18n keys; the client translates. + */ + +/** Display metadata per module. Order is the order modules render in. */ +const MODULE_META: Record = { + dashboard: { icon: "LayoutDashboard", order: 0 }, + moderation: { icon: "Shield", order: 1 }, + actions: { icon: "Zap", order: 2 }, + logging: { icon: "ScrollText", order: 3 }, + welcome: { icon: "Hand", order: 4 }, + leveling: { icon: "TrendingUp", order: 5 }, + tickets: { icon: "Ticket", order: 6 }, + giveaways: { icon: "Gift", order: 7 }, + starboard: { icon: "Star", order: 8 }, + suggestions: { icon: "Lightbulb", order: 9 }, + roles: { icon: "Badge", order: 10 }, + tempvoice: { icon: "Mic", order: 11 }, + security: { icon: "ShieldAlert", order: 12 }, + scheduled: { icon: "Clock", order: 13 }, + commands: { icon: "Terminal", order: 14 }, +}; + +/** Action verbs the key convention allows. */ +const ACTIONS = new Set(["view", "manage", "execute", "purge"]); + +export interface PermissionView { + key: string; + /** i18n key for the resource noun, e.g. "Cases". */ + resourceKey: string; + /** i18n key for the action verb, e.g. "View". */ + actionKey: string; +} + +export interface PermissionModuleView { + key: string; + icon: string; + /** i18n key for the module name. */ + labelKey: string; + permissions: PermissionView[]; +} + +export function buildPermissionRegistry( + keys: ReadonlySet, +): PermissionModuleView[] { + const byModule = new Map(); + + for (const key of [...keys].sort()) { + const [module, resource, action] = key.split("."); + if (!module || !resource || !action) continue; + + const views = byModule.get(module) ?? []; + views.push({ + key, + resourceKey: `permissions:resources.${resource}`, + actionKey: `permissions:permissionActions.${action}`, + }); + byModule.set(module, views); + } + + return [...byModule.entries()] + .filter(([module]) => module in MODULE_META) + .sort(([a], [b]) => MODULE_META[a].order - MODULE_META[b].order) + .map(([module, permissions]) => ({ + key: module, + icon: MODULE_META[module].icon, + labelKey: `permissions:permissionCategories.${module}`, + permissions, + })); +} + +/** + * Fail fast when a route declares a key the UI could never render — an unknown + * module, a malformed key, or an unknown action verb. Called once at boot. + */ +export function validatePermissionRegistry(keys: ReadonlySet): void { + const problems: string[] = []; + + for (const key of keys) { + const parts = key.split("."); + if (parts.length !== 3 || parts.some((p) => p.length === 0)) { + problems.push(`"${key}" is not module.resource.action`); + continue; + } + const [module, , action] = parts; + if (!(module in MODULE_META)) { + problems.push(`"${key}" has no MODULE_META entry for module "${module}"`); + } + if (!ACTIONS.has(action)) { + problems.push(`"${key}" uses unknown action "${action}"`); + } + } + + if (problems.length > 0) { + throw new Error(`Invalid permission registry:\n ${problems.join("\n ")}`); + } +} diff --git a/apps/dashboard/tests/server/shared/middleware.test.ts b/apps/dashboard/tests/server/shared/middleware.test.ts index 5a8f1a0f..f6c84ebb 100644 --- a/apps/dashboard/tests/server/shared/middleware.test.ts +++ b/apps/dashboard/tests/server/shared/middleware.test.ts @@ -29,9 +29,8 @@ vi.mock("../../../src/server/shared/permissions.js", () => ({ createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), })); -const { requireAuth, requireGuildAccess } = await import( - "../../../src/server/shared/middleware.js" -); +const { requireAuth, requireGuildAccess, requirePermission, getDeclaredPermissions } = + await import("../../../src/server/shared/middleware.js"); interface MockRequest { cookies: Record; @@ -286,4 +285,15 @@ describe("middleware", () => { expect(reply.code).toHaveBeenCalledWith(403); }); }); + + describe("getDeclaredPermissions", () => { + it("records every key passed to requirePermission", () => { + requirePermission("tickets.list.view", "tickets.list.manage"); + + const declared = getDeclaredPermissions(); + + expect(declared.has("tickets.list.view")).toBe(true); + expect(declared.has("tickets.list.manage")).toBe(true); + }); + }); }); diff --git a/apps/dashboard/tests/server/shared/permissionRegistry.test.ts b/apps/dashboard/tests/server/shared/permissionRegistry.test.ts new file mode 100644 index 00000000..47334dbe --- /dev/null +++ b/apps/dashboard/tests/server/shared/permissionRegistry.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { + buildPermissionRegistry, + validatePermissionRegistry, +} from "../../../src/server/shared/permissionRegistry.js"; + +describe("buildPermissionRegistry", () => { + it("groups keys by module in MODULE_META order", () => { + const registry = buildPermissionRegistry( + new Set(["tickets.list.view", "dashboard.roles.view", "tickets.panels.manage"]), + ); + + expect(registry.map((m) => m.key)).toEqual(["dashboard", "tickets"]); + expect(registry[1].permissions.map((p) => p.key)).toEqual([ + "tickets.list.view", + "tickets.panels.manage", + ]); + }); + + it("exposes i18n keys rather than English labels", () => { + const [mod] = buildPermissionRegistry(new Set(["tickets.list.view"])); + + expect(mod.labelKey).toBe("permissions:permissionCategories.tickets"); + expect(mod.permissions[0]).toEqual({ + key: "tickets.list.view", + resourceKey: "permissions:resources.list", + actionKey: "permissions:permissionActions.view", + }); + }); + + it("carries the module icon", () => { + const [mod] = buildPermissionRegistry(new Set(["moderation.cases.view"])); + expect(mod.icon).toBe("Shield"); + }); +}); + +describe("validatePermissionRegistry", () => { + it("accepts well-formed keys in known modules", () => { + expect(() => + validatePermissionRegistry(new Set(["tickets.list.view"])), + ).not.toThrow(); + }); + + it("rejects a key whose module has no metadata", () => { + expect(() => + validatePermissionRegistry(new Set(["quests.list.view"])), + ).toThrow(/quests/); + }); + + it("rejects a key that is not module.resource.action", () => { + expect(() => + validatePermissionRegistry(new Set(["tickets.view"])), + ).toThrow(/tickets\.view/); + }); + + it("rejects an unknown action verb", () => { + expect(() => + validatePermissionRegistry(new Set(["tickets.list.obliterate"])), + ).toThrow(/obliterate/); + }); +}); From 36834fc3feedb7f0c29b8436dc1e2825e90f6f6a Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 18:54:07 +0300 Subject: [PATCH 14/36] fix(permissions): make the registry drift-guard actually run in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validatePermissionRegistry only ran inside createApp(), which no test calls (it hits a real DB and process.exits on missing config), so a route declaring an unknown module or bad action verb passed pnpm test clean and only broke a real boot. Add scanDeclaredPermissionKeys(), which statically extracts every requirePermission(...) literal from the route sources, and assert it validates and fully survives buildPermissionRegistry — the same check now runs in CI without booting anything. Also replace the "groups keys by module in MODULE_META order" test: its dashboard/tickets pair happened to agree both alphabetically and by MODULE_META.order, so it would still pass with the module sort deleted. moderation/actions genuinely disagrees between the two orderings. --- apps/dashboard/tests/helpers/declaredKeys.ts | 57 +++++++++++++++ .../server/shared/permissionRegistry.test.ts | 70 +++++++++++++++++-- 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 apps/dashboard/tests/helpers/declaredKeys.ts diff --git a/apps/dashboard/tests/helpers/declaredKeys.ts b/apps/dashboard/tests/helpers/declaredKeys.ts new file mode 100644 index 00000000..d6be05c6 --- /dev/null +++ b/apps/dashboard/tests/helpers/declaredKeys.ts @@ -0,0 +1,57 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +const FEATURES_DIR = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../src/server/features", +); + +const callPattern = /requirePermission\(([^)]*)\)/g; +const literalPattern = /"([^"]+)"/g; + +function walk(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walk(full)); + } else if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(full); + } + } + return files; +} + +/** + * Statically scans every route source file under `src/server/features/` for + * `requirePermission(...)` calls and extracts the string-literal keys passed + * to them. + * + * This reproduces the same key set `getDeclaredPermissions()` accumulates + * once the real app boots and every route registers — without booting a real + * `createApp()` (which connects to the database and can `process.exit` on + * missing config, making it unsuitable for a unit test). Because it reads the + * actual route sources, a new route declaring an unknown module or a bad + * action verb shows up here exactly as it would at boot, so tests built on + * this helper act as the CI-time equivalent of the boot-time + * `validatePermissionRegistry` check. + * + * Exported for reuse — later tests (e.g. a drift check against the dashboard + * client) should scan the same way rather than re-implementing the regex. + */ +export function scanDeclaredPermissionKeys(): Set { + const keys = new Set(); + + for (const file of walk(FEATURES_DIR)) { + const source = readFileSync(file, "utf8"); + for (const call of source.matchAll(callPattern)) { + const args = call[1] ?? ""; + for (const literal of args.matchAll(literalPattern)) { + keys.add(literal[1]); + } + } + } + + return keys; +} diff --git a/apps/dashboard/tests/server/shared/permissionRegistry.test.ts b/apps/dashboard/tests/server/shared/permissionRegistry.test.ts index 47334dbe..d05da929 100644 --- a/apps/dashboard/tests/server/shared/permissionRegistry.test.ts +++ b/apps/dashboard/tests/server/shared/permissionRegistry.test.ts @@ -3,15 +3,38 @@ import { buildPermissionRegistry, validatePermissionRegistry, } from "../../../src/server/shared/permissionRegistry.js"; +import { scanDeclaredPermissionKeys } from "../../helpers/declaredKeys.js"; describe("buildPermissionRegistry", () => { - it("groups keys by module in MODULE_META order", () => { + it("groups modules by MODULE_META order, not alphabetical or Set-insertion order", () => { + // "moderation" has MODULE_META order 1, "actions" has order 2, so the + // correct grouped output is ["moderation", "actions"]. Both alternative + // orderings disagree with that and with each other: + // - alphabetical: "actions" < "moderation" -> ["actions", "moderation"] + // - Set-insertion: moderation's key is added first -> ["moderation", "actions"] + // (agrees here, but is irrelevant: buildPermissionRegistry alpha-sorts + // the incoming keys before grouping, so insertion order never survives + // into the Map either way) + // A prior version of this test used "dashboard" (order 0) / "tickets" + // (order 6): alphabetical order happens to already match MODULE_META + // order for that pair, so deleting the trailing `.sort()` in + // buildPermissionRegistry would still have produced ["dashboard", + // "tickets"] and the test would not have caught it. This pair does catch + // it: deleting that `.sort()` yields the alphabetical grouping order + // ["actions", "moderation"], which fails the assertion below. const registry = buildPermissionRegistry( - new Set(["tickets.list.view", "dashboard.roles.view", "tickets.panels.manage"]), + new Set(["moderation.cases.view", "actions.rules.view"]), ); - expect(registry.map((m) => m.key)).toEqual(["dashboard", "tickets"]); - expect(registry[1].permissions.map((p) => p.key)).toEqual([ + expect(registry.map((m) => m.key)).toEqual(["moderation", "actions"]); + }); + + it("sorts permissions within a module alphabetically", () => { + const registry = buildPermissionRegistry( + new Set(["tickets.panels.manage", "tickets.list.view"]), + ); + + expect(registry[0].permissions.map((p) => p.key)).toEqual([ "tickets.list.view", "tickets.panels.manage", ]); @@ -59,3 +82,42 @@ describe("validatePermissionRegistry", () => { ).toThrow(/obliterate/); }); }); + +// `createApp()` is deliberately not exercised here — it connects to the +// database and `process.exit`s on missing config, which would make this a +// fragile, heavily-mocked integration test. Scanning the route sources +// reproduces the same key set `getDeclaredPermissions()` would accumulate at +// boot, without booting anything, and gives the same CI-time guarantee: a +// route declaring an unknown module or a bad action verb fails this suite +// instead of only surfacing when someone starts the real server. +describe("declared permission keys (scanned from route sources)", () => { + const scannedKeys = scanDeclaredPermissionKeys(); + + it("finds a healthy number of declared keys", () => { + // Guards against a scan regex that silently matches nothing (a rename of + // requirePermission, a moved features/ dir, ...), which would make the + // two assertions below pass vacuously on an empty set. + expect(scannedKeys.size).toBeGreaterThan(40); + }); + + it("validates without throwing — the CI gate for an unknown module or bad action verb", () => { + expect(() => validatePermissionRegistry(scannedKeys)).not.toThrow(); + }); + + it("every scanned key survives buildPermissionRegistry (no module silently dropped)", () => { + // buildPermissionRegistry silently *filters out* any module missing from + // MODULE_META (`.filter(([module]) => module in MODULE_META)`) instead of + // throwing — a materially different failure mode than + // validatePermissionRegistry's throw, and the one that matters for the + // dashboard UI: a permission the UI never renders. Comparing the built + // count to the scanned-set size directly asserts that guarantee, rather + // than relying on validatePermissionRegistry (tested above) to imply it. + const registry = buildPermissionRegistry(scannedKeys); + const builtCount = registry.reduce( + (sum, mod) => sum + mod.permissions.length, + 0, + ); + + expect(builtCount).toBe(scannedKeys.size); + }); +}); From a35b824a8cbd06b00208f7ba45fd4f950d96dea1 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 19:08:27 +0300 Subject: [PATCH 15/36] refactor(permissions): drop the hand-maintained permission registry --- .../features/permissions/roles-routes.ts | 10 +- .../src/server/features/permissions/routes.ts | 28 ++- .../permissions/userPermissions.test.ts | 8 +- .../server/shared/permissionDrift.test.ts | 32 +++ .../tests/server/shared/permissions.test.ts | 100 +++------ packages/types/src/dashboard-permissions.ts | 208 ++---------------- packages/types/src/index.ts | 4 +- 7 files changed, 109 insertions(+), 281 deletions(-) create mode 100644 apps/dashboard/tests/server/shared/permissionDrift.test.ts diff --git a/apps/dashboard/src/server/features/permissions/roles-routes.ts b/apps/dashboard/src/server/features/permissions/roles-routes.ts index ead7c8a9..b7cc64a1 100644 --- a/apps/dashboard/src/server/features/permissions/roles-routes.ts +++ b/apps/dashboard/src/server/features/permissions/roles-routes.ts @@ -2,11 +2,15 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; import { getPrisma } from "@fluxcore/database"; import { - ALL_PERMISSION_KEYS, ROLE_PRESETS, matchPermission, } from "@fluxcore/types"; -import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; +import { + requireAuth, + requireGuildAccess, + requirePermission, + getDeclaredPermissions, +} from "../../shared/middleware.js"; import { createDashboardAuditLog, invalidatePermissionCache, @@ -20,7 +24,7 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; function isValidPermissionKey(key: string): boolean { // Allow exact keys and wildcard patterns - if (ALL_PERMISSION_KEYS.includes(key)) return true; + if (getDeclaredPermissions().has(key)) return true; if (key === "*") return true; // Wildcard patterns: "module.*", "*.resource.action", "*.*.view" if (key.includes("*")) { diff --git a/apps/dashboard/src/server/features/permissions/routes.ts b/apps/dashboard/src/server/features/permissions/routes.ts index c58ffdd7..51b87cbf 100644 --- a/apps/dashboard/src/server/features/permissions/routes.ts +++ b/apps/dashboard/src/server/features/permissions/routes.ts @@ -1,12 +1,14 @@ import type { FastifyInstance } from "fastify"; import { withDocs } from "../../shared/openapi-schemas.js"; import { getPrisma } from "@fluxcore/database"; +import { resolveEffectivePermissions } from "@fluxcore/types"; import { - PERMISSION_REGISTRY, - ALL_PERMISSION_KEYS, - resolveEffectivePermissions, -} from "@fluxcore/types"; -import { requireAuth, requireGuildAccess, requirePermission } from "../../shared/middleware.js"; + requireAuth, + requireGuildAccess, + requirePermission, + getDeclaredPermissions, +} from "../../shared/middleware.js"; +import { buildPermissionRegistry } from "../../shared/permissionRegistry.js"; import { resolveUserPermissions, createDashboardAuditLog, @@ -39,7 +41,10 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { reply.send({ permissions: [...resolved.permissions], - effectivePermissions: resolveEffectivePermissions([...resolved.permissions]), + effectivePermissions: resolveEffectivePermissions( + [...resolved.permissions], + [...getDeclaredPermissions()], + ), roles: assignments.map((a) => a.role), isOwner: resolved.isOwner, }); @@ -52,12 +57,12 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { { schema: withDocs( { params: { type: "object", properties: { guildId: { type: "string" } }, required: ["guildId"] } }, - { tag: "DashboardPermissions", response: { 200: { type: "object", additionalProperties: true } } }, + { tag: "DashboardPermissions", response: { 200: { type: "array", items: { type: "object", additionalProperties: true } } } }, ), preHandler: [requireAuth, requireGuildAccess], }, async (_request, reply) => { - reply.send(PERMISSION_REGISTRY); + reply.send(buildPermissionRegistry(getDeclaredPermissions())); }, ); @@ -92,7 +97,10 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { grantedBy: p.grantedBy, createdAt: p.createdAt, })), - effectivePermissions: resolveEffectivePermissions([...resolved.permissions]), + effectivePermissions: resolveEffectivePermissions( + [...resolved.permissions], + [...getDeclaredPermissions()], + ), isOwner: resolved.isOwner, }); }, @@ -447,7 +455,7 @@ const ALLOWED_AUDIT_ACTIONS = new Set([ ]); function isValidPermKey(key: string): boolean { - if (ALL_PERMISSION_KEYS.includes(key)) return true; + if (getDeclaredPermissions().has(key)) return true; if (key === "*") return true; if (key.includes("*")) { const parts = key.split("."); diff --git a/apps/dashboard/tests/server/features/permissions/userPermissions.test.ts b/apps/dashboard/tests/server/features/permissions/userPermissions.test.ts index 2d8786c6..c3e9bddb 100644 --- a/apps/dashboard/tests/server/features/permissions/userPermissions.test.ts +++ b/apps/dashboard/tests/server/features/permissions/userPermissions.test.ts @@ -114,7 +114,7 @@ describe("PUT /api/guilds/:guildId/user-permissions/:userId — escalation guard method: "PUT", url: "/api/guilds/guild-1/user-permissions/target-1", cookies: { session: app.signCookie("valid") }, - payload: { permissions: ["actions.rules.manage"] }, + payload: { permissions: ["dashboard.settings.manage"] }, }); expect(res.statusCode).toBe(403); }); @@ -131,7 +131,7 @@ describe("PUT /api/guilds/:guildId/user-permissions/:userId — escalation guard method: "PUT", url: "/api/guilds/guild-1/user-permissions/target-1", cookies: { session: app.signCookie("valid") }, - payload: { permissions: ["actions.rules.manage"] }, + payload: { permissions: ["dashboard.settings.manage"] }, }); expect(res.statusCode).toBe(200); }); @@ -157,11 +157,11 @@ describe("PUT /user-permissions — error response does not leak key", () => { method: "PUT", url: "/api/guilds/guild-1/user-permissions/target-1", cookies: { session: app.signCookie("valid") }, - payload: { permissions: ["actions.rules.manage"] }, + payload: { permissions: ["dashboard.settings.manage"] }, }); expect(res.statusCode).toBe(403); const body = res.json(); expect(body).not.toHaveProperty("permission"); - expect(JSON.stringify(body)).not.toContain("actions.rules.manage"); + expect(JSON.stringify(body)).not.toContain("dashboard.settings.manage"); }); }); diff --git a/apps/dashboard/tests/server/shared/permissionDrift.test.ts b/apps/dashboard/tests/server/shared/permissionDrift.test.ts new file mode 100644 index 00000000..fd79cfdc --- /dev/null +++ b/apps/dashboard/tests/server/shared/permissionDrift.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { ROLE_PRESETS } from "@fluxcore/types"; +import { navItems } from "../../../src/client/shared/lib/navigation.js"; +import { scanDeclaredPermissionKeys } from "../../helpers/declaredKeys.js"; + +describe("permission drift", () => { + it("finds permission keys to check", () => { + expect(scanDeclaredPermissionKeys().size).toBeGreaterThan(40); + }); + + it("enforces every permission the sidebar navigates by", () => { + const declared = scanDeclaredPermissionKeys(); + + const missing = navItems + .map((item) => item.permission) + .filter((perm): perm is string => Boolean(perm)) + .filter((perm) => !declared.has(perm)); + + expect(missing).toEqual([]); + }); + + it("enforces every literal key used by a role preset", () => { + const declared = scanDeclaredPermissionKeys(); + + const missing = Object.values(ROLE_PRESETS) + .flatMap((preset) => preset.permissions) + .filter((perm) => !perm.includes("*")) + .filter((perm) => !declared.has(perm)); + + expect(missing).toEqual([]); + }); +}); diff --git a/apps/dashboard/tests/server/shared/permissions.test.ts b/apps/dashboard/tests/server/shared/permissions.test.ts index b6c8795c..6d668b24 100644 --- a/apps/dashboard/tests/server/shared/permissions.test.ts +++ b/apps/dashboard/tests/server/shared/permissions.test.ts @@ -3,8 +3,6 @@ import { matchPermission, expandWildcard, resolveEffectivePermissions, - ALL_PERMISSION_KEYS, - PERMISSION_REGISTRY, ROLE_PRESETS, } from "@fluxcore/types"; @@ -69,73 +67,48 @@ describe("matchPermission", () => { }); }); +const KEYS = [ + "moderation.cases.view", + "moderation.cases.manage", + "moderation.settings.manage", + "actions.rules.view", + "tickets.list.view", +]; + describe("expandWildcard", () => { - it("expands * to all permission keys", () => { - const expanded = expandWildcard("*"); - expect(expanded).toEqual(ALL_PERMISSION_KEYS); - expect(expanded.length).toBeGreaterThan(40); + it("expands * to every key", () => { + expect(expandWildcard("*", KEYS)).toEqual(KEYS); }); - it("expands module.* to all permissions in that module", () => { - const expanded = expandWildcard("moderation.*"); - expect(expanded).toContain("moderation.cases.view"); - expect(expanded).toContain("moderation.cases.manage"); - expect(expanded).toContain("moderation.warnings.view"); - expect(expanded).toContain("moderation.warnings.manage"); - expect(expanded).not.toContain("actions.rules.view"); + it("expands a module wildcard", () => { + expect(expandWildcard("moderation.*", KEYS)).toEqual([ + "moderation.cases.view", + "moderation.cases.manage", + "moderation.settings.manage", + ]); }); - it("expands *.*.view to all view permissions", () => { - const expanded = expandWildcard("*.*.view"); - expect(expanded.every((k) => k.endsWith(".view"))).toBe(true); - expect(expanded.length).toBeGreaterThan(5); + it("expands a cross-module action wildcard", () => { + expect(expandWildcard("*.*.view", KEYS)).toEqual([ + "moderation.cases.view", + "actions.rules.view", + "tickets.list.view", + ]); }); }); describe("resolveEffectivePermissions", () => { - it("resolves wildcard to concrete keys", () => { - const effective = resolveEffectivePermissions(["moderation.*"]); - expect(effective).toContain("moderation.cases.view"); - expect(effective).toContain("moderation.warnings.manage"); - expect(effective).not.toContain("actions.rules.view"); - }); - - it("resolves full wildcard to all keys", () => { - const effective = resolveEffectivePermissions(["*"]); - expect(effective).toEqual(ALL_PERMISSION_KEYS); - }); - - it("merges multiple grants", () => { - const effective = resolveEffectivePermissions(["moderation.*", "actions.rules.view"]); - expect(effective).toContain("moderation.cases.view"); - expect(effective).toContain("actions.rules.view"); - expect(effective).not.toContain("actions.rules.manage"); - }); - - it("returns empty for empty input", () => { - expect(resolveEffectivePermissions([])).toEqual([]); - }); -}); - -describe("PERMISSION_REGISTRY", () => { - it("has all expected modules", () => { - const moduleKeys = PERMISSION_REGISTRY.map((m) => m.key); - expect(moduleKeys).toContain("dashboard"); - expect(moduleKeys).toContain("moderation"); - expect(moduleKeys).toContain("actions"); - expect(moduleKeys).toContain("logging"); - expect(moduleKeys).toContain("security"); + it("returns nothing for an empty grant", () => { + expect(resolveEffectivePermissions([], KEYS)).toEqual([]); }); - it("has unique permission keys across all modules", () => { - const allKeys = PERMISSION_REGISTRY.flatMap((m) => m.permissions.map((p) => p.key)); - const unique = new Set(allKeys); - expect(unique.size).toBe(allKeys.length); - }); - - it("ALL_PERMISSION_KEYS matches registry", () => { - const registryKeys = PERMISSION_REGISTRY.flatMap((m) => m.permissions.map((p) => p.key)); - expect(ALL_PERMISSION_KEYS).toEqual(registryKeys); + it("merges wildcards and literals", () => { + expect(resolveEffectivePermissions(["moderation.*", "actions.rules.view"], KEYS)).toEqual([ + "moderation.cases.view", + "moderation.cases.manage", + "moderation.settings.manage", + "actions.rules.view", + ]); }); }); @@ -154,15 +127,4 @@ describe("ROLE_PRESETS", () => { it("full-admin has full wildcard", () => { expect(ROLE_PRESETS["full-admin"].permissions).toEqual(["*"]); }); - - it("all preset permissions are valid", () => { - for (const [, preset] of Object.entries(ROLE_PRESETS)) { - for (const perm of preset.permissions) { - if (perm === "*") continue; - // Wildcard or exact key should expand to at least one concrete key - const expanded = expandWildcard(perm); - expect(expanded.length, `"${perm}" should expand to at least one key`).toBeGreaterThan(0); - } - } - }); }); diff --git a/packages/types/src/dashboard-permissions.ts b/packages/types/src/dashboard-permissions.ts index 744e2781..b340178f 100644 --- a/packages/types/src/dashboard-permissions.ts +++ b/packages/types/src/dashboard-permissions.ts @@ -1,183 +1,3 @@ -// ─── Permission Key Types ─── - -export interface PermissionDefinition { - key: string; - label: string; - description: string; -} - -export interface PermissionModule { - key: string; - label: string; - icon: string; // Lucide icon name - permissions: PermissionDefinition[]; -} - -// ─── Permission Registry ─── - -export const PERMISSION_REGISTRY: PermissionModule[] = [ - { - key: "dashboard", - label: "Dashboard", - icon: "LayoutDashboard", - permissions: [ - { key: "dashboard.roles.view", label: "View Roles", description: "View dashboard roles and assignments" }, - { key: "dashboard.roles.manage", label: "Manage Roles", description: "Create/edit/delete dashboard roles" }, - { key: "dashboard.audit.view", label: "View Audit Log", description: "View dashboard audit log" }, - { key: "dashboard.settings.manage", label: "Manage Settings", description: "Manage guild-wide dashboard settings" }, - { key: "dashboard.lookups.view", label: "Use Pickers", description: "Look up channels, roles, and members for pickers" }, - ], - }, - { - key: "moderation", - label: "Moderation", - icon: "Shield", - permissions: [ - { key: "moderation.cases.view", label: "View Cases", description: "View moderation case history" }, - { key: "moderation.cases.manage", label: "Manage Cases", description: "Edit/delete moderation cases" }, - { key: "moderation.settings.manage", label: "Manage Settings", description: "Configure moderation settings" }, - { key: "moderation.warnings.view", label: "View Warnings", description: "View warning history" }, - { key: "moderation.warnings.manage", label: "Manage Warnings", description: "Create/delete warnings" }, - { key: "moderation.punishments.manage", label: "Manage Punishments", description: "Configure warning punishment escalations" }, - ], - }, - { - key: "actions", - label: "Actions", - icon: "Zap", - permissions: [ - { key: "actions.rules.view", label: "View Rules", description: "View automation rules" }, - { key: "actions.rules.manage", label: "Manage Rules", description: "Create/edit/delete automation rules" }, - { key: "actions.rules.execute", label: "Execute Rules", description: "Bulk enable/disable rules" }, - { key: "actions.analytics.view", label: "View Analytics", description: "View rule analytics and logs" }, - { key: "actions.settings.manage", label: "Manage Settings", description: "Configure action system settings" }, - ], - }, - { - key: "logging", - label: "Logging", - icon: "ScrollText", - permissions: [ - { key: "logging.entries.view", label: "View Logs", description: "View log entries" }, - { key: "logging.entries.purge", label: "Purge Logs", description: "Purge old log entries" }, - { key: "logging.config.manage", label: "Manage Config", description: "Configure log channels and events" }, - ], - }, - { - key: "welcome", - label: "Welcome", - icon: "HandMetal", - permissions: [ - { key: "welcome.config.view", label: "View Config", description: "View welcome/farewell config" }, - { key: "welcome.config.manage", label: "Manage Config", description: "Update welcome/farewell settings" }, - { key: "welcome.test.execute", label: "Test Messages", description: "Send test welcome/farewell messages" }, - ], - }, - { - key: "leveling", - label: "Leveling", - icon: "TrendingUp", - permissions: [ - { key: "leveling.leaderboard.view", label: "View Leaderboard", description: "View leaderboard" }, - { key: "leveling.users.manage", label: "Manage Users", description: "Set user XP manually" }, - { key: "leveling.rewards.manage", label: "Manage Rewards", description: "Add/remove level rewards" }, - { key: "leveling.settings.manage", label: "Manage Settings", description: "Configure leveling settings" }, - ], - }, - { - key: "tickets", - label: "Tickets", - icon: "Ticket", - permissions: [ - { key: "tickets.list.view", label: "View Tickets", description: "View tickets" }, - { key: "tickets.list.manage", label: "Manage Tickets", description: "Force close tickets" }, - { key: "tickets.panels.manage", label: "Manage Panels", description: "Create/edit/delete ticket panels" }, - { key: "tickets.settings.manage", label: "Manage Settings", description: "Configure ticket settings" }, - ], - }, - { - key: "giveaways", - label: "Giveaways", - icon: "Gift", - permissions: [ - { key: "giveaways.list.view", label: "View Giveaways", description: "View giveaways" }, - { key: "giveaways.list.manage", label: "Manage Giveaways", description: "Create/end/reroll giveaways" }, - ], - }, - { - key: "starboard", - label: "Starboard", - icon: "Star", - permissions: [ - { key: "starboard.entries.view", label: "View Entries", description: "View starred messages" }, - { key: "starboard.settings.manage", label: "Manage Settings", description: "Configure starboard settings" }, - ], - }, - { - key: "suggestions", - label: "Suggestions", - icon: "Lightbulb", - permissions: [ - { key: "suggestions.list.view", label: "View Suggestions", description: "View suggestions" }, - { key: "suggestions.list.manage", label: "Manage Suggestions", description: "Create/update status/delete suggestions" }, - { key: "suggestions.settings.manage", label: "Manage Settings", description: "Configure suggestion settings" }, - ], - }, - { - key: "roles", - label: "Role Panels", - icon: "UserCog", - permissions: [ - { key: "roles.panels.view", label: "View Panels", description: "View role panels" }, - { key: "roles.panels.manage", label: "Manage Panels", description: "Create/edit/delete/send role panels" }, - ], - }, - { - key: "tempvoice", - label: "Temp Voice", - icon: "Mic", - permissions: [ - { key: "tempvoice.config.view", label: "View Config", description: "View temp voice configs" }, - { key: "tempvoice.config.manage", label: "Manage Config", description: "Create/edit/delete temp voice configs" }, - ], - }, - { - key: "security", - label: "Security", - icon: "ShieldAlert", - permissions: [ - { key: "security.config.view", label: "View Config", description: "View anti-raid configuration" }, - { key: "security.config.manage", label: "Manage Config", description: "Update anti-raid settings" }, - { key: "security.events.view", label: "View Events", description: "View raid event history" }, - ], - }, - { - key: "scheduled", - label: "Scheduled Messages", - icon: "Clock", - permissions: [ - { key: "scheduled.messages.view", label: "View Messages", description: "View scheduled messages" }, - { key: "scheduled.messages.manage", label: "Manage Messages", description: "Create/edit/delete scheduled messages" }, - { key: "scheduled.messages.execute", label: "Test Messages", description: "Test send scheduled messages" }, - ], - }, - { - key: "commands", - label: "Custom Commands", - icon: "Terminal", - permissions: [ - { key: "commands.list.view", label: "View Commands", description: "View custom commands" }, - { key: "commands.list.manage", label: "Manage Commands", description: "Create/edit/delete custom commands" }, - ], - }, -]; - -// ─── All permission keys as a flat array ─── - -export const ALL_PERMISSION_KEYS: string[] = PERMISSION_REGISTRY.flatMap( - (mod) => mod.permissions.map((p) => p.key), -); - // ─── Role Presets ─── export interface RolePreset { @@ -287,22 +107,26 @@ function wildcardMatch(pattern: string, key: string): boolean { } /** - * Expand a wildcard pattern to all matching concrete permission keys. - * Useful for UI display of effective permissions. + * Expand a wildcard pattern to the concrete keys it covers. + * `allKeys` is the caller's vocabulary — the dashboard passes the keys declared + * by its route table, so this module never has to know what permissions exist. */ -export function expandWildcard(pattern: string): string[] { - if (pattern === "*") return [...ALL_PERMISSION_KEYS]; - - return ALL_PERMISSION_KEYS.filter((key) => - matchPermission(new Set([pattern]), key), - ); +export function expandWildcard( + pattern: string, + allKeys: readonly string[], +): string[] { + if (pattern === "*") return [...allKeys]; + return allKeys.filter((key) => matchPermission(new Set([pattern]), key)); } /** - * Given a set of granted permissions (may include wildcards), - * return all concrete permission keys the user has. + * Given granted permissions (possibly wildcards), return every concrete key + * they cover, out of `allKeys`. */ -export function resolveEffectivePermissions(granted: string[]): string[] { +export function resolveEffectivePermissions( + granted: string[], + allKeys: readonly string[], +): string[] { const grantedSet = new Set(granted); - return ALL_PERMISSION_KEYS.filter((key) => matchPermission(grantedSet, key)); + return allKeys.filter((key) => matchPermission(grantedSet, key)); } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c8a1097e..0ca9e0bf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -26,10 +26,8 @@ export type { } from "./tickets.js"; export type { Giveaway, CreateGiveawayData } from "./giveaways.js"; export type { Suggestion, SuggestionGuildSettings, SuggestionStatus } from "./suggestions.js"; -export type { PermissionDefinition, PermissionModule, RolePreset } from "./dashboard-permissions.js"; +export type { RolePreset } from "./dashboard-permissions.js"; export { - PERMISSION_REGISTRY, - ALL_PERMISSION_KEYS, ROLE_PRESETS, matchPermission, expandWildcard, From 0e7993e88806d44994df38d4185b253ee3452111 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 19:18:49 +0300 Subject: [PATCH 16/36] test(permissions): cover wildcard preset entries in the drift test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The literal-key drift check filtered out every wildcard entry, so ROLE_PRESETS wildcards (moderation.*, welcome.*, etc. — nearly all of the preset data) had no coverage left after the static PERMISSION_REGISTRY-based "all preset permissions are valid" test was removed. Add a case that expands each preset's wildcard entries against the scanned declared-key vocabulary and fails naming the dead preset:pattern pair, so a stale wildcard (e.g. after a module rename) is caught instead of silently granting zero permissions. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/server/shared/permissionDrift.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/tests/server/shared/permissionDrift.test.ts b/apps/dashboard/tests/server/shared/permissionDrift.test.ts index fd79cfdc..bb2dbba3 100644 --- a/apps/dashboard/tests/server/shared/permissionDrift.test.ts +++ b/apps/dashboard/tests/server/shared/permissionDrift.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { ROLE_PRESETS } from "@fluxcore/types"; +import { ROLE_PRESETS, expandWildcard } from "@fluxcore/types"; import { navItems } from "../../../src/client/shared/lib/navigation.js"; import { scanDeclaredPermissionKeys } from "../../helpers/declaredKeys.js"; @@ -29,4 +29,17 @@ describe("permission drift", () => { expect(missing).toEqual([]); }); + + it("every wildcard used by a role preset expands to at least one declared key", () => { + const declared = [...scanDeclaredPermissionKeys()]; + + const deadWildcards = Object.entries(ROLE_PRESETS).flatMap(([name, preset]) => + preset.permissions + .filter((perm) => perm !== "*" && perm.includes("*")) + .filter((perm) => expandWildcard(perm, declared).length === 0) + .map((perm) => `${name}: ${perm}`), + ); + + expect(deadWildcards).toEqual([]); + }); }); From 4e9a9c23530152eadd058fde954e4d14bd86d5f3 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 19:29:02 +0300 Subject: [PATCH 17/36] feat(permissions): render the permission grid from the served registry Co-Authored-By: Claude Opus 5 (1M context) --- .../permissions/hooks/usePermissions.ts | 22 ++++++++ .../routes/guild/$guildId/permissions.tsx | 28 +++++----- .../src/client/shared/lib/schemas.ts | 17 +++++++ .../usePermissionRegistry.test.tsx | 51 +++++++++++++++++++ packages/i18n/src/locales/en/permissions.json | 3 +- 5 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 apps/dashboard/tests/client/features/permissions/usePermissionRegistry.test.tsx diff --git a/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts b/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts index b5d269ee..f52b658d 100644 --- a/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts +++ b/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts @@ -6,11 +6,13 @@ import { DashboardRoleListSchema, DashboardGuildSettingsSchema, DashboardAuditResponseSchema, + PermissionRegistrySchema, type MyPermissions, type DashboardRole, type DashboardGuildSettings, type DashboardAuditResponse, type DashboardRoleMember, + type PermissionModuleView, } from "../../../shared/lib/schemas"; // ─── Permission Matching (client-side mirror of server logic) ─── @@ -83,6 +85,26 @@ export function usePermissions(guildId: string) { }; } +// ─── Permission Registry ─── + +/** + * The permission vocabulary, served from the route table rather than a static + * list, so the grid can never offer a permission no route enforces. + */ +export function usePermissionRegistry(guildId: string) { + return useQuery({ + queryKey: ["guilds", guildId, "permission-registry"], + queryFn: async () => { + const raw = await apiFetch( + `/api/guilds/${guildId}/permission-registry`, + ); + return PermissionRegistrySchema.parse(raw); + }, + staleTime: Infinity, + enabled: Boolean(guildId), + }); +} + // ─── Dashboard Roles ─── export function useDashboardRoles(guildId: string) { diff --git a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx index d91a831b..535f103d 100644 --- a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx +++ b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx @@ -41,12 +41,10 @@ import { useDashboardSettings, useUpdateDashboardSettings, useDashboardAuditLog, + usePermissionRegistry, } from "../../../features/permissions/hooks/usePermissions"; import type { DashboardRole } from "../../../shared/lib/schemas"; -import { - PERMISSION_REGISTRY, - ROLE_PRESETS, -} from "@fluxcore/types"; +import { ROLE_PRESETS } from "@fluxcore/types"; // ─── Main Page ─── @@ -222,6 +220,7 @@ function RoleEditor({ onDelete: () => void; }) { const { t } = useTranslation("permissions"); + const { data: registry = [] } = usePermissionRegistry(guildId); const updateRole = useUpdateDashboardRole(guildId); const deleteRole = useDeleteDashboardRole(guildId); const [name, setName] = useState(role.name); @@ -281,7 +280,7 @@ function RoleEditor({ next.delete(wildcard); } else { // Remove individual permissions for this module, add wildcard - const modulePerms = PERMISSION_REGISTRY.find((m) => m.key === moduleKey); + const modulePerms = registry.find((m) => m.key === moduleKey); if (modulePerms) { for (const p of modulePerms.permissions) next.delete(p.key); } @@ -343,12 +342,13 @@ function RoleEditor({
- {PERMISSION_REGISTRY.map((mod) => { + {registry.map((mod) => { const wildcard = `${mod.key}.*`; const hasWildcard = permissions.has(wildcard); const allGranted = hasWildcard || mod.permissions.every((p) => permissions.has(p.key)); + const modLabel = t(mod.labelKey); return (
@@ -356,10 +356,10 @@ function RoleEditor({ toggleModuleWildcard(mod.key)} - aria-label={`${mod.label} — ${t("roleEditor.allBadge")}`} + aria-label={`${modLabel} — ${t("roleEditor.allBadge")}`} /> - {mod.label} + {modLabel} {hasWildcard && ( @@ -370,6 +370,10 @@ function RoleEditor({
{mod.permissions.map((perm) => { const checked = hasWildcard || permissions.has(perm.key); + const permLabel = t("roleEditor.permissionLabel", { + action: t(perm.actionKey), + resource: t(perm.resourceKey), + }); return ( ); diff --git a/apps/dashboard/src/client/shared/lib/schemas.ts b/apps/dashboard/src/client/shared/lib/schemas.ts index fe54d3df..17374bf2 100644 --- a/apps/dashboard/src/client/shared/lib/schemas.ts +++ b/apps/dashboard/src/client/shared/lib/schemas.ts @@ -792,3 +792,20 @@ export const DashboardAuditResponseSchema = z.object({ pages: z.number(), }); export type DashboardAuditResponse = z.infer; + +// --- Permission Registry --- +export const PermissionViewSchema = z.object({ + key: z.string(), + resourceKey: z.string(), + actionKey: z.string(), +}); + +export const PermissionModuleViewSchema = z.object({ + key: z.string(), + icon: z.string(), + labelKey: z.string(), + permissions: z.array(PermissionViewSchema), +}); + +export const PermissionRegistrySchema = z.array(PermissionModuleViewSchema); +export type PermissionModuleView = z.infer; diff --git a/apps/dashboard/tests/client/features/permissions/usePermissionRegistry.test.tsx b/apps/dashboard/tests/client/features/permissions/usePermissionRegistry.test.tsx new file mode 100644 index 00000000..907a1633 --- /dev/null +++ b/apps/dashboard/tests/client/features/permissions/usePermissionRegistry.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import type { ReactNode } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mockApiFetch = vi.fn(); +vi.mock("../../../../src/client/shared/lib/client", () => ({ + apiFetch: (...args: unknown[]) => mockApiFetch(...args), +})); + +import { usePermissionRegistry } from "../../../../src/client/features/permissions/hooks/usePermissions"; + +const REGISTRY = [ + { + key: "tickets", + icon: "Ticket", + labelKey: "permissions:permissionCategories.tickets", + permissions: [ + { + key: "tickets.list.view", + resourceKey: "permissions:resources.list", + actionKey: "permissions:permissionActions.view", + }, + ], + }, +]; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe("usePermissionRegistry", () => { + it("parses the served registry", async () => { + mockApiFetch.mockResolvedValue(REGISTRY); + + const { result } = renderHook(() => usePermissionRegistry("guild-1"), { wrapper }); + + await waitFor(() => expect(result.current.data).toEqual(REGISTRY)); + expect(mockApiFetch).toHaveBeenCalledWith("/api/guilds/guild-1/permission-registry"); + }); + + it("rejects a registry entry missing its i18n keys", async () => { + mockApiFetch.mockResolvedValue([{ key: "tickets", icon: "Ticket", permissions: [] }]); + + const { result } = renderHook(() => usePermissionRegistry("guild-1"), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); diff --git a/packages/i18n/src/locales/en/permissions.json b/packages/i18n/src/locales/en/permissions.json index 9c70a0f6..b8fd69cb 100644 --- a/packages/i18n/src/locales/en/permissions.json +++ b/packages/i18n/src/locales/en/permissions.json @@ -41,7 +41,8 @@ "defaultRole": "Default role", "permissions": "Permissions", "allBadge": "All", - "discordRoles": "Discord Roles" + "discordRoles": "Discord Roles", + "permissionLabel": "{{action}} {{resource}}" }, "roleForm": { "name": "Role Name", From cb998a045f585c0787a34e6c9b573e348dba8712 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 19:43:53 +0300 Subject: [PATCH 18/36] fix(permissions): show loading and error states for the registry fetch An empty grid was indistinguishable from a role having no permissions available, so a failed fetch silently disabled the permission editor. Co-Authored-By: Claude Opus 5 (1M context) --- .../routes/guild/$guildId/permissions.tsx | 29 +++- .../guild/$guildId/permissions.test.tsx | 131 ++++++++++++++++++ packages/i18n/src/locales/en/permissions.json | 4 +- 3 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx diff --git a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx index 535f103d..4e578f23 100644 --- a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx +++ b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx @@ -24,6 +24,8 @@ import { Separator } from "../../../shared/ui/separator"; import { ColorPicker } from "../../../shared/ui/color-picker"; import { ScrollArea } from "../../../shared/ui/scroll-area"; import { Checkbox } from "../../../shared/ui/checkbox"; +import { Skeleton } from "../../../shared/ui/skeleton"; +import { Alert } from "../../../shared/ui/alert"; import { Dialog, DialogContent, @@ -220,7 +222,7 @@ function RoleEditor({ onDelete: () => void; }) { const { t } = useTranslation("permissions"); - const { data: registry = [] } = usePermissionRegistry(guildId); + const { data: registry = [], isLoading: registryLoading, isError: registryError } = usePermissionRegistry(guildId); const updateRole = useUpdateDashboardRole(guildId); const deleteRole = useDeleteDashboardRole(guildId); const [name, setName] = useState(role.name); @@ -341,6 +343,30 @@ function RoleEditor({
+ {registryLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : registryError ? ( + + {t("roleEditor.registryError")} + + ) : registry.length === 0 ? ( +

+ {t("roleEditor.registryEmpty")} +

+ ) : (
{registry.map((mod) => { const wildcard = `${mod.key}.*`; @@ -398,6 +424,7 @@ function RoleEditor({ ); })}
+ )}
diff --git a/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx new file mode 100644 index 00000000..e8e71d34 --- /dev/null +++ b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => (o ? `${k}:${JSON.stringify(o)}` : k), + }), +})); + +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ guildId: "g1" }), +})); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +// PermissionsPage/RoleEditor consume several hooks from this module; only +// `usePermissionRegistry` is what this fix touches, so its result is driven +// per-test via a mutable `vi.hoisted` box while the rest stay fixed at a +// steady "everything else already loaded" shape. Mocking the whole module +// (rather than wrapping in QueryClientProvider) keeps this a pure render +// test of the loading/error/empty/loaded branching added around the grid. +const registryState = vi.hoisted(() => ({ + box: { + data: undefined as unknown, + isLoading: false, + isError: false, + }, +})); + +const ROLE = { + id: "role-1", + name: "Moderators", + color: "#a3a6ff", + position: 0, + isDefault: false, + permissions: [], + memberCount: 2, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +vi.mock("../../../../../src/client/features/permissions/hooks/usePermissions", () => ({ + usePermissions: () => ({ isOwner: true, isLoading: false }), + useDashboardRoles: () => ({ data: [ROLE], isLoading: false }), + useCreateDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), + useUpdateDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), + useCreateRoleFromPreset: () => ({ mutate: vi.fn(), isPending: false }), + useDashboardSettings: () => ({ + data: { guildId: "g1", auditRetentionDays: 30, requirePermissions: true }, + isLoading: false, + }), + useUpdateDashboardSettings: () => ({ mutate: vi.fn(), isPending: false }), + useDashboardAuditLog: () => ({ data: { entries: [], total: 0, page: 1, pages: 1 }, isLoading: false }), + usePermissionRegistry: () => registryState.box, +})); + +import { PermissionsPage } from "../../../../../src/client/routes/guild/$guildId/permissions"; + +// Radix ScrollArea (wrapping the permission grid) needs ResizeObserver, which +// jsdom lacks. Typed against the DOM lib interface so no cast is needed. +class ResizeObserverStub implements ResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +beforeAll(() => { + globalThis.ResizeObserver ??= ResizeObserverStub; +}); + +const REGISTRY = [ + { + key: "tickets", + icon: "Ticket", + labelKey: "permissions:permissionCategories.tickets", + permissions: [ + { + key: "tickets.list.view", + resourceKey: "permissions:resources.list", + actionKey: "permissions:permissionActions.view", + }, + ], + }, +]; + +describe("PermissionsPage — permission registry loading/error/empty states", () => { + it("shows a skeleton and no grid or error while the registry is loading", async () => { + registryState.box = { data: undefined, isLoading: true, isError: false }; + render(); + + // Auto-select of the role happens post-mount; wait for the editor to appear. + expect(await screen.findByTestId("permission-registry-loading")).toBeInTheDocument(); + expect(screen.queryByTestId("permission-registry-error")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-registry-empty")).not.toBeInTheDocument(); + expect(screen.queryByText("permissions:permissionCategories.tickets")).not.toBeInTheDocument(); + }); + + it("shows a visible error state and no grid or skeleton when the registry fails to load", async () => { + registryState.box = { data: undefined, isLoading: false, isError: true }; + render(); + + const alert = await screen.findByTestId("permission-registry-error"); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent("roleEditor.registryError"); + expect(screen.queryByTestId("permission-registry-loading")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-registry-empty")).not.toBeInTheDocument(); + }); + + it("renders the grid, with neither the skeleton nor the error, once the registry loads", async () => { + registryState.box = { data: REGISTRY, isLoading: false, isError: false }; + render(); + + expect(await screen.findByText("permissions:permissionCategories.tickets")).toBeInTheDocument(); + expect(screen.queryByTestId("permission-registry-loading")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-registry-error")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-registry-empty")).not.toBeInTheDocument(); + }); + + it("distinguishes a successfully-loaded empty registry from the error state", async () => { + registryState.box = { data: [], isLoading: false, isError: false }; + render(); + + const empty = await screen.findByTestId("permission-registry-empty"); + expect(empty).toBeInTheDocument(); + expect(empty).toHaveTextContent("roleEditor.registryEmpty"); + expect(screen.queryByTestId("permission-registry-error")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-registry-loading")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/i18n/src/locales/en/permissions.json b/packages/i18n/src/locales/en/permissions.json index b8fd69cb..df90587a 100644 --- a/packages/i18n/src/locales/en/permissions.json +++ b/packages/i18n/src/locales/en/permissions.json @@ -42,7 +42,9 @@ "permissions": "Permissions", "allBadge": "All", "discordRoles": "Discord Roles", - "permissionLabel": "{{action}} {{resource}}" + "permissionLabel": "{{action}} {{resource}}", + "registryError": "The permission list could not be loaded. Try refreshing the page.", + "registryEmpty": "No permissions are available to assign yet." }, "roleForm": { "name": "Role Name", From cfc29e8be90f32495db8977d4938b91c96916125 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 19:48:32 +0300 Subject: [PATCH 19/36] fix(permissions): remove test cast and re-indent nested grid JSX vi.hoisted's factory now declares its return type instead of casting the initial `data: undefined` value, per the project's no-`as` rule for test files. Also re-indents the permission grid JSX to match its new nesting inside the loading/error/empty ternary (no logic change). Co-Authored-By: Claude Opus 5 (1M context) --- .../routes/guild/$guildId/permissions.tsx | 112 +++++++++--------- .../guild/$guildId/permissions.test.tsx | 10 +- 2 files changed, 64 insertions(+), 58 deletions(-) diff --git a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx index 4e578f23..a4784b9a 100644 --- a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx +++ b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx @@ -367,63 +367,63 @@ function RoleEditor({ {t("roleEditor.registryEmpty")}

) : ( -
- {registry.map((mod) => { - const wildcard = `${mod.key}.*`; - const hasWildcard = permissions.has(wildcard); - const allGranted = - hasWildcard || - mod.permissions.every((p) => permissions.has(p.key)); - const modLabel = t(mod.labelKey); - - return ( -
-
- toggleModuleWildcard(mod.key)} - aria-label={`${modLabel} — ${t("roleEditor.allBadge")}`} - /> - - {modLabel} - - {hasWildcard && ( - - {t("roleEditor.allBadge")} - - )} -
-
- {mod.permissions.map((perm) => { - const checked = hasWildcard || permissions.has(perm.key); - const permLabel = t("roleEditor.permissionLabel", { - action: t(perm.actionKey), - resource: t(perm.resourceKey), - }); - return ( - - ); - })} +
+ {registry.map((mod) => { + const wildcard = `${mod.key}.*`; + const hasWildcard = permissions.has(wildcard); + const allGranted = + hasWildcard || + mod.permissions.every((p) => permissions.has(p.key)); + const modLabel = t(mod.labelKey); + + return ( +
+
+ toggleModuleWildcard(mod.key)} + aria-label={`${modLabel} — ${t("roleEditor.allBadge")}`} + /> + + {modLabel} + + {hasWildcard && ( + + {t("roleEditor.allBadge")} + + )} +
+
+ {mod.permissions.map((perm) => { + const checked = hasWildcard || permissions.has(perm.key); + const permLabel = t("roleEditor.permissionLabel", { + action: t(perm.actionKey), + resource: t(perm.resourceKey), + }); + return ( + + ); + })} +
-
- ); - })} -
+ ); + })} +
)}
diff --git a/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx index e8e71d34..e7bd73ee 100644 --- a/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx +++ b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx @@ -20,9 +20,15 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); // steady "everything else already loaded" shape. Mocking the whole module // (rather than wrapping in QueryClientProvider) keeps this a pure render // test of the loading/error/empty/loaded branching added around the grid. -const registryState = vi.hoisted(() => ({ +interface RegistryQueryState { + data: unknown; + isLoading: boolean; + isError: boolean; +} + +const registryState = vi.hoisted((): { box: RegistryQueryState } => ({ box: { - data: undefined as unknown, + data: undefined, isLoading: false, isError: false, }, From 946f3a3069b4798e0d68226048dd937aca415516 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 20:17:08 +0300 Subject: [PATCH 20/36] i18n(permissions): translate registry module, resource, and action labels Co-Authored-By: Claude Opus 5 (1M context) --- packages/i18n/package.json | 7 +- packages/i18n/src/locales/af/permissions.json | 33 ++++++++- packages/i18n/src/locales/ar/permissions.json | 33 ++++++++- packages/i18n/src/locales/bg/permissions.json | 33 ++++++++- packages/i18n/src/locales/bn/permissions.json | 33 ++++++++- packages/i18n/src/locales/ca/permissions.json | 33 ++++++++- packages/i18n/src/locales/cs/permissions.json | 33 ++++++++- packages/i18n/src/locales/da/permissions.json | 33 ++++++++- packages/i18n/src/locales/de/permissions.json | 33 ++++++++- packages/i18n/src/locales/el/permissions.json | 33 ++++++++- packages/i18n/src/locales/en/permissions.json | 28 ++++++++ packages/i18n/src/locales/es/permissions.json | 33 ++++++++- packages/i18n/src/locales/et/permissions.json | 33 ++++++++- packages/i18n/src/locales/eu/permissions.json | 33 ++++++++- packages/i18n/src/locales/fa/permissions.json | 33 ++++++++- packages/i18n/src/locales/fi/permissions.json | 33 ++++++++- .../i18n/src/locales/fil/permissions.json | 33 ++++++++- packages/i18n/src/locales/fr/permissions.json | 33 ++++++++- packages/i18n/src/locales/gl/permissions.json | 33 ++++++++- packages/i18n/src/locales/he/permissions.json | 33 ++++++++- packages/i18n/src/locales/hi/permissions.json | 33 ++++++++- packages/i18n/src/locales/hr/permissions.json | 33 ++++++++- packages/i18n/src/locales/hu/permissions.json | 33 ++++++++- packages/i18n/src/locales/id/permissions.json | 33 ++++++++- packages/i18n/src/locales/it/permissions.json | 33 ++++++++- packages/i18n/src/locales/ja/permissions.json | 33 ++++++++- packages/i18n/src/locales/ko/permissions.json | 33 ++++++++- packages/i18n/src/locales/lt/permissions.json | 33 ++++++++- packages/i18n/src/locales/lv/permissions.json | 33 ++++++++- packages/i18n/src/locales/ms/permissions.json | 33 ++++++++- packages/i18n/src/locales/nl/permissions.json | 33 ++++++++- packages/i18n/src/locales/no/permissions.json | 33 ++++++++- packages/i18n/src/locales/pl/permissions.json | 33 ++++++++- packages/i18n/src/locales/pt/permissions.json | 33 ++++++++- packages/i18n/src/locales/ro/permissions.json | 33 ++++++++- packages/i18n/src/locales/ru/permissions.json | 33 ++++++++- packages/i18n/src/locales/sk/permissions.json | 33 ++++++++- packages/i18n/src/locales/sl/permissions.json | 33 ++++++++- packages/i18n/src/locales/sr/permissions.json | 33 ++++++++- packages/i18n/src/locales/sv/permissions.json | 33 ++++++++- packages/i18n/src/locales/sw/permissions.json | 33 ++++++++- packages/i18n/src/locales/ta/permissions.json | 33 ++++++++- packages/i18n/src/locales/th/permissions.json | 33 ++++++++- packages/i18n/src/locales/tr/permissions.json | 33 ++++++++- packages/i18n/src/locales/uk/permissions.json | 33 ++++++++- packages/i18n/src/locales/ur/permissions.json | 33 ++++++++- packages/i18n/src/locales/vi/permissions.json | 33 ++++++++- .../i18n/src/locales/zh-CN/permissions.json | 33 ++++++++- .../i18n/src/locales/zh-TW/permissions.json | 33 ++++++++- packages/i18n/tests/permission-keys.test.ts | 69 +++++++++++++++++++ packages/i18n/vitest.config.ts | 8 +++ pnpm-lock.yaml | 67 ++---------------- 52 files changed, 1621 insertions(+), 109 deletions(-) create mode 100644 packages/i18n/tests/permission-keys.test.ts create mode 100644 packages/i18n/vitest.config.ts diff --git a/packages/i18n/package.json b/packages/i18n/package.json index ddd15ae7..383591ba 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -28,7 +28,9 @@ "build": "tsc && rm -rf dist/locales && cp -r src/locales dist/locales", "typecheck": "tsc --noEmit", "clean": "rm -rf dist", - "translate": "tsx src/scripts/translate.ts" + "translate": "tsx src/scripts/translate.ts", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "i18next": "^25.1.3", @@ -43,6 +45,7 @@ "devDependencies": { "@types/node": "^25.3.0", "tsx": "^4.21.0", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^4.0.18" } } diff --git a/packages/i18n/src/locales/af/permissions.json b/packages/i18n/src/locales/af/permissions.json index d8c06025..3eb98031 100644 --- a/packages/i18n/src/locales/af/permissions.json +++ b/packages/i18n/src/locales/af/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Verstekrol", "permissions": "Toestemmings", "allBadge": "Almal", - "discordRoles": "Discord-rolle" + "discordRoles": "Discord-rolle", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Die toestemminglys kon nie gelaai word nie. Probeer om die bladsy te herlaai.", + "registryEmpty": "Daar is nog geen toestemmings beskikbaar om toe te ken nie." }, "roleForm": { "name": "Rolnaam", @@ -58,6 +61,7 @@ "color": "Kleur" }, "permissionCategories": { + "dashboard": "Kontrolepaneel", "actions": "Aksies & Outomatisering", "tempvoice": "TempVoice", "welcome": "Welkom & Afskeid", @@ -74,6 +78,33 @@ "commands": "Pasgemaakte Opdragte", "settings": "Bedienerinstellings" }, + "resources": { + "roles": "Rolle", + "audit": "Ouditlogboek", + "settings": "Instellings", + "lookups": "Kiesers", + "cases": "Sake", + "warnings": "Waarskuwings", + "punishments": "Strawwe", + "rules": "Reëls", + "analytics": "Analise", + "entries": "Inskrywings", + "config": "Konfigurasie", + "test": "Toetsboodskappe", + "leaderboard": "Ranglys", + "users": "Gebruikers", + "rewards": "Belonings", + "list": "Lys", + "panels": "Panele", + "messages": "Boodskappe", + "events": "Gebeurtenisse" + }, + "permissionActions": { + "view": "Bekyk", + "manage": "Bestuur", + "execute": "Voer uit", + "purge": "Vee uit" + }, "audit": { "table": { "user": "Gebruiker", diff --git a/packages/i18n/src/locales/ar/permissions.json b/packages/i18n/src/locales/ar/permissions.json index 09d27576..fdd9a41a 100644 --- a/packages/i18n/src/locales/ar/permissions.json +++ b/packages/i18n/src/locales/ar/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "الدور الافتراضي", "permissions": "الصلاحيات", "allBadge": "الكل", - "discordRoles": "أدوار Discord" + "discordRoles": "أدوار Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "تعذر تحميل قائمة الأذونات. حاول تحديث الصفحة.", + "registryEmpty": "لا توجد أذونات متاحة للتخصيص بعد." }, "roleForm": { "name": "اسم الدور", @@ -58,6 +61,7 @@ "color": "اللون" }, "permissionCategories": { + "dashboard": "لوحة التحكم", "actions": "الإجراءات والأتمتة", "tempvoice": "القنوات الصوتية المؤقتة", "welcome": "الترحيب والتوديع", @@ -74,6 +78,33 @@ "commands": "الأوامر المخصصة", "settings": "إعدادات السيرفر" }, + "resources": { + "roles": "الأدوار", + "audit": "سجل التدقيق", + "settings": "الإعدادات", + "lookups": "أدوات الاختيار", + "cases": "الحالات", + "warnings": "التحذيرات", + "punishments": "العقوبات", + "rules": "القواعد", + "analytics": "التحليلات", + "entries": "الإدخالات", + "config": "التكوين", + "test": "رسائل الاختبار", + "leaderboard": "لوحة الصدارة", + "users": "المستخدمون", + "rewards": "المكافآت", + "list": "القائمة", + "panels": "اللوحات", + "messages": "الرسائل", + "events": "الأحداث" + }, + "permissionActions": { + "view": "عرض", + "manage": "إدارة", + "execute": "تنفيذ", + "purge": "مسح" + }, "audit": { "table": { "user": "المستخدم", diff --git a/packages/i18n/src/locales/bg/permissions.json b/packages/i18n/src/locales/bg/permissions.json index d05fd46a..4a2b85ce 100644 --- a/packages/i18n/src/locales/bg/permissions.json +++ b/packages/i18n/src/locales/bg/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Роля по подразбиране", "permissions": "Разрешения", "allBadge": "Всички", - "discordRoles": "Discord роли" + "discordRoles": "Discord роли", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Списъкът с разрешения не можа да се зареди. Опитайте да презаредите страницата.", + "registryEmpty": "Все още няма налични разрешения за присвояване." }, "roleForm": { "name": "Име на ролята", @@ -58,6 +61,7 @@ "color": "Цвят" }, "permissionCategories": { + "dashboard": "Табло", "actions": "Действия и автоматизация", "tempvoice": "TempVoice", "welcome": "Добре дошли и сбогом", @@ -74,6 +78,33 @@ "commands": "Персонализирани команди", "settings": "Настройки на сървъра" }, + "resources": { + "roles": "Роли", + "audit": "Одитен журнал", + "settings": "Настройки", + "lookups": "Избирачи", + "cases": "Случаи", + "warnings": "Предупреждения", + "punishments": "Наказания", + "rules": "Правила", + "analytics": "Анализи", + "entries": "Записи", + "config": "Конфигурация", + "test": "Тестови съобщения", + "leaderboard": "Класация", + "users": "Потребители", + "rewards": "Награди", + "list": "Списък", + "panels": "Панели", + "messages": "Съобщения", + "events": "Събития" + }, + "permissionActions": { + "view": "Преглед", + "manage": "Управление", + "execute": "Изпълнение", + "purge": "Изчистване" + }, "audit": { "table": { "user": "Потребител", diff --git a/packages/i18n/src/locales/bn/permissions.json b/packages/i18n/src/locales/bn/permissions.json index 1a26c535..f15482ef 100644 --- a/packages/i18n/src/locales/bn/permissions.json +++ b/packages/i18n/src/locales/bn/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "ডিফল্ট রোল", "permissions": "অনুমতি", "allBadge": "সব", - "discordRoles": "Discord রোল" + "discordRoles": "Discord রোল", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "অনুমতির তালিকা লোড করা যায়নি। পৃষ্ঠাটি রিফ্রেশ করার চেষ্টা করুন।", + "registryEmpty": "এখনও বরাদ্দ করার জন্য কোনো অনুমতি উপলব্ধ নেই।" }, "roleForm": { "name": "রোলের নাম", @@ -58,6 +61,7 @@ "color": "রঙ" }, "permissionCategories": { + "dashboard": "ড্যাশবোর্ড", "actions": "অ্যাকশন ও অটোমেশন", "tempvoice": "TempVoice", "welcome": "স্বাগতম ও বিদায়", @@ -74,6 +78,33 @@ "commands": "কাস্টম কমান্ড", "settings": "সার্ভার সেটিংস" }, + "resources": { + "roles": "রোল", + "audit": "অডিট লগ", + "settings": "সেটিংস", + "lookups": "নির্বাচক", + "cases": "কেস", + "warnings": "সতর্কতা", + "punishments": "শাস্তি", + "rules": "নিয়ম", + "analytics": "বিশ্লেষণ", + "entries": "এন্ট্রি", + "config": "কনফিগারেশন", + "test": "টেস্ট বার্তা", + "leaderboard": "লিডারবোর্ড", + "users": "ব্যবহারকারী", + "rewards": "পুরস্কার", + "list": "তালিকা", + "panels": "প্যানেল", + "messages": "বার্তা", + "events": "ইভেন্ট" + }, + "permissionActions": { + "view": "দেখুন", + "manage": "পরিচালনা করুন", + "execute": "সম্পাদন করুন", + "purge": "মুছে ফেলুন" + }, "audit": { "table": { "user": "ব্যবহারকারী", diff --git a/packages/i18n/src/locales/ca/permissions.json b/packages/i18n/src/locales/ca/permissions.json index 136a0ce8..15c99f06 100644 --- a/packages/i18n/src/locales/ca/permissions.json +++ b/packages/i18n/src/locales/ca/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Rol per defecte", "permissions": "Permisos", "allBadge": "Tots", - "discordRoles": "Rols de Discord" + "discordRoles": "Rols de Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "No s'ha pogut carregar la llista de permisos. Torna a carregar la pàgina.", + "registryEmpty": "Encara no hi ha permisos disponibles per assignar." }, "roleForm": { "name": "Nom del rol", @@ -58,6 +61,7 @@ "color": "Color" }, "permissionCategories": { + "dashboard": "Tauler de control", "actions": "Accions i automatització", "tempvoice": "TempVoice", "welcome": "Benvinguda i comiat", @@ -74,6 +78,33 @@ "commands": "Comandes personalitzades", "settings": "Configuració del servidor" }, + "resources": { + "roles": "Rols", + "audit": "Registre d'auditoria", + "settings": "Configuració", + "lookups": "Selectors", + "cases": "Casos", + "warnings": "Advertències", + "punishments": "Sancions", + "rules": "Regles", + "analytics": "Analítica", + "entries": "Entrades", + "config": "Configuració", + "test": "Missatges de prova", + "leaderboard": "Classificació", + "users": "Usuaris", + "rewards": "Recompenses", + "list": "Llista", + "panels": "Panells", + "messages": "Missatges", + "events": "Esdeveniments" + }, + "permissionActions": { + "view": "Veure", + "manage": "Gestionar", + "execute": "Executar", + "purge": "Purgar" + }, "audit": { "table": { "user": "Usuari", diff --git a/packages/i18n/src/locales/cs/permissions.json b/packages/i18n/src/locales/cs/permissions.json index 4f593299..d2f27666 100644 --- a/packages/i18n/src/locales/cs/permissions.json +++ b/packages/i18n/src/locales/cs/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Výchozí role", "permissions": "Oprávnění", "allBadge": "Vše", - "discordRoles": "Discord role" + "discordRoles": "Discord role", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Seznam oprávnění se nepodařilo načíst. Zkuste stránku obnovit.", + "registryEmpty": "Zatím nejsou k dispozici žádná oprávnění k přiřazení." }, "roleForm": { "name": "Název role", @@ -58,6 +61,7 @@ "color": "Barva" }, "permissionCategories": { + "dashboard": "Řídicí panel", "actions": "Akce a automatizace", "tempvoice": "TempVoice", "welcome": "Uvítání a rozloučení", @@ -74,6 +78,33 @@ "commands": "Vlastní příkazy", "settings": "Nastavení serveru" }, + "resources": { + "roles": "Role", + "audit": "Auditní log", + "settings": "Nastavení", + "lookups": "Výběry", + "cases": "Případy", + "warnings": "Varování", + "punishments": "Tresty", + "rules": "Pravidla", + "analytics": "Analytika", + "entries": "Záznamy", + "config": "Konfigurace", + "test": "Testovací zprávy", + "leaderboard": "Žebříček", + "users": "Uživatelé", + "rewards": "Odměny", + "list": "Seznam", + "panels": "Panely", + "messages": "Zprávy", + "events": "Události" + }, + "permissionActions": { + "view": "Zobrazit", + "manage": "Spravovat", + "execute": "Spustit", + "purge": "Vymazat" + }, "audit": { "table": { "user": "Uživatel", diff --git a/packages/i18n/src/locales/da/permissions.json b/packages/i18n/src/locales/da/permissions.json index 826ecb6b..4816f2c4 100644 --- a/packages/i18n/src/locales/da/permissions.json +++ b/packages/i18n/src/locales/da/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Standardrolle", "permissions": "Tilladelser", "allBadge": "Alle", - "discordRoles": "Discord-roller" + "discordRoles": "Discord-roller", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Tilladelseslisten kunne ikke indlæses. Prøv at genindlæse siden.", + "registryEmpty": "Der er endnu ingen tilladelser tilgængelige at tildele." }, "roleForm": { "name": "Rollenavn", @@ -58,6 +61,7 @@ "color": "Farve" }, "permissionCategories": { + "dashboard": "Kontrolpanel", "actions": "Handlinger og automatisering", "tempvoice": "TempVoice", "welcome": "Velkommen og farvel", @@ -74,6 +78,33 @@ "commands": "Brugerdefinerede kommandoer", "settings": "Serverindstillinger" }, + "resources": { + "roles": "Roller", + "audit": "Revisionslog", + "settings": "Indstillinger", + "lookups": "Vælgere", + "cases": "Sager", + "warnings": "Advarsler", + "punishments": "Straffe", + "rules": "Regler", + "analytics": "Analyser", + "entries": "Poster", + "config": "Konfiguration", + "test": "Testbeskeder", + "leaderboard": "Rangliste", + "users": "Brugere", + "rewards": "Belønninger", + "list": "Liste", + "panels": "Paneler", + "messages": "Beskeder", + "events": "Hændelser" + }, + "permissionActions": { + "view": "Vis", + "manage": "Administrer", + "execute": "Udfør", + "purge": "Ryd" + }, "audit": { "table": { "user": "Bruger", diff --git a/packages/i18n/src/locales/de/permissions.json b/packages/i18n/src/locales/de/permissions.json index ca45caa1..9999912e 100644 --- a/packages/i18n/src/locales/de/permissions.json +++ b/packages/i18n/src/locales/de/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Standardrolle", "permissions": "Berechtigungen", "allBadge": "Alle", - "discordRoles": "Discord-Rollen" + "discordRoles": "Discord-Rollen", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "Die Berechtigungsliste konnte nicht geladen werden. Versuche, die Seite neu zu laden.", + "registryEmpty": "Es sind noch keine Berechtigungen zum Zuweisen verfügbar." }, "roleForm": { "name": "Rollenname", @@ -58,6 +61,7 @@ "color": "Farbe" }, "permissionCategories": { + "dashboard": "Dashboard", "actions": "Aktionen & Automatisierung", "tempvoice": "TempVoice", "welcome": "Willkommen & Abschied", @@ -74,6 +78,33 @@ "commands": "Benutzerdefinierte Befehle", "settings": "Servereinstellungen" }, + "resources": { + "roles": "Rollen", + "audit": "Audit-Protokoll", + "settings": "Einstellungen", + "lookups": "Auswahlfelder", + "cases": "Fälle", + "warnings": "Verwarnungen", + "punishments": "Bestrafungen", + "rules": "Regeln", + "analytics": "Analysen", + "entries": "Einträge", + "config": "Konfiguration", + "test": "Testnachrichten", + "leaderboard": "Rangliste", + "users": "Benutzer", + "rewards": "Belohnungen", + "list": "Liste", + "panels": "Panels", + "messages": "Nachrichten", + "events": "Ereignisse" + }, + "permissionActions": { + "view": "Anzeigen", + "manage": "Verwalten", + "execute": "Ausführen", + "purge": "Bereinigen" + }, "audit": { "table": { "user": "Benutzer", diff --git a/packages/i18n/src/locales/el/permissions.json b/packages/i18n/src/locales/el/permissions.json index 25c3e44b..150a3ae5 100644 --- a/packages/i18n/src/locales/el/permissions.json +++ b/packages/i18n/src/locales/el/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Προεπιλεγμένος ρόλος", "permissions": "Δικαιώματα", "allBadge": "Όλα", - "discordRoles": "Ρόλοι Discord" + "discordRoles": "Ρόλοι Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Δεν ήταν δυνατή η φόρτωση της λίστας δικαιωμάτων. Δοκιμάστε να ανανεώσετε τη σελίδα.", + "registryEmpty": "Δεν υπάρχουν ακόμη διαθέσιμα δικαιώματα για ανάθεση." }, "roleForm": { "name": "Όνομα ρόλου", @@ -58,6 +61,7 @@ "color": "Χρώμα" }, "permissionCategories": { + "dashboard": "Πίνακας ελέγχου", "actions": "Ενέργειες & Αυτοματισμός", "tempvoice": "TempVoice", "welcome": "Καλωσόρισμα & Αποχαιρετισμός", @@ -74,6 +78,33 @@ "commands": "Προσαρμοσμένες εντολές", "settings": "Ρυθμίσεις server" }, + "resources": { + "roles": "Ρόλοι", + "audit": "Αρχείο ελέγχου", + "settings": "Ρυθμίσεις", + "lookups": "Επιλογείς", + "cases": "Περιπτώσεις", + "warnings": "Προειδοποιήσεις", + "punishments": "Ποινές", + "rules": "Κανόνες", + "analytics": "Αναλυτικά στοιχεία", + "entries": "Καταχωρίσεις", + "config": "Διαμόρφωση", + "test": "Δοκιμαστικά μηνύματα", + "leaderboard": "Κατάταξη", + "users": "Χρήστες", + "rewards": "Ανταμοιβές", + "list": "Λίστα", + "panels": "Πάνελ", + "messages": "Μηνύματα", + "events": "Γεγονότα" + }, + "permissionActions": { + "view": "Προβολή", + "manage": "Διαχείριση", + "execute": "Εκτέλεση", + "purge": "Εκκαθάριση" + }, "audit": { "table": { "user": "Χρήστης", diff --git a/packages/i18n/src/locales/en/permissions.json b/packages/i18n/src/locales/en/permissions.json index df90587a..dee6e611 100644 --- a/packages/i18n/src/locales/en/permissions.json +++ b/packages/i18n/src/locales/en/permissions.json @@ -61,6 +61,7 @@ "color": "Color" }, "permissionCategories": { + "dashboard": "Dashboard", "actions": "Actions & Automation", "tempvoice": "TempVoice", "welcome": "Welcome & Farewell", @@ -77,6 +78,33 @@ "commands": "Custom Commands", "settings": "Server Settings" }, + "resources": { + "roles": "Roles", + "audit": "Audit Log", + "settings": "Settings", + "lookups": "Pickers", + "cases": "Cases", + "warnings": "Warnings", + "punishments": "Punishments", + "rules": "Rules", + "analytics": "Analytics", + "entries": "Entries", + "config": "Configuration", + "test": "Test Messages", + "leaderboard": "Leaderboard", + "users": "Users", + "rewards": "Rewards", + "list": "List", + "panels": "Panels", + "messages": "Messages", + "events": "Events" + }, + "permissionActions": { + "view": "View", + "manage": "Manage", + "execute": "Execute", + "purge": "Purge" + }, "audit": { "table": { "user": "User", diff --git a/packages/i18n/src/locales/es/permissions.json b/packages/i18n/src/locales/es/permissions.json index a42a0e42..9b64d858 100644 --- a/packages/i18n/src/locales/es/permissions.json +++ b/packages/i18n/src/locales/es/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Rol predeterminado", "permissions": "Permisos", "allBadge": "Todos", - "discordRoles": "Roles de Discord" + "discordRoles": "Roles de Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "No se pudo cargar la lista de permisos. Intenta actualizar la página.", + "registryEmpty": "Aún no hay permisos disponibles para asignar." }, "roleForm": { "name": "Nombre del rol", @@ -58,6 +61,7 @@ "color": "Color" }, "permissionCategories": { + "dashboard": "Panel de control", "actions": "Acciones y automatizacion", "tempvoice": "TempVoice", "welcome": "Bienvenida y despedida", @@ -74,6 +78,33 @@ "commands": "Comandos personalizados", "settings": "Configuracion del servidor" }, + "resources": { + "roles": "Roles", + "audit": "Registro de auditoría", + "settings": "Configuración", + "lookups": "Selectores", + "cases": "Casos", + "warnings": "Advertencias", + "punishments": "Sanciones", + "rules": "Reglas", + "analytics": "Analíticas", + "entries": "Entradas", + "config": "Configuración", + "test": "Mensajes de prueba", + "leaderboard": "Clasificación", + "users": "Usuarios", + "rewards": "Recompensas", + "list": "Lista", + "panels": "Paneles", + "messages": "Mensajes", + "events": "Eventos" + }, + "permissionActions": { + "view": "Ver", + "manage": "Gestionar", + "execute": "Ejecutar", + "purge": "Purgar" + }, "audit": { "table": { "user": "Usuario", diff --git a/packages/i18n/src/locales/et/permissions.json b/packages/i18n/src/locales/et/permissions.json index d2d68163..c5476973 100644 --- a/packages/i18n/src/locales/et/permissions.json +++ b/packages/i18n/src/locales/et/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Vaikimisi roll", "permissions": "Õigused", "allBadge": "Kõik", - "discordRoles": "Discord rollid" + "discordRoles": "Discord rollid", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Õiguste loendit ei õnnestunud laadida. Proovi lehte värskendada.", + "registryEmpty": "Määramiseks pole veel ühtegi õigust saadaval." }, "roleForm": { "name": "Rolli nimi", @@ -58,6 +61,7 @@ "color": "Värv" }, "permissionCategories": { + "dashboard": "Juhtpaneel", "actions": "Toimingud ja automatiseerimine", "tempvoice": "TempVoice", "welcome": "Tervitus ja hüvastijätt", @@ -74,6 +78,33 @@ "commands": "Kohandatud käsud", "settings": "Serveri seaded" }, + "resources": { + "roles": "Rollid", + "audit": "Auditilogi", + "settings": "Seaded", + "lookups": "Valijad", + "cases": "Juhtumid", + "warnings": "Hoiatused", + "punishments": "Karistused", + "rules": "Reeglid", + "analytics": "Analüütika", + "entries": "Kirjed", + "config": "Konfiguratsioon", + "test": "Testsõnumid", + "leaderboard": "Edetabel", + "users": "Kasutajad", + "rewards": "Auhinnad", + "list": "Loend", + "panels": "Paneelid", + "messages": "Sõnumid", + "events": "Sündmused" + }, + "permissionActions": { + "view": "Vaata", + "manage": "Halda", + "execute": "Käivita", + "purge": "Kustuta" + }, "audit": { "table": { "user": "Kasutaja", diff --git a/packages/i18n/src/locales/eu/permissions.json b/packages/i18n/src/locales/eu/permissions.json index 0ad55e0f..a80ade93 100644 --- a/packages/i18n/src/locales/eu/permissions.json +++ b/packages/i18n/src/locales/eu/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Lehenetsitako rola", "permissions": "Baimenak", "allBadge": "Denak", - "discordRoles": "Discord rolak" + "discordRoles": "Discord rolak", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "Baimen-zerrenda ezin izan da kargatu. Saiatu orria freskatzen.", + "registryEmpty": "Oraindik ez dago esleitzeko baimenik erabilgarri." }, "roleForm": { "name": "Rol-izena", @@ -58,6 +61,7 @@ "color": "Kolorea" }, "permissionCategories": { + "dashboard": "Kontrol-panela", "actions": "Ekintzak eta automatizazioa", "tempvoice": "TempVoice", "welcome": "Ongi etorria eta agurra", @@ -74,6 +78,33 @@ "commands": "Komando pertsonalizatuak", "settings": "Zerbitzariaren ezarpenak" }, + "resources": { + "roles": "Rolak", + "audit": "Auditoretza-erregistroa", + "settings": "Ezarpenak", + "lookups": "Hautatzaileak", + "cases": "Kasuak", + "warnings": "Abisuak", + "punishments": "Zigorrak", + "rules": "Arauak", + "analytics": "Analitika", + "entries": "Sarrerak", + "config": "Konfigurazioa", + "test": "Proba-mezuak", + "leaderboard": "Sailkapena", + "users": "Erabiltzaileak", + "rewards": "Sariak", + "list": "Zerrenda", + "panels": "Panelak", + "messages": "Mezuak", + "events": "Gertaerak" + }, + "permissionActions": { + "view": "Ikusi", + "manage": "Kudeatu", + "execute": "Exekutatu", + "purge": "Garbitu" + }, "audit": { "table": { "user": "Erabiltzailea", diff --git a/packages/i18n/src/locales/fa/permissions.json b/packages/i18n/src/locales/fa/permissions.json index a5149d07..5e12ef2c 100644 --- a/packages/i18n/src/locales/fa/permissions.json +++ b/packages/i18n/src/locales/fa/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "نقش پیش‌فرض", "permissions": "دسترسی‌ها", "allBadge": "همه", - "discordRoles": "نقش‌های Discord" + "discordRoles": "نقش‌های Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "فهرست دسترسی‌ها بارگذاری نشد. صفحه را دوباره بارگذاری کنید.", + "registryEmpty": "هنوز هیچ دسترسی‌ای برای اختصاص دادن وجود ندارد." }, "roleForm": { "name": "نام نقش", @@ -58,6 +61,7 @@ "color": "رنگ" }, "permissionCategories": { + "dashboard": "داشبورد", "actions": "عملیات و اتوماسیون", "tempvoice": "کانال‌های صوتی موقت", "welcome": "خوش‌آمدگویی و بدرقه", @@ -74,6 +78,33 @@ "commands": "دستورات سفارشی", "settings": "تنظیمات سرور" }, + "resources": { + "roles": "نقش‌ها", + "audit": "گزارش بازرسی", + "settings": "تنظیمات", + "lookups": "انتخابگرها", + "cases": "پرونده‌ها", + "warnings": "هشدارها", + "punishments": "تنبیهات", + "rules": "قوانین", + "analytics": "تحلیل‌ها", + "entries": "ورودی‌ها", + "config": "پیکربندی", + "test": "پیام‌های آزمایشی", + "leaderboard": "جدول رتبه‌بندی", + "users": "کاربران", + "rewards": "پاداش‌ها", + "list": "فهرست", + "panels": "پنل‌ها", + "messages": "پیام‌ها", + "events": "رویدادها" + }, + "permissionActions": { + "view": "مشاهده", + "manage": "مدیریت", + "execute": "اجرا", + "purge": "پاک‌سازی" + }, "audit": { "table": { "user": "کاربر", diff --git a/packages/i18n/src/locales/fi/permissions.json b/packages/i18n/src/locales/fi/permissions.json index 4a90b9ed..42d0d6fd 100644 --- a/packages/i18n/src/locales/fi/permissions.json +++ b/packages/i18n/src/locales/fi/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Oletusrooli", "permissions": "Oikeudet", "allBadge": "Kaikki", - "discordRoles": "Discord-roolit" + "discordRoles": "Discord-roolit", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Käyttöoikeusluetteloa ei voitu ladata. Yritä päivittää sivu.", + "registryEmpty": "Jaettavia käyttöoikeuksia ei ole vielä saatavilla." }, "roleForm": { "name": "Roolin nimi", @@ -58,6 +61,7 @@ "color": "Väri" }, "permissionCategories": { + "dashboard": "Hallintapaneeli", "actions": "Toiminnot ja automaatio", "tempvoice": "TempVoice", "welcome": "Tervetuloa ja hyvästit", @@ -74,6 +78,33 @@ "commands": "Mukautetut komennot", "settings": "Palvelinasetukset" }, + "resources": { + "roles": "Roolit", + "audit": "Tarkastusloki", + "settings": "Asetukset", + "lookups": "Valitsimet", + "cases": "Tapaukset", + "warnings": "Varoitukset", + "punishments": "Rangaistukset", + "rules": "Säännöt", + "analytics": "Analytiikka", + "entries": "Merkinnät", + "config": "Kokoonpano", + "test": "Testiviestit", + "leaderboard": "Tulostaulukko", + "users": "Käyttäjät", + "rewards": "Palkinnot", + "list": "Luettelo", + "panels": "Paneelit", + "messages": "Viestit", + "events": "Tapahtumat" + }, + "permissionActions": { + "view": "Näytä", + "manage": "Hallinnoi", + "execute": "Suorita", + "purge": "Tyhjennä" + }, "audit": { "table": { "user": "Käyttäjä", diff --git a/packages/i18n/src/locales/fil/permissions.json b/packages/i18n/src/locales/fil/permissions.json index 23901af3..18571aee 100644 --- a/packages/i18n/src/locales/fil/permissions.json +++ b/packages/i18n/src/locales/fil/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Default na role", "permissions": "Mga Pahintulot", "allBadge": "Lahat", - "discordRoles": "Mga Discord Role" + "discordRoles": "Mga Discord Role", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Hindi ma-load ang listahan ng pahintulot. Subukang i-refresh ang pahina.", + "registryEmpty": "Wala pang mga pahintulot na available na itatalaga." }, "roleForm": { "name": "Pangalan ng Role", @@ -58,6 +61,7 @@ "color": "Kulay" }, "permissionCategories": { + "dashboard": "Dashboard", "actions": "Mga Aksyon at Automation", "tempvoice": "TempVoice", "welcome": "Maligayang Pagdating at Paalam", @@ -74,6 +78,33 @@ "commands": "Mga Custom na Utos", "settings": "Mga Setting ng Server" }, + "resources": { + "roles": "Mga Role", + "audit": "Talaan ng Pag-audit", + "settings": "Mga Setting", + "lookups": "Mga Pipili", + "cases": "Mga Kaso", + "warnings": "Mga Babala", + "punishments": "Mga Parusa", + "rules": "Mga Panuntunan", + "analytics": "Analitika", + "entries": "Mga Entry", + "config": "Konpigurasyon", + "test": "Mga Pagsubok na Mensahe", + "leaderboard": "Leaderboard", + "users": "Mga User", + "rewards": "Mga Gantimpala", + "list": "Listahan", + "panels": "Mga Panel", + "messages": "Mga Mensahe", + "events": "Mga Event" + }, + "permissionActions": { + "view": "Tingnan", + "manage": "Pamahalaan", + "execute": "Isagawa", + "purge": "Linisin" + }, "audit": { "table": { "user": "User", diff --git a/packages/i18n/src/locales/fr/permissions.json b/packages/i18n/src/locales/fr/permissions.json index 04b3c89f..94fe98fe 100644 --- a/packages/i18n/src/locales/fr/permissions.json +++ b/packages/i18n/src/locales/fr/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Role par defaut", "permissions": "Permissions", "allBadge": "Toutes", - "discordRoles": "Roles Discord" + "discordRoles": "Roles Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Impossible de charger la liste des permissions. Essayez d'actualiser la page.", + "registryEmpty": "Aucune permission n'est encore disponible à attribuer." }, "roleForm": { "name": "Nom du role", @@ -58,6 +61,7 @@ "color": "Couleur" }, "permissionCategories": { + "dashboard": "Tableau de bord", "actions": "Actions et automatisation", "tempvoice": "TempVoice", "welcome": "Bienvenue et adieu", @@ -74,6 +78,33 @@ "commands": "Commandes personnalisees", "settings": "Parametres du serveur" }, + "resources": { + "roles": "Rôles", + "audit": "Journal d'audit", + "settings": "Paramètres", + "lookups": "Sélecteurs", + "cases": "Cas", + "warnings": "Avertissements", + "punishments": "Sanctions", + "rules": "Règles", + "analytics": "Analytique", + "entries": "Entrées", + "config": "Configuration", + "test": "Messages de test", + "leaderboard": "Classement", + "users": "Utilisateurs", + "rewards": "Récompenses", + "list": "Liste", + "panels": "Panneaux", + "messages": "Messages", + "events": "Événements" + }, + "permissionActions": { + "view": "Voir", + "manage": "Gérer", + "execute": "Exécuter", + "purge": "Purger" + }, "audit": { "table": { "user": "Utilisateur", diff --git a/packages/i18n/src/locales/gl/permissions.json b/packages/i18n/src/locales/gl/permissions.json index caddb163..19262c09 100644 --- a/packages/i18n/src/locales/gl/permissions.json +++ b/packages/i18n/src/locales/gl/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Rol predeterminado", "permissions": "Permisos", "allBadge": "Todos", - "discordRoles": "Roles de Discord" + "discordRoles": "Roles de Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Non se puido cargar a lista de permisos. Proba a actualizar a páxina.", + "registryEmpty": "Aínda non hai permisos dispoñibles para asignar." }, "roleForm": { "name": "Nome do Rol", @@ -58,6 +61,7 @@ "color": "Cor" }, "permissionCategories": { + "dashboard": "Panel de control", "actions": "Accións e Automatización", "tempvoice": "TempVoice", "welcome": "Benvida e Despedida", @@ -74,6 +78,33 @@ "commands": "Ordes Personalizadas", "settings": "Configuración do Servidor" }, + "resources": { + "roles": "Roles", + "audit": "Rexistro de Auditoría", + "settings": "Configuración", + "lookups": "Selectores", + "cases": "Casos", + "warnings": "Advertencias", + "punishments": "Sancións", + "rules": "Regras", + "analytics": "Analítica", + "entries": "Entradas", + "config": "Configuración", + "test": "Mensaxes de proba", + "leaderboard": "Clasificación", + "users": "Usuarios", + "rewards": "Recompensas", + "list": "Lista", + "panels": "Paneis", + "messages": "Mensaxes", + "events": "Eventos" + }, + "permissionActions": { + "view": "Ver", + "manage": "Xestionar", + "execute": "Executar", + "purge": "Purgar" + }, "audit": { "table": { "user": "Usuario", diff --git a/packages/i18n/src/locales/he/permissions.json b/packages/i18n/src/locales/he/permissions.json index 052df03c..0f974e53 100644 --- a/packages/i18n/src/locales/he/permissions.json +++ b/packages/i18n/src/locales/he/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "תפקיד ברירת מחדל", "permissions": "הרשאות", "allBadge": "הכל", - "discordRoles": "תפקידי Discord" + "discordRoles": "תפקידי Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "לא ניתן היה לטעון את רשימת ההרשאות. נסה לרענן את הדף.", + "registryEmpty": "אין עדיין הרשאות זמינות להקצאה." }, "roleForm": { "name": "שם תפקיד", @@ -58,6 +61,7 @@ "color": "צבע" }, "permissionCategories": { + "dashboard": "לוח בקרה", "actions": "פעולות ואוטומציה", "tempvoice": "קול זמני", "welcome": "ברוכים הבאים ופרידה", @@ -74,6 +78,33 @@ "commands": "פקודות מותאמות", "settings": "הגדרות שרת" }, + "resources": { + "roles": "תפקידים", + "audit": "יומן ביקורת", + "settings": "הגדרות", + "lookups": "בוררים", + "cases": "תיקים", + "warnings": "אזהרות", + "punishments": "ענישה", + "rules": "חוקים", + "analytics": "ניתוחים", + "entries": "רשומות", + "config": "תצורה", + "test": "הודעות בדיקה", + "leaderboard": "טבלת מובילים", + "users": "משתמשים", + "rewards": "תגמולים", + "list": "רשימה", + "panels": "לוחות", + "messages": "הודעות", + "events": "אירועים" + }, + "permissionActions": { + "view": "הצגה", + "manage": "ניהול", + "execute": "הרצה", + "purge": "ניקוי" + }, "audit": { "table": { "user": "משתמש", diff --git a/packages/i18n/src/locales/hi/permissions.json b/packages/i18n/src/locales/hi/permissions.json index 5ba6131b..21aeb0de 100644 --- a/packages/i18n/src/locales/hi/permissions.json +++ b/packages/i18n/src/locales/hi/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "डिफ़ॉल्ट रोल", "permissions": "अनुमतियां", "allBadge": "सभी", - "discordRoles": "Discord रोल" + "discordRoles": "Discord रोल", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "अनुमति सूची लोड नहीं हो सकी। पृष्ठ को रीफ्रेश करने का प्रयास करें।", + "registryEmpty": "अभी तक असाइन करने के लिए कोई अनुमति उपलब्ध नहीं है।" }, "roleForm": { "name": "रोल का नाम", @@ -58,6 +61,7 @@ "color": "रंग" }, "permissionCategories": { + "dashboard": "डैशबोर्ड", "actions": "एक्शन और ऑटोमेशन", "tempvoice": "TempVoice", "welcome": "स्वागत और विदाई", @@ -74,6 +78,33 @@ "commands": "कस्टम कमांड", "settings": "सर्वर सेटिंग्स" }, + "resources": { + "roles": "रोल", + "audit": "ऑडिट लॉग", + "settings": "सेटिंग्स", + "lookups": "चयनकर्ता", + "cases": "मामले", + "warnings": "चेतावनियाँ", + "punishments": "दंड", + "rules": "नियम", + "analytics": "विश्लेषण", + "entries": "प्रविष्टियां", + "config": "कॉन्फ़िगरेशन", + "test": "परीक्षण संदेश", + "leaderboard": "लीडरबोर्ड", + "users": "उपयोगकर्ता", + "rewards": "पुरस्कार", + "list": "सूची", + "panels": "पैनल", + "messages": "संदेश", + "events": "इवेंट" + }, + "permissionActions": { + "view": "देखें", + "manage": "प्रबंधित करें", + "execute": "निष्पादित करें", + "purge": "हटाएं" + }, "audit": { "table": { "user": "उपयोगकर्ता", diff --git a/packages/i18n/src/locales/hr/permissions.json b/packages/i18n/src/locales/hr/permissions.json index d7ca9d10..afa1e42c 100644 --- a/packages/i18n/src/locales/hr/permissions.json +++ b/packages/i18n/src/locales/hr/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Zadana uloga", "permissions": "Dozvole", "allBadge": "Sve", - "discordRoles": "Discord uloge" + "discordRoles": "Discord uloge", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Popis dozvola nije uspio učitati. Pokušajte osvježiti stranicu.", + "registryEmpty": "Trenutno nema dostupnih dozvola za dodjelu." }, "roleForm": { "name": "Naziv uloge", @@ -58,6 +61,7 @@ "color": "Boja" }, "permissionCategories": { + "dashboard": "Nadzorna ploča", "actions": "Radnje i automatizacija", "tempvoice": "TempVoice", "welcome": "Dobrodoslica i oprostaj", @@ -74,6 +78,33 @@ "commands": "Prilagodene naredbe", "settings": "Postavke posluzitelja" }, + "resources": { + "roles": "Uloge", + "audit": "Revizijski zapis", + "settings": "Postavke", + "lookups": "Birači", + "cases": "Slučajevi", + "warnings": "Upozorenja", + "punishments": "Kazne", + "rules": "Pravila", + "analytics": "Analitika", + "entries": "Unosi", + "config": "Konfiguracija", + "test": "Testne poruke", + "leaderboard": "Ljestvica", + "users": "Korisnici", + "rewards": "Nagrade", + "list": "Popis", + "panels": "Paneli", + "messages": "Poruke", + "events": "Događaji" + }, + "permissionActions": { + "view": "Pregled", + "manage": "Upravljanje", + "execute": "Izvršavanje", + "purge": "Brisanje" + }, "audit": { "table": { "user": "Korisnik", diff --git a/packages/i18n/src/locales/hu/permissions.json b/packages/i18n/src/locales/hu/permissions.json index 9c70a0f6..1ea0468b 100644 --- a/packages/i18n/src/locales/hu/permissions.json +++ b/packages/i18n/src/locales/hu/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Default role", "permissions": "Permissions", "allBadge": "All", - "discordRoles": "Discord Roles" + "discordRoles": "Discord Roles", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "A jogosultságlistát nem sikerült betölteni. Próbáld frissíteni az oldalt.", + "registryEmpty": "Még nincsenek kiosztható jogosultságok." }, "roleForm": { "name": "Role Name", @@ -58,6 +61,7 @@ "color": "Color" }, "permissionCategories": { + "dashboard": "Irányítópult", "actions": "Actions & Automation", "tempvoice": "TempVoice", "welcome": "Welcome & Farewell", @@ -74,6 +78,33 @@ "commands": "Custom Commands", "settings": "Server Settings" }, + "resources": { + "roles": "Szerepek", + "audit": "Auditnapló", + "settings": "Beállítások", + "lookups": "Választók", + "cases": "Esetek", + "warnings": "Figyelmeztetések", + "punishments": "Büntetések", + "rules": "Szabályok", + "analytics": "Elemzések", + "entries": "Bejegyzések", + "config": "Konfiguráció", + "test": "Tesztüzenetek", + "leaderboard": "Ranglista", + "users": "Felhasználók", + "rewards": "Jutalmak", + "list": "Lista", + "panels": "Panelek", + "messages": "Üzenetek", + "events": "Események" + }, + "permissionActions": { + "view": "Megtekintés", + "manage": "Kezelés", + "execute": "Végrehajtás", + "purge": "Törlés" + }, "audit": { "table": { "user": "User", diff --git a/packages/i18n/src/locales/id/permissions.json b/packages/i18n/src/locales/id/permissions.json index d5a7ebf7..1cc8ba04 100644 --- a/packages/i18n/src/locales/id/permissions.json +++ b/packages/i18n/src/locales/id/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Peran bawaan", "permissions": "Izin", "allBadge": "Semua", - "discordRoles": "Peran Discord" + "discordRoles": "Peran Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Daftar izin tidak dapat dimuat. Coba segarkan halaman.", + "registryEmpty": "Belum ada izin yang tersedia untuk diberikan." }, "roleForm": { "name": "Nama Peran", @@ -58,6 +61,7 @@ "color": "Warna" }, "permissionCategories": { + "dashboard": "Dasbor", "actions": "Aksi & Otomasi", "tempvoice": "TempVoice", "welcome": "Sambutan & Perpisahan", @@ -74,6 +78,33 @@ "commands": "Perintah Kustom", "settings": "Pengaturan Server" }, + "resources": { + "roles": "Peran", + "audit": "Log Audit", + "settings": "Pengaturan", + "lookups": "Pemilih", + "cases": "Kasus", + "warnings": "Peringatan", + "punishments": "Hukuman", + "rules": "Aturan", + "analytics": "Analitik", + "entries": "Entri", + "config": "Konfigurasi", + "test": "Pesan Uji Coba", + "leaderboard": "Papan Peringkat", + "users": "Pengguna", + "rewards": "Hadiah", + "list": "Daftar", + "panels": "Panel", + "messages": "Pesan", + "events": "Kejadian" + }, + "permissionActions": { + "view": "Lihat", + "manage": "Kelola", + "execute": "Jalankan", + "purge": "Bersihkan" + }, "audit": { "table": { "user": "Pengguna", diff --git a/packages/i18n/src/locales/it/permissions.json b/packages/i18n/src/locales/it/permissions.json index 2e94c967..56268c16 100644 --- a/packages/i18n/src/locales/it/permissions.json +++ b/packages/i18n/src/locales/it/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Ruolo predefinito", "permissions": "Permessi", "allBadge": "Tutti", - "discordRoles": "Ruoli Discord" + "discordRoles": "Ruoli Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Impossibile caricare l'elenco dei permessi. Prova ad aggiornare la pagina.", + "registryEmpty": "Non ci sono ancora permessi disponibili da assegnare." }, "roleForm": { "name": "Nome del ruolo", @@ -58,6 +61,7 @@ "color": "Colore" }, "permissionCategories": { + "dashboard": "Pannello di controllo", "actions": "Azioni e automazione", "tempvoice": "TempVoice", "welcome": "Benvenuto e addio", @@ -74,6 +78,33 @@ "commands": "Comandi personalizzati", "settings": "Impostazioni server" }, + "resources": { + "roles": "Ruoli", + "audit": "Registro di audit", + "settings": "Impostazioni", + "lookups": "Selettori", + "cases": "Casi", + "warnings": "Avvertimenti", + "punishments": "Sanzioni", + "rules": "Regole", + "analytics": "Analisi", + "entries": "Voci", + "config": "Configurazione", + "test": "Messaggi di prova", + "leaderboard": "Classifica", + "users": "Utenti", + "rewards": "Ricompense", + "list": "Elenco", + "panels": "Pannelli", + "messages": "Messaggi", + "events": "Eventi" + }, + "permissionActions": { + "view": "Visualizza", + "manage": "Gestisci", + "execute": "Esegui", + "purge": "Elimina" + }, "audit": { "table": { "user": "Utente", diff --git a/packages/i18n/src/locales/ja/permissions.json b/packages/i18n/src/locales/ja/permissions.json index ae06668b..b9bc3b75 100644 --- a/packages/i18n/src/locales/ja/permissions.json +++ b/packages/i18n/src/locales/ja/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "デフォルトロール", "permissions": "権限", "allBadge": "すべて", - "discordRoles": "Discordロール" + "discordRoles": "Discordロール", + "permissionLabel": "{{resource}}{{action}}", + "registryError": "権限リストを読み込めませんでした。ページを更新してください。", + "registryEmpty": "割り当てられる権限はまだありません。" }, "roleForm": { "name": "ロール名", @@ -58,6 +61,7 @@ "color": "色" }, "permissionCategories": { + "dashboard": "ダッシュボード", "actions": "アクション&自動化", "tempvoice": "テンポラリボイス", "welcome": "ウェルカム&お別れ", @@ -74,6 +78,33 @@ "commands": "カスタムコマンド", "settings": "サーバー設定" }, + "resources": { + "roles": "ロール", + "audit": "監査ログ", + "settings": "設定", + "lookups": "セレクター", + "cases": "ケース", + "warnings": "警告", + "punishments": "処罰", + "rules": "ルール", + "analytics": "分析", + "entries": "項目", + "config": "コンフィグ", + "test": "テストメッセージ", + "leaderboard": "リーダーボード", + "users": "ユーザー", + "rewards": "報酬", + "list": "リスト", + "panels": "パネル", + "messages": "メッセージ", + "events": "イベント" + }, + "permissionActions": { + "view": "表示", + "manage": "管理", + "execute": "実行", + "purge": "消去" + }, "audit": { "table": { "user": "ユーザー", diff --git a/packages/i18n/src/locales/ko/permissions.json b/packages/i18n/src/locales/ko/permissions.json index b53df19a..34f648c7 100644 --- a/packages/i18n/src/locales/ko/permissions.json +++ b/packages/i18n/src/locales/ko/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "기본 역할", "permissions": "권한", "allBadge": "전체", - "discordRoles": "Discord 역할" + "discordRoles": "Discord 역할", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "권한 목록을 불러올 수 없습니다. 페이지를 새로고침해 보세요.", + "registryEmpty": "아직 할당할 수 있는 권한이 없습니다." }, "roleForm": { "name": "역할 이름", @@ -58,6 +61,7 @@ "color": "색상" }, "permissionCategories": { + "dashboard": "대시보드", "actions": "액션 및 자동화", "tempvoice": "임시 음성", "welcome": "환영 및 작별", @@ -74,6 +78,33 @@ "commands": "커스텀 명령어", "settings": "서버 설정" }, + "resources": { + "roles": "역할", + "audit": "감사 로그", + "settings": "설정", + "lookups": "선택기", + "cases": "케이스", + "warnings": "경고", + "punishments": "처벌", + "rules": "규칙", + "analytics": "분석", + "entries": "항목", + "config": "구성", + "test": "테스트 메시지", + "leaderboard": "리더보드", + "users": "사용자", + "rewards": "보상", + "list": "목록", + "panels": "패널", + "messages": "메시지", + "events": "이벤트" + }, + "permissionActions": { + "view": "보기", + "manage": "관리", + "execute": "실행", + "purge": "삭제" + }, "audit": { "table": { "user": "사용자", diff --git a/packages/i18n/src/locales/lt/permissions.json b/packages/i18n/src/locales/lt/permissions.json index f2acffac..2e843ab3 100644 --- a/packages/i18n/src/locales/lt/permissions.json +++ b/packages/i18n/src/locales/lt/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Numatytoji role", "permissions": "Leidimai", "allBadge": "Visi", - "discordRoles": "Discord roles" + "discordRoles": "Discord roles", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Nepavyko įkelti leidimų sąrašo. Pabandykite iš naujo įkelti puslapį.", + "registryEmpty": "Kol kas nėra priskirtinų leidimų." }, "roleForm": { "name": "Roles pavadinimas", @@ -58,6 +61,7 @@ "color": "Spalva" }, "permissionCategories": { + "dashboard": "Valdymo skydelis", "actions": "Veiksmai ir automatizavimas", "tempvoice": "TempVoice", "welcome": "Pasisveikinimas ir atsisveikinimas", @@ -74,6 +78,33 @@ "commands": "Pasirinktines komandos", "settings": "Serverio nustatymai" }, + "resources": { + "roles": "Rolės", + "audit": "Audito žurnalas", + "settings": "Nustatymai", + "lookups": "Parinkikliai", + "cases": "Atvejai", + "warnings": "Įspėjimai", + "punishments": "Bausmės", + "rules": "Taisyklės", + "analytics": "Analitika", + "entries": "Įrašai", + "config": "Konfigūracija", + "test": "Bandomieji pranešimai", + "leaderboard": "Lyderių lentelė", + "users": "Naudotojai", + "rewards": "Apdovanojimai", + "list": "Sąrašas", + "panels": "Paneliai", + "messages": "Žinutės", + "events": "Įvykiai" + }, + "permissionActions": { + "view": "Peržiūrėti", + "manage": "Valdyti", + "execute": "Vykdyti", + "purge": "Išvalyti" + }, "audit": { "table": { "user": "Naudotojas", diff --git a/packages/i18n/src/locales/lv/permissions.json b/packages/i18n/src/locales/lv/permissions.json index 0d1590c5..5ae4ebfa 100644 --- a/packages/i18n/src/locales/lv/permissions.json +++ b/packages/i18n/src/locales/lv/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Noklusetaa loma", "permissions": "Atlaujas", "allBadge": "Visas", - "discordRoles": "Discord lomas" + "discordRoles": "Discord lomas", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Neizdevās ielādēt atļauju sarakstu. Mēģiniet atsvaidzināt lapu.", + "registryEmpty": "Pagaidām nav pieejamu atļauju piešķiršanai." }, "roleForm": { "name": "Lomas nosaukums", @@ -58,6 +61,7 @@ "color": "Krasa" }, "permissionCategories": { + "dashboard": "Vadības panelis", "actions": "Darbibas un automatizacija", "tempvoice": "TempVoice", "welcome": "Sveiciens un atvadas", @@ -74,6 +78,33 @@ "commands": "Pieelagotas komandas", "settings": "Servera iestatijumi" }, + "resources": { + "roles": "Lomas", + "audit": "Audita žurnāls", + "settings": "Iestatījumi", + "lookups": "Atlasītāji", + "cases": "Gadījumi", + "warnings": "Brīdinājumi", + "punishments": "Sodi", + "rules": "Noteikumi", + "analytics": "Analītika", + "entries": "Ieraksti", + "config": "Konfigurācija", + "test": "Testa ziņojumi", + "leaderboard": "Līderu tabula", + "users": "Lietotāji", + "rewards": "Balvas", + "list": "Saraksts", + "panels": "Paneļi", + "messages": "Ziņojumi", + "events": "Notikumi" + }, + "permissionActions": { + "view": "Skatīt", + "manage": "Pārvaldīt", + "execute": "Izpildīt", + "purge": "Notīrīt" + }, "audit": { "table": { "user": "Lietotajs", diff --git a/packages/i18n/src/locales/ms/permissions.json b/packages/i18n/src/locales/ms/permissions.json index 9225b804..bb58224d 100644 --- a/packages/i18n/src/locales/ms/permissions.json +++ b/packages/i18n/src/locales/ms/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Peranan lalai", "permissions": "Kebenaran", "allBadge": "Semua", - "discordRoles": "Peranan Discord" + "discordRoles": "Peranan Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Senarai kebenaran tidak dapat dimuatkan. Cuba muat semula halaman.", + "registryEmpty": "Belum ada kebenaran tersedia untuk diberikan." }, "roleForm": { "name": "Nama Peranan", @@ -58,6 +61,7 @@ "color": "Warna" }, "permissionCategories": { + "dashboard": "Papan Pemuka", "actions": "Tindakan & Automasi", "tempvoice": "TempVoice", "welcome": "Selamat Datang & Selamat Tinggal", @@ -74,6 +78,33 @@ "commands": "Arahan Tersuai", "settings": "Tetapan Pelayan" }, + "resources": { + "roles": "Peranan", + "audit": "Log Audit", + "settings": "Tetapan", + "lookups": "Pemilih", + "cases": "Kes", + "warnings": "Amaran", + "punishments": "Hukuman", + "rules": "Peraturan", + "analytics": "Analitik", + "entries": "Entri", + "config": "Konfigurasi", + "test": "Mesej Ujian", + "leaderboard": "Papan Pendahulu", + "users": "Pengguna", + "rewards": "Ganjaran", + "list": "Senarai", + "panels": "Panel", + "messages": "Mesej", + "events": "Peristiwa" + }, + "permissionActions": { + "view": "Lihat", + "manage": "Urus", + "execute": "Laksana", + "purge": "Bersihkan" + }, "audit": { "table": { "user": "Pengguna", diff --git a/packages/i18n/src/locales/nl/permissions.json b/packages/i18n/src/locales/nl/permissions.json index a563cc5b..8511debb 100644 --- a/packages/i18n/src/locales/nl/permissions.json +++ b/packages/i18n/src/locales/nl/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Standaardrol", "permissions": "Rechten", "allBadge": "Alle", - "discordRoles": "Discord-rollen" + "discordRoles": "Discord-rollen", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "De machtigingenlijst kon niet worden geladen. Probeer de pagina te vernieuwen.", + "registryEmpty": "Er zijn nog geen machtigingen beschikbaar om toe te wijzen." }, "roleForm": { "name": "Rolnaam", @@ -58,6 +61,7 @@ "color": "Kleur" }, "permissionCategories": { + "dashboard": "Dashboard", "actions": "Acties & Automatisering", "tempvoice": "TempVoice", "welcome": "Welkom & Afscheid", @@ -74,6 +78,33 @@ "commands": "Aangepaste commando's", "settings": "Serverinstellingen" }, + "resources": { + "roles": "Rollen", + "audit": "Auditlogboek", + "settings": "Instellingen", + "lookups": "Kiezers", + "cases": "Zaken", + "warnings": "Waarschuwingen", + "punishments": "Straffen", + "rules": "Regels", + "analytics": "Analyses", + "entries": "Items", + "config": "Configuratie", + "test": "Testberichten", + "leaderboard": "Klassement", + "users": "Gebruikers", + "rewards": "Beloningen", + "list": "Lijst", + "panels": "Panelen", + "messages": "Berichten", + "events": "Gebeurtenissen" + }, + "permissionActions": { + "view": "Weergeven", + "manage": "Beheren", + "execute": "Uitvoeren", + "purge": "Wissen" + }, "audit": { "table": { "user": "Gebruiker", diff --git a/packages/i18n/src/locales/no/permissions.json b/packages/i18n/src/locales/no/permissions.json index eec77bf9..72ac718e 100644 --- a/packages/i18n/src/locales/no/permissions.json +++ b/packages/i18n/src/locales/no/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Standardrolle", "permissions": "Tillatelser", "allBadge": "Alle", - "discordRoles": "Discord-roller" + "discordRoles": "Discord-roller", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Kunne ikke laste inn tillatelseslisten. Prøv å oppdatere siden.", + "registryEmpty": "Det finnes ingen tillatelser å tildele ennå." }, "roleForm": { "name": "Rollenavn", @@ -58,6 +61,7 @@ "color": "Farge" }, "permissionCategories": { + "dashboard": "Kontrollpanel", "actions": "Handlinger og automatisering", "tempvoice": "TempVoice", "welcome": "Velkommen og farvel", @@ -74,6 +78,33 @@ "commands": "Egendefinerte kommandoer", "settings": "Serverinnstillinger" }, + "resources": { + "roles": "Roller", + "audit": "Revisjonslogg", + "settings": "Innstillinger", + "lookups": "Velgere", + "cases": "Saker", + "warnings": "Advarsler", + "punishments": "Straffer", + "rules": "Regler", + "analytics": "Analyser", + "entries": "Oppføringer", + "config": "Konfigurasjon", + "test": "Testmeldinger", + "leaderboard": "Ledertavle", + "users": "Brukere", + "rewards": "Belønninger", + "list": "Liste", + "panels": "Paneler", + "messages": "Meldinger", + "events": "Hendelser" + }, + "permissionActions": { + "view": "Vis", + "manage": "Administrer", + "execute": "Kjør", + "purge": "Tøm" + }, "audit": { "table": { "user": "Bruker", diff --git a/packages/i18n/src/locales/pl/permissions.json b/packages/i18n/src/locales/pl/permissions.json index 5919aa86..36ee429d 100644 --- a/packages/i18n/src/locales/pl/permissions.json +++ b/packages/i18n/src/locales/pl/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Rola domyślna", "permissions": "Uprawnienia", "allBadge": "Wszystkie", - "discordRoles": "Role Discord" + "discordRoles": "Role Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Nie udało się wczytać listy uprawnień. Spróbuj odświeżyć stronę.", + "registryEmpty": "Nie ma jeszcze żadnych uprawnień do przypisania." }, "roleForm": { "name": "Nazwa roli", @@ -58,6 +61,7 @@ "color": "Kolor" }, "permissionCategories": { + "dashboard": "Pulpit nawigacyjny", "actions": "Akcje i automatyzacja", "tempvoice": "TempVoice", "welcome": "Powitanie i pożegnanie", @@ -74,6 +78,33 @@ "commands": "Komendy niestandardowe", "settings": "Ustawienia serwera" }, + "resources": { + "roles": "Role", + "audit": "Dziennik audytu", + "settings": "Ustawienia", + "lookups": "Selektory", + "cases": "Sprawy", + "warnings": "Ostrzeżenia", + "punishments": "Kary", + "rules": "Reguły", + "analytics": "Analityka", + "entries": "Wpisy", + "config": "Konfiguracja", + "test": "Wiadomości testowe", + "leaderboard": "Ranking", + "users": "Użytkownicy", + "rewards": "Nagrody", + "list": "Lista", + "panels": "Panele", + "messages": "Wiadomości", + "events": "Zdarzenia" + }, + "permissionActions": { + "view": "Wyświetl", + "manage": "Zarządzaj", + "execute": "Wykonaj", + "purge": "Wyczyść" + }, "audit": { "table": { "user": "Użytkownik", diff --git a/packages/i18n/src/locales/pt/permissions.json b/packages/i18n/src/locales/pt/permissions.json index ad631a92..8b112e67 100644 --- a/packages/i18n/src/locales/pt/permissions.json +++ b/packages/i18n/src/locales/pt/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Cargo predefinido", "permissions": "Permissoes", "allBadge": "Todas", - "discordRoles": "Cargos do Discord" + "discordRoles": "Cargos do Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Não foi possível carregar a lista de permissões. Tente atualizar a página.", + "registryEmpty": "Ainda não há permissões disponíveis para atribuir." }, "roleForm": { "name": "Nome do cargo", @@ -58,6 +61,7 @@ "color": "Cor" }, "permissionCategories": { + "dashboard": "Painel de controlo", "actions": "Acoes e automatizacao", "tempvoice": "TempVoice", "welcome": "Boas-vindas e despedida", @@ -74,6 +78,33 @@ "commands": "Comandos personalizados", "settings": "Definicoes do servidor" }, + "resources": { + "roles": "Cargos", + "audit": "Registo de auditoria", + "settings": "Definições", + "lookups": "Seletores", + "cases": "Casos", + "warnings": "Avisos", + "punishments": "Punições", + "rules": "Regras", + "analytics": "Análises", + "entries": "Entradas", + "config": "Configuração", + "test": "Mensagens de teste", + "leaderboard": "Classificação", + "users": "Utilizadores", + "rewards": "Recompensas", + "list": "Lista", + "panels": "Painéis", + "messages": "Mensagens", + "events": "Eventos" + }, + "permissionActions": { + "view": "Ver", + "manage": "Gerir", + "execute": "Executar", + "purge": "Limpar" + }, "audit": { "table": { "user": "Utilizador", diff --git a/packages/i18n/src/locales/ro/permissions.json b/packages/i18n/src/locales/ro/permissions.json index ad9520fb..9080d7fd 100644 --- a/packages/i18n/src/locales/ro/permissions.json +++ b/packages/i18n/src/locales/ro/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Rol implicit", "permissions": "Permisiuni", "allBadge": "Toate", - "discordRoles": "Roluri Discord" + "discordRoles": "Roluri Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Lista de permisiuni nu a putut fi încărcată. Încearcă să reîmprospătezi pagina.", + "registryEmpty": "Nu există încă permisiuni disponibile pentru alocare." }, "roleForm": { "name": "Numele Rolului", @@ -58,6 +61,7 @@ "color": "Culoare" }, "permissionCategories": { + "dashboard": "Tablou de bord", "actions": "Acțiuni & Automatizare", "tempvoice": "TempVoice", "welcome": "Bun venit & La revedere", @@ -74,6 +78,33 @@ "commands": "Comenzi Personalizate", "settings": "Setări Server" }, + "resources": { + "roles": "Roluri", + "audit": "Jurnal de Audit", + "settings": "Setări", + "lookups": "Selectoare", + "cases": "Cazuri", + "warnings": "Avertismente", + "punishments": "Sancțiuni", + "rules": "Reguli", + "analytics": "Analitică", + "entries": "Intrări", + "config": "Configurare", + "test": "Mesaje de test", + "leaderboard": "Clasament", + "users": "Utilizatori", + "rewards": "Recompense", + "list": "Listă", + "panels": "Panouri", + "messages": "Mesaje", + "events": "Evenimente" + }, + "permissionActions": { + "view": "Vizualizare", + "manage": "Gestionare", + "execute": "Executare", + "purge": "Curățare" + }, "audit": { "table": { "user": "Utilizator", diff --git a/packages/i18n/src/locales/ru/permissions.json b/packages/i18n/src/locales/ru/permissions.json index b26b207f..257c9ed5 100644 --- a/packages/i18n/src/locales/ru/permissions.json +++ b/packages/i18n/src/locales/ru/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Роль по умолчанию", "permissions": "Права", "allBadge": "Все", - "discordRoles": "Роли Discord" + "discordRoles": "Роли Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Не удалось загрузить список разрешений. Попробуйте обновить страницу.", + "registryEmpty": "Пока нет доступных разрешений для назначения." }, "roleForm": { "name": "Название роли", @@ -58,6 +61,7 @@ "color": "Цвет" }, "permissionCategories": { + "dashboard": "Панель управления", "actions": "Действия и автоматизация", "tempvoice": "TempVoice", "welcome": "Приветствие и прощание", @@ -74,6 +78,33 @@ "commands": "Пользовательские команды", "settings": "Настройки сервера" }, + "resources": { + "roles": "Роли", + "audit": "Журнал аудита", + "settings": "Настройки", + "lookups": "Селекторы", + "cases": "Случаи", + "warnings": "Предупреждения", + "punishments": "Наказания", + "rules": "Правила", + "analytics": "Аналитика", + "entries": "Записи", + "config": "Конфигурация", + "test": "Тестовые сообщения", + "leaderboard": "Таблица лидеров", + "users": "Пользователи", + "rewards": "Награды", + "list": "Список", + "panels": "Панели", + "messages": "Сообщения", + "events": "События" + }, + "permissionActions": { + "view": "Просмотр", + "manage": "Управление", + "execute": "Выполнение", + "purge": "Очистка" + }, "audit": { "table": { "user": "Пользователь", diff --git a/packages/i18n/src/locales/sk/permissions.json b/packages/i18n/src/locales/sk/permissions.json index 9c70a0f6..4f91d2e7 100644 --- a/packages/i18n/src/locales/sk/permissions.json +++ b/packages/i18n/src/locales/sk/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Default role", "permissions": "Permissions", "allBadge": "All", - "discordRoles": "Discord Roles" + "discordRoles": "Discord Roles", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Zoznam oprávnení sa nepodarilo načítať. Skúste stránku obnoviť.", + "registryEmpty": "Zatiaľ nie sú k dispozícii žiadne oprávnenia na priradenie." }, "roleForm": { "name": "Role Name", @@ -58,6 +61,7 @@ "color": "Color" }, "permissionCategories": { + "dashboard": "Ovládací panel", "actions": "Actions & Automation", "tempvoice": "TempVoice", "welcome": "Welcome & Farewell", @@ -74,6 +78,33 @@ "commands": "Custom Commands", "settings": "Server Settings" }, + "resources": { + "roles": "Role", + "audit": "Denník auditu", + "settings": "Nastavenia", + "lookups": "Výbery", + "cases": "Prípady", + "warnings": "Varovania", + "punishments": "Tresty", + "rules": "Pravidlá", + "analytics": "Analytika", + "entries": "Záznamy", + "config": "Konfigurácia", + "test": "Testovacie správy", + "leaderboard": "Rebríček", + "users": "Používatelia", + "rewards": "Odmeny", + "list": "Zoznam", + "panels": "Panely", + "messages": "Správy", + "events": "Udalosti" + }, + "permissionActions": { + "view": "Zobraziť", + "manage": "Spravovať", + "execute": "Spustiť", + "purge": "Vymazať" + }, "audit": { "table": { "user": "User", diff --git a/packages/i18n/src/locales/sl/permissions.json b/packages/i18n/src/locales/sl/permissions.json index 8f8d0354..e22a9e0f 100644 --- a/packages/i18n/src/locales/sl/permissions.json +++ b/packages/i18n/src/locales/sl/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Privzeta vloga", "permissions": "Dovoljenja", "allBadge": "Vse", - "discordRoles": "Discord vloge" + "discordRoles": "Discord vloge", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Seznama dovoljenj ni bilo mogoče naložiti. Poskusite osvežiti stran.", + "registryEmpty": "Trenutno ni na voljo nobenih dovoljenj za dodelitev." }, "roleForm": { "name": "Ime vloge", @@ -58,6 +61,7 @@ "color": "Barva" }, "permissionCategories": { + "dashboard": "Nadzorna plošča", "actions": "Dejanja in avtomatizacija", "tempvoice": "TempVoice", "welcome": "Dobrodošlica in poslovitev", @@ -74,6 +78,33 @@ "commands": "Lastni ukazi", "settings": "Nastavitve strežnika" }, + "resources": { + "roles": "Vloge", + "audit": "Revizijski dnevnik", + "settings": "Nastavitve", + "lookups": "Izbirniki", + "cases": "Primeri", + "warnings": "Opozorila", + "punishments": "Kazni", + "rules": "Pravila", + "analytics": "Analitika", + "entries": "Vnosi", + "config": "Konfiguracija", + "test": "Testna sporočila", + "leaderboard": "Lestvica", + "users": "Uporabniki", + "rewards": "Nagrade", + "list": "Seznam", + "panels": "Plošče", + "messages": "Sporočila", + "events": "Dogodki" + }, + "permissionActions": { + "view": "Ogled", + "manage": "Upravljanje", + "execute": "Izvedba", + "purge": "Čiščenje" + }, "audit": { "table": { "user": "Uporabnik", diff --git a/packages/i18n/src/locales/sr/permissions.json b/packages/i18n/src/locales/sr/permissions.json index 2476e28c..c8440978 100644 --- a/packages/i18n/src/locales/sr/permissions.json +++ b/packages/i18n/src/locales/sr/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Podrazumevana uloga", "permissions": "Dozvole", "allBadge": "Sve", - "discordRoles": "Discord uloge" + "discordRoles": "Discord uloge", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Листа дозвола није могла да се учита. Покушајте да освежите страницу.", + "registryEmpty": "Тренутно нема доступних дозвола за доделу." }, "roleForm": { "name": "Naziv uloge", @@ -58,6 +61,7 @@ "color": "Boja" }, "permissionCategories": { + "dashboard": "Контролна табла", "actions": "Radnje i automatizacija", "tempvoice": "TempVoice", "welcome": "Dobrodoslica i oprostaj", @@ -74,6 +78,33 @@ "commands": "Prilagodene komande", "settings": "Podesavanja servera" }, + "resources": { + "roles": "Улоге", + "audit": "Дневник ревизије", + "settings": "Подешавања", + "lookups": "Селектори", + "cases": "Случајеви", + "warnings": "Упозорења", + "punishments": "Казне", + "rules": "Правила", + "analytics": "Аналитика", + "entries": "Уноси", + "config": "Конфигурација", + "test": "Тест поруке", + "leaderboard": "Ранг листа", + "users": "Корисници", + "rewards": "Награде", + "list": "Листа", + "panels": "Панели", + "messages": "Поруке", + "events": "Догађаји" + }, + "permissionActions": { + "view": "Преглед", + "manage": "Управљање", + "execute": "Извршавање", + "purge": "Брисање" + }, "audit": { "table": { "user": "Korisnik", diff --git a/packages/i18n/src/locales/sv/permissions.json b/packages/i18n/src/locales/sv/permissions.json index 3fc48ae2..f9f1950e 100644 --- a/packages/i18n/src/locales/sv/permissions.json +++ b/packages/i18n/src/locales/sv/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Standardroll", "permissions": "Behörigheter", "allBadge": "Alla", - "discordRoles": "Discord-roller" + "discordRoles": "Discord-roller", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Behörighetslistan kunde inte läsas in. Försök uppdatera sidan.", + "registryEmpty": "Det finns inga behörigheter att tilldela än." }, "roleForm": { "name": "Rollnamn", @@ -58,6 +61,7 @@ "color": "Färg" }, "permissionCategories": { + "dashboard": "Instrumentpanel", "actions": "Åtgärder & automatisering", "tempvoice": "TempVoice", "welcome": "Välkommen & farväl", @@ -74,6 +78,33 @@ "commands": "Anpassade kommandon", "settings": "Serverinställningar" }, + "resources": { + "roles": "Roller", + "audit": "Revisionslogg", + "settings": "Inställningar", + "lookups": "Väljare", + "cases": "Ärenden", + "warnings": "Varningar", + "punishments": "Bestraffningar", + "rules": "Regler", + "analytics": "Analys", + "entries": "Poster", + "config": "Konfiguration", + "test": "Testmeddelanden", + "leaderboard": "Topplista", + "users": "Användare", + "rewards": "Belöningar", + "list": "Lista", + "panels": "Paneler", + "messages": "Meddelanden", + "events": "Händelser" + }, + "permissionActions": { + "view": "Visa", + "manage": "Hantera", + "execute": "Kör", + "purge": "Rensa" + }, "audit": { "table": { "user": "Användare", diff --git a/packages/i18n/src/locales/sw/permissions.json b/packages/i18n/src/locales/sw/permissions.json index 21f0776d..b83c9780 100644 --- a/packages/i18n/src/locales/sw/permissions.json +++ b/packages/i18n/src/locales/sw/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Jukumu la chaguo-msingi", "permissions": "Ruhusa", "allBadge": "Zote", - "discordRoles": "Majukumu ya Discord" + "discordRoles": "Majukumu ya Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Orodha ya ruhusa haikuweza kupakiwa. Jaribu kuonyesha upya ukurasa.", + "registryEmpty": "Hakuna ruhusa zinazopatikana kugawiwa bado." }, "roleForm": { "name": "Jina la Jukumu", @@ -58,6 +61,7 @@ "color": "Rangi" }, "permissionCategories": { + "dashboard": "Dashibodi", "actions": "Vitendo na Otomatiki", "tempvoice": "TempVoice", "welcome": "Karibu na Kuaga", @@ -74,6 +78,33 @@ "commands": "Amri Maalum", "settings": "Mipangilio ya Seva" }, + "resources": { + "roles": "Majukumu", + "audit": "Kumbukumbu ya Ukaguzi", + "settings": "Mipangilio", + "lookups": "Vichaguzi", + "cases": "Kesi", + "warnings": "Maonyo", + "punishments": "Adhabu", + "rules": "Kanuni", + "analytics": "Uchambuzi", + "entries": "Kumbukumbu", + "config": "Usanidi", + "test": "Ujumbe wa Majaribio", + "leaderboard": "Orodha ya Washindi", + "users": "Watumiaji", + "rewards": "Tuzo", + "list": "Orodha", + "panels": "Paneli", + "messages": "Ujumbe", + "events": "Matukio" + }, + "permissionActions": { + "view": "Angalia", + "manage": "Simamia", + "execute": "Tekeleza", + "purge": "Futa" + }, "audit": { "table": { "user": "Mtumiaji", diff --git a/packages/i18n/src/locales/ta/permissions.json b/packages/i18n/src/locales/ta/permissions.json index 88ac2af6..494b99b3 100644 --- a/packages/i18n/src/locales/ta/permissions.json +++ b/packages/i18n/src/locales/ta/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "இயல்புநிலை பங்கு", "permissions": "அனுமதிகள்", "allBadge": "அனைத்தும்", - "discordRoles": "Discord பங்குகள்" + "discordRoles": "Discord பங்குகள்", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "அனுமதிப் பட்டியலை ஏற்ற முடியவில்லை. பக்கத்தை புதுப்பிக்க முயற்சிக்கவும்.", + "registryEmpty": "இதுவரை ஒதுக்க அனுமதிகள் எதுவும் இல்லை." }, "roleForm": { "name": "பங்கு பெயர்", @@ -58,6 +61,7 @@ "color": "நிறம்" }, "permissionCategories": { + "dashboard": "டாஷ்போர்டு", "actions": "செயல்கள் & தன்னியக்கம்", "tempvoice": "TempVoice", "welcome": "வரவேற்பு & விடைபெறுதல்", @@ -74,6 +78,33 @@ "commands": "தனிப்பயன் கட்டளைகள்", "settings": "சேவையக அமைப்புகள்" }, + "resources": { + "roles": "பங்குகள்", + "audit": "தணிக்கை பதிவு", + "settings": "அமைப்புகள்", + "lookups": "தேர்வாளர்கள்", + "cases": "வழக்குகள்", + "warnings": "எச்சரிக்கைகள்", + "punishments": "தண்டனைகள்", + "rules": "விதிகள்", + "analytics": "பகுப்பாய்வு", + "entries": "பதிவுகள்", + "config": "கட்டமைப்பு", + "test": "சோதனை செய்திகள்", + "leaderboard": "தலைவர் பட்டியல்", + "users": "பயனர்கள்", + "rewards": "வெகுமதிகள்", + "list": "பட்டியல்", + "panels": "பேனல்கள்", + "messages": "செய்திகள்", + "events": "நிகழ்வுகள்" + }, + "permissionActions": { + "view": "காண்க", + "manage": "நிர்வகி", + "execute": "இயக்கு", + "purge": "அழி" + }, "audit": { "table": { "user": "பயனர்", diff --git a/packages/i18n/src/locales/th/permissions.json b/packages/i18n/src/locales/th/permissions.json index 7829c09e..aa2cf305 100644 --- a/packages/i18n/src/locales/th/permissions.json +++ b/packages/i18n/src/locales/th/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "บทบาทเริ่มต้น", "permissions": "สิทธิ์", "allBadge": "ทั้งหมด", - "discordRoles": "บทบาท Discord" + "discordRoles": "บทบาท Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "ไม่สามารถโหลดรายการสิทธิ์ได้ ลองรีเฟรชหน้านี้", + "registryEmpty": "ยังไม่มีสิทธิ์ที่สามารถกำหนดได้" }, "roleForm": { "name": "ชื่อบทบาท", @@ -58,6 +61,7 @@ "color": "สี" }, "permissionCategories": { + "dashboard": "แดชบอร์ด", "actions": "แอ็กชันและระบบอัตโนมัติ", "tempvoice": "ห้องเสียงชั่วคราว", "welcome": "ต้อนรับและอำลา", @@ -74,6 +78,33 @@ "commands": "คำสั่งกำหนดเอง", "settings": "การตั้งค่าเซิร์ฟเวอร์" }, + "resources": { + "roles": "บทบาท", + "audit": "บันทึกตรวจสอบ", + "settings": "การตั้งค่า", + "lookups": "ตัวเลือก", + "cases": "เคส", + "warnings": "คำเตือน", + "punishments": "การลงโทษ", + "rules": "กฎ", + "analytics": "การวิเคราะห์", + "entries": "รายการ", + "config": "การกำหนดค่า", + "test": "ข้อความทดสอบ", + "leaderboard": "ลีดเดอร์บอร์ด", + "users": "ผู้ใช้", + "rewards": "รางวัล", + "list": "รายชื่อ", + "panels": "แผง", + "messages": "ข้อความ", + "events": "เหตุการณ์" + }, + "permissionActions": { + "view": "ดู", + "manage": "จัดการ", + "execute": "ดำเนินการ", + "purge": "ล้างข้อมูล" + }, "audit": { "table": { "user": "ผู้ใช้", diff --git a/packages/i18n/src/locales/tr/permissions.json b/packages/i18n/src/locales/tr/permissions.json index 1ab033e0..9901c765 100644 --- a/packages/i18n/src/locales/tr/permissions.json +++ b/packages/i18n/src/locales/tr/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Varsayılan rol", "permissions": "İzinler", "allBadge": "Tümü", - "discordRoles": "Discord Rolleri" + "discordRoles": "Discord Rolleri", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "İzin listesi yüklenemedi. Sayfayı yenilemeyi deneyin.", + "registryEmpty": "Henüz atanabilecek izin yok." }, "roleForm": { "name": "Rol Adı", @@ -58,6 +61,7 @@ "color": "Renk" }, "permissionCategories": { + "dashboard": "Kontrol Paneli", "actions": "Eylemler ve Otomasyon", "tempvoice": "Geçici Ses", "welcome": "Hoş Geldin ve Güle Güle", @@ -74,6 +78,33 @@ "commands": "Özel Komutlar", "settings": "Sunucu Ayarları" }, + "resources": { + "roles": "Roller", + "audit": "Denetim Günlüğü", + "settings": "Ayarlar", + "lookups": "Seçiciler", + "cases": "Vakalar", + "warnings": "Uyarılar", + "punishments": "Cezalar", + "rules": "Kurallar", + "analytics": "Analitik", + "entries": "Kayıtlar", + "config": "Yapılandırma", + "test": "Test Mesajları", + "leaderboard": "Lider Tablosu", + "users": "Kullanıcılar", + "rewards": "Ödüller", + "list": "Liste", + "panels": "Paneller", + "messages": "Mesajlar", + "events": "Olaylar" + }, + "permissionActions": { + "view": "Görüntüle", + "manage": "Yönet", + "execute": "Çalıştır", + "purge": "Temizle" + }, "audit": { "table": { "user": "Kullanıcı", diff --git a/packages/i18n/src/locales/uk/permissions.json b/packages/i18n/src/locales/uk/permissions.json index 7f1f63d2..84364e18 100644 --- a/packages/i18n/src/locales/uk/permissions.json +++ b/packages/i18n/src/locales/uk/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Роль за замовчуванням", "permissions": "Права", "allBadge": "Всі", - "discordRoles": "Ролі Discord" + "discordRoles": "Ролі Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Не вдалося завантажити список дозволів. Спробуйте оновити сторінку.", + "registryEmpty": "Поки що немає дозволів, доступних для призначення." }, "roleForm": { "name": "Назва ролі", @@ -58,6 +61,7 @@ "color": "Колір" }, "permissionCategories": { + "dashboard": "Панель керування", "actions": "Дії та автоматизація", "tempvoice": "TempVoice", "welcome": "Привітання та прощання", @@ -74,6 +78,33 @@ "commands": "Користувацькі команди", "settings": "Налаштування сервера" }, + "resources": { + "roles": "Ролі", + "audit": "Журнал аудиту", + "settings": "Налаштування", + "lookups": "Селектори", + "cases": "Випадки", + "warnings": "Попередження", + "punishments": "Покарання", + "rules": "Правила", + "analytics": "Аналітика", + "entries": "Записи", + "config": "Конфігурація", + "test": "Тестові повідомлення", + "leaderboard": "Таблиця лідерів", + "users": "Користувачі", + "rewards": "Нагороди", + "list": "Список", + "panels": "Панелі", + "messages": "Повідомлення", + "events": "Події" + }, + "permissionActions": { + "view": "Перегляд", + "manage": "Керування", + "execute": "Виконання", + "purge": "Очищення" + }, "audit": { "table": { "user": "Користувач", diff --git a/packages/i18n/src/locales/ur/permissions.json b/packages/i18n/src/locales/ur/permissions.json index 5e31c5e5..e13c8815 100644 --- a/packages/i18n/src/locales/ur/permissions.json +++ b/packages/i18n/src/locales/ur/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "ڈیفالٹ رول", "permissions": "اجازتیں", "allBadge": "تمام", - "discordRoles": "Discord رولز" + "discordRoles": "Discord رولز", + "permissionLabel": "{{resource}} {{action}}", + "registryError": "اجازتوں کی فہرست لوڈ نہیں ہو سکی۔ صفحہ ریفریش کرنے کی کوشش کریں۔", + "registryEmpty": "ابھی تفویض کرنے کے لیے کوئی اجازت دستیاب نہیں ہے۔" }, "roleForm": { "name": "رول کا نام", @@ -58,6 +61,7 @@ "color": "رنگ" }, "permissionCategories": { + "dashboard": "ڈیش بورڈ", "actions": "ایکشنز اور آٹومیشن", "tempvoice": "TempVoice", "welcome": "ویلکم اور الوداع", @@ -74,6 +78,33 @@ "commands": "کسٹم کمانڈز", "settings": "سرور ترتیبات" }, + "resources": { + "roles": "کردار", + "audit": "آڈٹ لاگ", + "settings": "ترتیبات", + "lookups": "منتخب کنندگان", + "cases": "کیسز", + "warnings": "انتباہات", + "punishments": "سزائیں", + "rules": "قواعد", + "analytics": "تجزیات", + "entries": "اندراجات", + "config": "تشکیل", + "test": "ٹیسٹ پیغامات", + "leaderboard": "لیڈر بورڈ", + "users": "صارفین", + "rewards": "انعامات", + "list": "فہرست", + "panels": "پینلز", + "messages": "پیغامات", + "events": "ایونٹس" + }, + "permissionActions": { + "view": "دیکھیں", + "manage": "انتظام کریں", + "execute": "عمل درآمد کریں", + "purge": "صاف کریں" + }, "audit": { "table": { "user": "صارف", diff --git a/packages/i18n/src/locales/vi/permissions.json b/packages/i18n/src/locales/vi/permissions.json index a6fd165c..530d8539 100644 --- a/packages/i18n/src/locales/vi/permissions.json +++ b/packages/i18n/src/locales/vi/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "Vai trò mặc định", "permissions": "Quyền Hạn", "allBadge": "Tất Cả", - "discordRoles": "Vai Trò Discord" + "discordRoles": "Vai Trò Discord", + "permissionLabel": "{{action}} {{resource}}", + "registryError": "Không thể tải danh sách quyền. Hãy thử làm mới trang.", + "registryEmpty": "Hiện chưa có quyền nào khả dụng để gán." }, "roleForm": { "name": "Tên Vai Trò", @@ -58,6 +61,7 @@ "color": "Màu Sắc" }, "permissionCategories": { + "dashboard": "Bảng Điều Khiển", "actions": "Hành Động & Tự Động Hóa", "tempvoice": "Kênh Thoại Tạm", "welcome": "Chào Mừng & Tạm Biệt", @@ -74,6 +78,33 @@ "commands": "Lệnh Tùy Chỉnh", "settings": "Cài Đặt Máy Chủ" }, + "resources": { + "roles": "Vai Trò", + "audit": "Nhật Ký Kiểm Toán", + "settings": "Cài Đặt", + "lookups": "Bộ Chọn", + "cases": "Trường Hợp", + "warnings": "Cảnh Báo", + "punishments": "Hình Phạt", + "rules": "Quy Tắc", + "analytics": "Phân Tích", + "entries": "Mục", + "config": "Cấu Hình", + "test": "Tin Nhắn Thử Nghiệm", + "leaderboard": "Bảng Xếp Hạng", + "users": "Người Dùng", + "rewards": "Phần Thưởng", + "list": "Danh Sách", + "panels": "Bảng", + "messages": "Tin Nhắn", + "events": "Sự Kiện" + }, + "permissionActions": { + "view": "Xem", + "manage": "Quản Lý", + "execute": "Thực Thi", + "purge": "Xóa Sạch" + }, "audit": { "table": { "user": "Người Dùng", diff --git a/packages/i18n/src/locales/zh-CN/permissions.json b/packages/i18n/src/locales/zh-CN/permissions.json index 1fa3e192..2d7ba0ff 100644 --- a/packages/i18n/src/locales/zh-CN/permissions.json +++ b/packages/i18n/src/locales/zh-CN/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "默认角色", "permissions": "权限", "allBadge": "全部", - "discordRoles": "Discord 角色" + "discordRoles": "Discord 角色", + "permissionLabel": "{{action}}{{resource}}", + "registryError": "无法加载权限列表。请尝试刷新页面。", + "registryEmpty": "暂无可分配的权限。" }, "roleForm": { "name": "角色名称", @@ -58,6 +61,7 @@ "color": "颜色" }, "permissionCategories": { + "dashboard": "仪表盘", "actions": "动作与自动化", "tempvoice": "临时语音", "welcome": "欢迎与告别", @@ -74,6 +78,33 @@ "commands": "自定义命令", "settings": "服务器设置" }, + "resources": { + "roles": "角色", + "audit": "审计日志", + "settings": "设置", + "lookups": "选择器", + "cases": "案例", + "warnings": "警告", + "punishments": "处罚", + "rules": "规则", + "analytics": "分析", + "entries": "条目", + "config": "配置", + "test": "测试消息", + "leaderboard": "排行榜", + "users": "用户", + "rewards": "奖励", + "list": "列表", + "panels": "面板", + "messages": "消息", + "events": "事件" + }, + "permissionActions": { + "view": "查看", + "manage": "管理", + "execute": "执行", + "purge": "清除" + }, "audit": { "table": { "user": "用户", diff --git a/packages/i18n/src/locales/zh-TW/permissions.json b/packages/i18n/src/locales/zh-TW/permissions.json index 4b7f77bc..27d46e2e 100644 --- a/packages/i18n/src/locales/zh-TW/permissions.json +++ b/packages/i18n/src/locales/zh-TW/permissions.json @@ -41,7 +41,10 @@ "defaultRole": "預設角色", "permissions": "權限", "allBadge": "全部", - "discordRoles": "Discord 角色" + "discordRoles": "Discord 角色", + "permissionLabel": "{{action}}{{resource}}", + "registryError": "無法載入權限清單。請嘗試重新整理頁面。", + "registryEmpty": "目前沒有可指派的權限。" }, "roleForm": { "name": "角色名稱", @@ -58,6 +61,7 @@ "color": "顏色" }, "permissionCategories": { + "dashboard": "儀表板", "actions": "動作與自動化", "tempvoice": "臨時語音", "welcome": "歡迎與告別", @@ -74,6 +78,33 @@ "commands": "自訂指令", "settings": "伺服器設定" }, + "resources": { + "roles": "角色", + "audit": "稽核日誌", + "settings": "設定", + "lookups": "選擇器", + "cases": "案例", + "warnings": "警告", + "punishments": "處罰", + "rules": "規則", + "analytics": "分析", + "entries": "項目", + "config": "組態", + "test": "測試訊息", + "leaderboard": "排行榜", + "users": "使用者", + "rewards": "獎勵", + "list": "清單", + "panels": "面板", + "messages": "訊息", + "events": "事件" + }, + "permissionActions": { + "view": "檢視", + "manage": "管理", + "execute": "執行", + "purge": "清除" + }, "audit": { "table": { "user": "使用者", diff --git a/packages/i18n/tests/permission-keys.test.ts b/packages/i18n/tests/permission-keys.test.ts new file mode 100644 index 00000000..3db4993a --- /dev/null +++ b/packages/i18n/tests/permission-keys.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const LOCALES_DIR = join(__dirname, "../src/locales"); + +const REQUIRED_MODULES = [ + "dashboard", "moderation", "actions", "logging", "welcome", "leveling", + "tickets", "giveaways", "starboard", "suggestions", "roles", "tempvoice", + "security", "scheduled", "commands", +]; + +const REQUIRED_RESOURCES = [ + "roles", "audit", "settings", "lookups", "cases", "warnings", "punishments", + "rules", "analytics", "entries", "config", "test", "leaderboard", "users", + "rewards", "list", "panels", "messages", "events", +]; + +const REQUIRED_ACTIONS = ["view", "manage", "execute", "purge"]; + +interface PermissionsFile { + permissionCategories?: Record; + resources?: Record; + permissionActions?: Record; + roleEditor?: Record; +} + +function readPermissions(lang: string): PermissionsFile { + const raw = readFileSync(join(LOCALES_DIR, lang, "permissions.json"), "utf8"); + return JSON.parse(raw) as PermissionsFile; +} + +describe("permission registry i18n", () => { + const languages = readdirSync(LOCALES_DIR); + + it("covers all 48 locales", () => { + expect(languages).toHaveLength(48); + }); + + it.each(languages)("%s has every module, resource, and action label", (lang) => { + const perms = readPermissions(lang); + + for (const key of REQUIRED_MODULES) { + expect(perms.permissionCategories?.[key], `${lang} permissionCategories.${key}`).toBeTruthy(); + } + for (const key of REQUIRED_RESOURCES) { + expect(perms.resources?.[key], `${lang} resources.${key}`).toBeTruthy(); + } + for (const key of REQUIRED_ACTIONS) { + expect(perms.permissionActions?.[key], `${lang} permissionActions.${key}`).toBeTruthy(); + } + }); + + it.each(languages)("%s has roleEditor.permissionLabel with both placeholders", (lang) => { + const perms = readPermissions(lang); + const label = perms.roleEditor?.permissionLabel; + + expect(label, `${lang} roleEditor.permissionLabel`).toBeTruthy(); + expect(label).toContain("{{action}}"); + expect(label).toContain("{{resource}}"); + }); + + it.each(languages)("%s has roleEditor.registryError and registryEmpty", (lang) => { + const perms = readPermissions(lang); + + expect(perms.roleEditor?.registryError, `${lang} roleEditor.registryError`).toBeTruthy(); + expect(perms.roleEditor?.registryEmpty, `${lang} roleEditor.registryEmpty`).toBeTruthy(); + }); +}); diff --git a/packages/i18n/vitest.config.ts b/packages/i18n/vitest.config.ts new file mode 100644 index 00000000..7d587c0f --- /dev/null +++ b/packages/i18n/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73aabd28..f8abc08b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,6 +346,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@29.1.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) packages/systems: dependencies: @@ -3650,46 +3653,6 @@ packages: yaml: optional: true - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -5370,13 +5333,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -6945,26 +6908,10 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): - dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.58.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.3.0 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.31.1 - tsx: 4.21.0 - yaml: 2.9.0 - vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@29.1.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -6981,7 +6928,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.3.0 From 70234326abb00b85578ac56ebf5457cef9a1d273 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 20:38:50 +0300 Subject: [PATCH 21/36] fix(i18n): move permission-keys test into dashboard suite, drop i18n test infra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bot compose service doesn't bind-mount the root lockfile, so adding vitest to packages/i18n required a Docker-side lockfile regeneration that resolved a narrower dependency graph than the committed one (dropped the vite@7.3.1 peer entries used elsewhere), breaking `pnpm install --frozen-lockfile`. Revert packages/i18n/package.json and pnpm-lock.yaml to their pre-task-11 state and delete packages/i18n/vitest.config.ts. Move the locale-coverage test to apps/dashboard/tests/i18n/permission-keys.test.ts instead — the dashboard is the only consumer of the permissions namespace, already has vitest wired and runs in CI, and the test only reads JSON off disk so it needs no new dependency. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/i18n}/permission-keys.test.ts | 2 +- packages/i18n/package.json | 7 +- packages/i18n/vitest.config.ts | 8 --- pnpm-lock.yaml | 67 +++++++++++++++++-- 4 files changed, 63 insertions(+), 21 deletions(-) rename {packages/i18n/tests => apps/dashboard/tests/i18n}/permission-keys.test.ts (96%) delete mode 100644 packages/i18n/vitest.config.ts diff --git a/packages/i18n/tests/permission-keys.test.ts b/apps/dashboard/tests/i18n/permission-keys.test.ts similarity index 96% rename from packages/i18n/tests/permission-keys.test.ts rename to apps/dashboard/tests/i18n/permission-keys.test.ts index 3db4993a..26131127 100644 --- a/packages/i18n/tests/permission-keys.test.ts +++ b/apps/dashboard/tests/i18n/permission-keys.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -const LOCALES_DIR = join(__dirname, "../src/locales"); +const LOCALES_DIR = join(__dirname, "../../../../packages/i18n/src/locales"); const REQUIRED_MODULES = [ "dashboard", "moderation", "actions", "logging", "welcome", "leveling", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 383591ba..ddd15ae7 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -28,9 +28,7 @@ "build": "tsc && rm -rf dist/locales && cp -r src/locales dist/locales", "typecheck": "tsc --noEmit", "clean": "rm -rf dist", - "translate": "tsx src/scripts/translate.ts", - "test": "vitest run", - "test:watch": "vitest" + "translate": "tsx src/scripts/translate.ts" }, "dependencies": { "i18next": "^25.1.3", @@ -45,7 +43,6 @@ "devDependencies": { "@types/node": "^25.3.0", "tsx": "^4.21.0", - "typescript": "catalog:", - "vitest": "^4.0.18" + "typescript": "catalog:" } } diff --git a/packages/i18n/vitest.config.ts b/packages/i18n/vitest.config.ts deleted file mode 100644 index 7d587c0f..00000000 --- a/packages/i18n/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - include: ["tests/**/*.test.ts"], - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8abc08b..73aabd28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,9 +346,6 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 - vitest: - specifier: ^4.0.18 - version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@29.1.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) packages/systems: dependencies: @@ -3653,6 +3650,46 @@ packages: yaml: optional: true + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -5333,13 +5370,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -6908,10 +6945,26 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 + vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.58.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.3.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + tsx: 4.21.0 + yaml: 2.9.0 + vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@29.1.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -6928,7 +6981,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.3.0 From b38179a04729ad38cba8462853f6c539505a7446 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 20:50:10 +0300 Subject: [PATCH 22/36] fix(i18n): re-translate sr additions into Serbian Latin, not Cyrillic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sr/permissions.json file's pre-existing convention is Serbian Latin (no diacritics: "Naziv uloge", "Moderacija", "Podesavanja servera"). The strings added by task 11 were Serbian Cyrillic, so the role editor would render mixed-script UI (Cyrillic permission labels next to Latin chrome). Re-translate permissionCategories.dashboard, all 19 resources values, all 4 permissionActions values, and roleEditor.permissionLabel/registryError/ registryEmpty into Latin, reusing the file's own established vocabulary where it already exists (e.g. resources.audit = "Revizijski zapis" to match tabs.auditLog; resources.panels = "Paneli" to match permissionCategories.roles "Paneli uloga"; permissionActions.purge = "Brisanje" to match the existing delete-flow strings). Audited all other 47 non-English locales for the same class of mistake (script/register mismatch between newly-added and pre-existing strings) — sr was the only real defect. ja flagged in an automated per-character script scan but is a false positive: Japanese naturally mixes Kanji/ Katakana/Hiragana within a single sentence, and the added ja strings match the file's existing mixed usage exactly (e.g. resources.audit "監査ログ" is byte-identical to the pre-existing tabs.auditLog value). Co-Authored-By: Claude Opus 5 (1M context) --- packages/i18n/src/locales/sr/permissions.json | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/i18n/src/locales/sr/permissions.json b/packages/i18n/src/locales/sr/permissions.json index c8440978..1703c6c7 100644 --- a/packages/i18n/src/locales/sr/permissions.json +++ b/packages/i18n/src/locales/sr/permissions.json @@ -43,8 +43,8 @@ "allBadge": "Sve", "discordRoles": "Discord uloge", "permissionLabel": "{{action}} {{resource}}", - "registryError": "Листа дозвола није могла да се учита. Покушајте да освежите страницу.", - "registryEmpty": "Тренутно нема доступних дозвола за доделу." + "registryError": "Lista dozvola nije mogla da se ucita. Pokusajte da osvezite stranicu.", + "registryEmpty": "Trenutno nema dostupnih dozvola za dodelu." }, "roleForm": { "name": "Naziv uloge", @@ -61,7 +61,7 @@ "color": "Boja" }, "permissionCategories": { - "dashboard": "Контролна табла", + "dashboard": "Kontrolna tabla", "actions": "Radnje i automatizacija", "tempvoice": "TempVoice", "welcome": "Dobrodoslica i oprostaj", @@ -79,31 +79,31 @@ "settings": "Podesavanja servera" }, "resources": { - "roles": "Улоге", - "audit": "Дневник ревизије", - "settings": "Подешавања", - "lookups": "Селектори", - "cases": "Случајеви", - "warnings": "Упозорења", - "punishments": "Казне", - "rules": "Правила", - "analytics": "Аналитика", - "entries": "Уноси", - "config": "Конфигурација", - "test": "Тест поруке", - "leaderboard": "Ранг листа", - "users": "Корисници", - "rewards": "Награде", - "list": "Листа", - "panels": "Панели", - "messages": "Поруке", - "events": "Догађаји" + "roles": "Uloge", + "audit": "Revizijski zapis", + "settings": "Podesavanja", + "lookups": "Selektori", + "cases": "Slucajevi", + "warnings": "Upozorenja", + "punishments": "Kazne", + "rules": "Pravila", + "analytics": "Analitika", + "entries": "Zapisi", + "config": "Konfiguracija", + "test": "Test poruke", + "leaderboard": "Rang lista", + "users": "Korisnici", + "rewards": "Nagrade", + "list": "Lista", + "panels": "Paneli", + "messages": "Poruke", + "events": "Dogadaji" }, "permissionActions": { - "view": "Преглед", - "manage": "Управљање", - "execute": "Извршавање", - "purge": "Брисање" + "view": "Pregled", + "manage": "Upravljanje", + "execute": "Izvrsavanje", + "purge": "Brisanje" }, "audit": { "table": { From d2599e48f29968f831355a0292317c78f66ef5cd Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 21:04:18 +0300 Subject: [PATCH 23/36] fix(overview): render for users without analytics permission Co-Authored-By: Claude Opus 5 (1M context) --- .../overview/components/AccessSummary.tsx | 57 ++++++++++ .../features/overview/hooks/useAnalytics.ts | 3 +- .../client/routes/guild/$guildId/overview.tsx | 33 +++++- .../client/routes/guild/OverviewPage.test.tsx | 103 ++++++++++++++++++ packages/i18n/src/locales/af/overview.json | 8 +- packages/i18n/src/locales/ar/overview.json | 8 +- packages/i18n/src/locales/bg/overview.json | 8 +- packages/i18n/src/locales/bn/overview.json | 8 +- packages/i18n/src/locales/ca/overview.json | 8 +- packages/i18n/src/locales/cs/overview.json | 8 +- packages/i18n/src/locales/da/overview.json | 8 +- packages/i18n/src/locales/de/overview.json | 8 +- packages/i18n/src/locales/el/overview.json | 8 +- packages/i18n/src/locales/en/overview.json | 8 +- packages/i18n/src/locales/es/overview.json | 8 +- packages/i18n/src/locales/et/overview.json | 8 +- packages/i18n/src/locales/eu/overview.json | 8 +- packages/i18n/src/locales/fa/overview.json | 8 +- packages/i18n/src/locales/fi/overview.json | 8 +- packages/i18n/src/locales/fil/overview.json | 8 +- packages/i18n/src/locales/fr/overview.json | 8 +- packages/i18n/src/locales/gl/overview.json | 8 +- packages/i18n/src/locales/he/overview.json | 8 +- packages/i18n/src/locales/hi/overview.json | 8 +- packages/i18n/src/locales/hr/overview.json | 8 +- packages/i18n/src/locales/hu/overview.json | 8 +- packages/i18n/src/locales/id/overview.json | 8 +- packages/i18n/src/locales/it/overview.json | 8 +- packages/i18n/src/locales/ja/overview.json | 8 +- packages/i18n/src/locales/ko/overview.json | 8 +- packages/i18n/src/locales/lt/overview.json | 8 +- packages/i18n/src/locales/lv/overview.json | 8 +- packages/i18n/src/locales/ms/overview.json | 8 +- packages/i18n/src/locales/nl/overview.json | 8 +- packages/i18n/src/locales/no/overview.json | 8 +- packages/i18n/src/locales/pl/overview.json | 8 +- packages/i18n/src/locales/pt/overview.json | 8 +- packages/i18n/src/locales/ro/overview.json | 8 +- packages/i18n/src/locales/ru/overview.json | 8 +- packages/i18n/src/locales/sk/overview.json | 8 +- packages/i18n/src/locales/sl/overview.json | 8 +- packages/i18n/src/locales/sr/overview.json | 8 +- packages/i18n/src/locales/sv/overview.json | 8 +- packages/i18n/src/locales/sw/overview.json | 8 +- packages/i18n/src/locales/ta/overview.json | 8 +- packages/i18n/src/locales/th/overview.json | 8 +- packages/i18n/src/locales/tr/overview.json | 8 +- packages/i18n/src/locales/uk/overview.json | 8 +- packages/i18n/src/locales/ur/overview.json | 8 +- packages/i18n/src/locales/vi/overview.json | 8 +- packages/i18n/src/locales/zh-CN/overview.json | 8 +- packages/i18n/src/locales/zh-TW/overview.json | 8 +- 52 files changed, 528 insertions(+), 52 deletions(-) create mode 100644 apps/dashboard/src/client/features/overview/components/AccessSummary.tsx create mode 100644 apps/dashboard/tests/client/routes/guild/OverviewPage.test.tsx diff --git a/apps/dashboard/src/client/features/overview/components/AccessSummary.tsx b/apps/dashboard/src/client/features/overview/components/AccessSummary.tsx new file mode 100644 index 00000000..4b818dd6 --- /dev/null +++ b/apps/dashboard/src/client/features/overview/components/AccessSummary.tsx @@ -0,0 +1,57 @@ +import { Link } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { Card } from "../../../shared/ui/card"; +import { Badge } from "../../../shared/ui/badge"; +import { Icon } from "../../../shared/components/Icon"; +import { navItems } from "../../../shared/lib/navigation"; +import { usePermissions } from "../../permissions/hooks/usePermissions"; + +/** + * Landing content for someone whose access is delegated: overview analytics are + * permission-gated, so without them the page would otherwise be empty. Shows + * what they can actually open, and which dashboard roles got them here. + */ +export function AccessSummary({ guildId }: { guildId: string }) { + const { t } = useTranslation(["overview", "common"]); + const { can, roles } = usePermissions(guildId); + + const available = navItems.filter( + (item) => item.permission && can(item.permission), + ); + + return ( + +

{t("overview:access.title")}

+

{t("overview:access.subtitle")}

+ + {roles.length > 0 && ( +
+ {roles.map((role) => ( + + {role.name} + + ))} +
+ )} + + {available.length === 0 ? ( +

{t("overview:access.empty")}

+ ) : ( +
    + {available.map((item) => ( +
  • + + + {t(item.i18nKey)} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/client/features/overview/hooks/useAnalytics.ts b/apps/dashboard/src/client/features/overview/hooks/useAnalytics.ts index b2020384..221d4421 100644 --- a/apps/dashboard/src/client/features/overview/hooks/useAnalytics.ts +++ b/apps/dashboard/src/client/features/overview/hooks/useAnalytics.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiFetch } from "../../../shared/lib/client"; import { AnalyticsResponseSchema, type AnalyticsResponse } from "../../../shared/lib/schemas"; -export function useAnalytics(guildId: string, days: number = 7) { +export function useAnalytics(guildId: string, days: number = 7, enabled = true) { return useQuery({ queryKey: ["guilds", guildId, "actions", "analytics", { days }], queryFn: async () => { @@ -11,5 +11,6 @@ export function useAnalytics(guildId: string, days: number = 7) { ); return AnalyticsResponseSchema.parse(data); }, + enabled: enabled && Boolean(guildId), }); } diff --git a/apps/dashboard/src/client/routes/guild/$guildId/overview.tsx b/apps/dashboard/src/client/routes/guild/$guildId/overview.tsx index db82a33c..f7c07159 100644 --- a/apps/dashboard/src/client/routes/guild/$guildId/overview.tsx +++ b/apps/dashboard/src/client/routes/guild/$guildId/overview.tsx @@ -3,12 +3,15 @@ import { useParams } from "@tanstack/react-router"; import { useTranslation } from "react-i18next"; import { useAnalytics } from "../../../features/overview/hooks/useAnalytics"; import { useConstants } from "../../../shared/hooks/useConstants"; +import { usePermissions } from "../../../features/permissions/hooks/usePermissions"; import { PageHeader } from "../../../shared/components/PageHeader"; import { CardGridSkeleton } from "../../../shared/ui/skeletons"; +import { Card } from "../../../shared/ui/card"; import { StatsCard } from "../../../shared/components/StatsCard"; import { ExecutionChart } from "../../../features/overview/components/ExecutionChart"; import { EventDistributionChart } from "../../../features/overview/components/EventDistributionChart"; import { RecentActivityFeed } from "../../../features/overview/components/RecentActivityFeed"; +import { AccessSummary } from "../../../features/overview/components/AccessSummary"; import { Button } from "../../../shared/ui/button"; import { Zap, CheckCircle, BarChart3, Target, RefreshCw } from "lucide-react"; @@ -16,10 +19,34 @@ export function OverviewPage() { const { t } = useTranslation(["overview", "common"]); const { guildId } = useParams({ from: "/guild/$guildId" }); const [days, setDays] = useState(7); - const { data: analytics, isLoading, isFetching } = useAnalytics(guildId, days); + const { can, isLoading: permissionsLoading } = usePermissions(guildId); + const canViewAnalytics = can("actions.analytics.view"); + const { data: analytics, isLoading, isError, isFetching } = useAnalytics(guildId, days, canViewAnalytics); const { data: constants } = useConstants(); - if (isLoading || !analytics) return ; + if (permissionsLoading) return ; + + if (!canViewAnalytics) { + return ( +
+ + +
+ ); + } + + if (isLoading) return ; + + if (isError || !analytics) { + return ( +
+ + + {t("errors.analyticsUnavailable")} + +
+ ); + } const { summary } = analytics; @@ -30,7 +57,7 @@ export function OverviewPage() { subtitle={t("subtitle")} /> -
+
({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ guildId: "g1" }), + Link: ({ children }: { children: ReactNode }) => {children}, +})); + +const mockCan = vi.fn(); +vi.mock("../../../../src/client/features/permissions/hooks/usePermissions", () => ({ + usePermissions: () => ({ can: mockCan, roles: [], isLoading: false }), +})); + +const mockUseAnalytics = vi.fn(); +vi.mock("../../../../src/client/features/overview/hooks/useAnalytics", () => ({ + useAnalytics: (...args: unknown[]) => mockUseAnalytics(...args), +})); + +vi.mock("../../../../src/client/shared/hooks/useConstants", () => ({ + useConstants: () => ({ data: undefined }), +})); + +import { OverviewPage } from "../../../../src/client/routes/guild/$guildId/overview"; + +describe("OverviewPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAnalytics.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + isFetching: false, + }); + }); + + it("shows the access summary instead of analytics without actions.analytics.view", () => { + mockCan.mockImplementation((key: string) => key === "tickets.list.view"); + + render(); + + expect(screen.getByTestId("access-summary")).toBeInTheDocument(); + expect(screen.queryByTestId("analytics-stats")).not.toBeInTheDocument(); + }); + + it("does not fetch analytics the user cannot see", () => { + mockCan.mockReturnValue(false); + + render(); + + expect(mockUseAnalytics).toHaveBeenCalledWith("g1", 7, false); + }); + + it("lists only the pages the user can open", () => { + mockCan.mockImplementation((key: string) => key === "tickets.list.view"); + + render(); + + const summary = screen.getByTestId("access-summary"); + expect(within(summary).getByText("nav.tickets")).toBeInTheDocument(); + expect(within(summary).queryByText("nav.moderation")).not.toBeInTheDocument(); + }); + + it("renders an error state rather than an endless skeleton when analytics fails", () => { + mockCan.mockReturnValue(true); + mockUseAnalytics.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + isFetching: false, + }); + + render(); + + expect(screen.getByTestId("analytics-error")).toBeInTheDocument(); + }); + + it("renders the analytics dashboard, not the access summary or an error, once permitted data has loaded", () => { + mockCan.mockReturnValue(true); + mockUseAnalytics.mockReturnValue({ + data: { + summary: { totalRules: 3, activeRules: 2, totalExecutions: 10, successRate: 90, recentErrors: 0 }, + executionTrend: [], + eventDistribution: [], + recentActivity: [], + }, + isLoading: false, + isError: false, + isFetching: false, + }); + + render(); + + expect(screen.getByTestId("analytics-stats")).toBeInTheDocument(); + expect(screen.queryByTestId("access-summary")).not.toBeInTheDocument(); + expect(screen.queryByTestId("analytics-error")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/i18n/src/locales/af/overview.json b/packages/i18n/src/locales/af/overview.json index 34620c3d..be27ac51 100644 --- a/packages/i18n/src/locales/af/overview.json +++ b/packages/i18n/src/locales/af/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Jou toegang", + "subtitle": "Jy is toegang gegee tot dele van hierdie bediener se paneelbord.", + "empty": "Jy het nog nie toegang tot enige modules nie." + }, + "errors": { "analyticsUnavailable": "Analise kon nie gelaai word nie." } } diff --git a/packages/i18n/src/locales/ar/overview.json b/packages/i18n/src/locales/ar/overview.json index 7238b756..cd9df47d 100644 --- a/packages/i18n/src/locales/ar/overview.json +++ b/packages/i18n/src/locales/ar/overview.json @@ -29,5 +29,11 @@ "title": "اتجاه التنفيذ", "success": "ناجح", "errors": "أخطاء" - } + }, + "access": { + "title": "صلاحيات الوصول الخاصة بك", + "subtitle": "تم منحك حق الوصول إلى أجزاء من لوحة تحكم هذا الخادم.", + "empty": "ليس لديك حق الوصول إلى أي وحدات بعد." + }, + "errors": { "analyticsUnavailable": "تعذر تحميل التحليلات." } } diff --git a/packages/i18n/src/locales/bg/overview.json b/packages/i18n/src/locales/bg/overview.json index 2af7e43d..56494d11 100644 --- a/packages/i18n/src/locales/bg/overview.json +++ b/packages/i18n/src/locales/bg/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Вашият достъп", + "subtitle": "Предоставен ви е достъп до части от таблото на този сървър.", + "empty": "Все още нямате достъп до никакви модули." + }, + "errors": { "analyticsUnavailable": "Анализите не можаха да бъдат заредени." } } diff --git a/packages/i18n/src/locales/bn/overview.json b/packages/i18n/src/locales/bn/overview.json index 055acbf2..c55f5fdf 100644 --- a/packages/i18n/src/locales/bn/overview.json +++ b/packages/i18n/src/locales/bn/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "আপনার অ্যাক্সেস", + "subtitle": "এই সার্ভারের ড্যাশবোর্ডের কিছু অংশে আপনাকে অ্যাক্সেস দেওয়া হয়েছে।", + "empty": "আপনার এখনও কোনো মডিউলে অ্যাক্সেস নেই।" + }, + "errors": { "analyticsUnavailable": "অ্যানালিটিক্স লোড করা যায়নি।" } } diff --git a/packages/i18n/src/locales/ca/overview.json b/packages/i18n/src/locales/ca/overview.json index 25e74ff8..e169c57d 100644 --- a/packages/i18n/src/locales/ca/overview.json +++ b/packages/i18n/src/locales/ca/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "El teu accés", + "subtitle": "Se t'ha concedit accés a parts del tauler d'aquest servidor.", + "empty": "Encara no tens accés a cap mòdul." + }, + "errors": { "analyticsUnavailable": "No s'han pogut carregar les analítiques." } } diff --git a/packages/i18n/src/locales/cs/overview.json b/packages/i18n/src/locales/cs/overview.json index 7f67a4e6..8110447f 100644 --- a/packages/i18n/src/locales/cs/overview.json +++ b/packages/i18n/src/locales/cs/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Váš přístup", + "subtitle": "Byl vám udělen přístup k částem řídicího panelu tohoto serveru.", + "empty": "Zatím nemáte přístup k žádným modulům." + }, + "errors": { "analyticsUnavailable": "Analytiku se nepodařilo načíst." } } diff --git a/packages/i18n/src/locales/da/overview.json b/packages/i18n/src/locales/da/overview.json index d85403c3..453fb55f 100644 --- a/packages/i18n/src/locales/da/overview.json +++ b/packages/i18n/src/locales/da/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Din adgang", + "subtitle": "Du har fået adgang til dele af denne servers dashboard.", + "empty": "Du har endnu ikke adgang til nogen moduler." + }, + "errors": { "analyticsUnavailable": "Analyser kunne ikke indlæses." } } diff --git a/packages/i18n/src/locales/de/overview.json b/packages/i18n/src/locales/de/overview.json index be61e8f6..6178ba92 100644 --- a/packages/i18n/src/locales/de/overview.json +++ b/packages/i18n/src/locales/de/overview.json @@ -29,5 +29,11 @@ "title": "Ausführungstrend", "success": "Erfolg", "errors": "Fehler" - } + }, + "access": { + "title": "Dein Zugriff", + "subtitle": "Dir wurde Zugriff auf Teile des Dashboards dieses Servers gewährt.", + "empty": "Du hast noch keinen Zugriff auf Module." + }, + "errors": { "analyticsUnavailable": "Analysen konnten nicht geladen werden." } } diff --git a/packages/i18n/src/locales/el/overview.json b/packages/i18n/src/locales/el/overview.json index 0f17a422..b08ea6e9 100644 --- a/packages/i18n/src/locales/el/overview.json +++ b/packages/i18n/src/locales/el/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Η πρόσβασή σας", + "subtitle": "Σας έχει δοθεί πρόσβαση σε τμήματα του πίνακα ελέγχου αυτού του διακομιστή.", + "empty": "Δεν έχετε ακόμη πρόσβαση σε καμία ενότητα." + }, + "errors": { "analyticsUnavailable": "Δεν ήταν δυνατή η φόρτωση των αναλύσεων." } } diff --git a/packages/i18n/src/locales/en/overview.json b/packages/i18n/src/locales/en/overview.json index c9364ace..5436802f 100644 --- a/packages/i18n/src/locales/en/overview.json +++ b/packages/i18n/src/locales/en/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Your access", + "subtitle": "You have been given access to parts of this server's dashboard.", + "empty": "You don't have access to any modules yet." + }, + "errors": { "analyticsUnavailable": "Analytics could not be loaded." } } diff --git a/packages/i18n/src/locales/es/overview.json b/packages/i18n/src/locales/es/overview.json index c0a57f02..249056ad 100644 --- a/packages/i18n/src/locales/es/overview.json +++ b/packages/i18n/src/locales/es/overview.json @@ -29,5 +29,11 @@ "title": "Tendencia de ejecución", "success": "Éxito", "errors": "Errores" - } + }, + "access": { + "title": "Tu acceso", + "subtitle": "Se te ha concedido acceso a partes del panel de este servidor.", + "empty": "Aún no tienes acceso a ningún módulo." + }, + "errors": { "analyticsUnavailable": "No se pudieron cargar las analíticas." } } diff --git a/packages/i18n/src/locales/et/overview.json b/packages/i18n/src/locales/et/overview.json index ef8967d4..9a7c5252 100644 --- a/packages/i18n/src/locales/et/overview.json +++ b/packages/i18n/src/locales/et/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Sinu juurdepääs", + "subtitle": "Sulle on antud juurdepääs selle serveri juhtpaneeli osadele.", + "empty": "Sul ei ole veel juurdepääsu ühelegi moodulile." + }, + "errors": { "analyticsUnavailable": "Analüütikat ei õnnestunud laadida." } } diff --git a/packages/i18n/src/locales/eu/overview.json b/packages/i18n/src/locales/eu/overview.json index ed37237b..d2a486d7 100644 --- a/packages/i18n/src/locales/eu/overview.json +++ b/packages/i18n/src/locales/eu/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Zure sarbidea", + "subtitle": "Zerbitzari honen panelaren zati batzuetarako sarbidea eman zaizu.", + "empty": "Oraindik ez duzu modulurik erabiltzeko sarbiderik." + }, + "errors": { "analyticsUnavailable": "Ezin izan dira analitikak kargatu." } } diff --git a/packages/i18n/src/locales/fa/overview.json b/packages/i18n/src/locales/fa/overview.json index 4708ff7a..6543fb84 100644 --- a/packages/i18n/src/locales/fa/overview.json +++ b/packages/i18n/src/locales/fa/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "دسترسی شما", + "subtitle": "به شما دسترسی به بخش‌هایی از داشبورد این سرور داده شده است.", + "empty": "شما هنوز به هیچ ماژولی دسترسی ندارید." + }, + "errors": { "analyticsUnavailable": "تحلیل‌ها بارگذاری نشدند." } } diff --git a/packages/i18n/src/locales/fi/overview.json b/packages/i18n/src/locales/fi/overview.json index 184a5616..bda8b1fb 100644 --- a/packages/i18n/src/locales/fi/overview.json +++ b/packages/i18n/src/locales/fi/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Käyttöoikeutesi", + "subtitle": "Sinulle on myönnetty pääsy osaan tämän palvelimen hallintapaneelia.", + "empty": "Sinulla ei ole vielä pääsyä mihinkään moduuliin." + }, + "errors": { "analyticsUnavailable": "Analytiikkaa ei voitu ladata." } } diff --git a/packages/i18n/src/locales/fil/overview.json b/packages/i18n/src/locales/fil/overview.json index f3496a9b..0ed858d3 100644 --- a/packages/i18n/src/locales/fil/overview.json +++ b/packages/i18n/src/locales/fil/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Ang iyong access", + "subtitle": "Binigyan ka ng access sa ilang bahagi ng dashboard ng server na ito.", + "empty": "Wala ka pang access sa anumang module." + }, + "errors": { "analyticsUnavailable": "Hindi ma-load ang analytics." } } diff --git a/packages/i18n/src/locales/fr/overview.json b/packages/i18n/src/locales/fr/overview.json index b96481c3..e9475c53 100644 --- a/packages/i18n/src/locales/fr/overview.json +++ b/packages/i18n/src/locales/fr/overview.json @@ -29,5 +29,11 @@ "title": "Tendance des exécutions", "success": "Réussite", "errors": "Erreurs" - } + }, + "access": { + "title": "Votre accès", + "subtitle": "Vous avez reçu l'accès à certaines parties du tableau de bord de ce serveur.", + "empty": "Vous n'avez encore accès à aucun module." + }, + "errors": { "analyticsUnavailable": "Impossible de charger les analyses." } } diff --git a/packages/i18n/src/locales/gl/overview.json b/packages/i18n/src/locales/gl/overview.json index bf3770ae..762868f2 100644 --- a/packages/i18n/src/locales/gl/overview.json +++ b/packages/i18n/src/locales/gl/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "O teu acceso", + "subtitle": "Concedéuseche acceso a partes do panel deste servidor.", + "empty": "Aínda non tes acceso a ningún módulo." + }, + "errors": { "analyticsUnavailable": "Non se puideron cargar as analíticas." } } diff --git a/packages/i18n/src/locales/he/overview.json b/packages/i18n/src/locales/he/overview.json index cce6a04f..6267f5d7 100644 --- a/packages/i18n/src/locales/he/overview.json +++ b/packages/i18n/src/locales/he/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "הגישה שלך", + "subtitle": "ניתנה לך גישה לחלקים מלוח הבקרה של שרת זה.", + "empty": "עדיין אין לך גישה לאף מודול." + }, + "errors": { "analyticsUnavailable": "לא ניתן היה לטעון את הנתונים האנליטיים." } } diff --git a/packages/i18n/src/locales/hi/overview.json b/packages/i18n/src/locales/hi/overview.json index 5edb18ca..11857c71 100644 --- a/packages/i18n/src/locales/hi/overview.json +++ b/packages/i18n/src/locales/hi/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "आपकी पहुँच", + "subtitle": "आपको इस सर्वर के डैशबोर्ड के कुछ हिस्सों तक पहुँच दी गई है।", + "empty": "आपके पास अभी तक किसी भी मॉड्यूल तक पहुँच नहीं है।" + }, + "errors": { "analyticsUnavailable": "एनालिटिक्स लोड नहीं हो सका।" } } diff --git a/packages/i18n/src/locales/hr/overview.json b/packages/i18n/src/locales/hr/overview.json index 9e7f9d53..ea4c9d19 100644 --- a/packages/i18n/src/locales/hr/overview.json +++ b/packages/i18n/src/locales/hr/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Vaš pristup", + "subtitle": "Dobili ste pristup dijelovima nadzorne ploče ovog poslužitelja.", + "empty": "Još nemate pristup nijednom modulu." + }, + "errors": { "analyticsUnavailable": "Analitiku nije bilo moguće učitati." } } diff --git a/packages/i18n/src/locales/hu/overview.json b/packages/i18n/src/locales/hu/overview.json index c9364ace..ba8c12fa 100644 --- a/packages/i18n/src/locales/hu/overview.json +++ b/packages/i18n/src/locales/hu/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Az Ön hozzáférése", + "subtitle": "Hozzáférést kapott ennek a szervernek az irányítópultja egyes részeihez.", + "empty": "Még egyetlen modulhoz sem fér hozzá." + }, + "errors": { "analyticsUnavailable": "Az elemzéseket nem sikerült betölteni." } } diff --git a/packages/i18n/src/locales/id/overview.json b/packages/i18n/src/locales/id/overview.json index 34bef7b3..357fab41 100644 --- a/packages/i18n/src/locales/id/overview.json +++ b/packages/i18n/src/locales/id/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Akses Anda", + "subtitle": "Anda telah diberi akses ke bagian dasbor server ini.", + "empty": "Anda belum memiliki akses ke modul apa pun." + }, + "errors": { "analyticsUnavailable": "Analitik tidak dapat dimuat." } } diff --git a/packages/i18n/src/locales/it/overview.json b/packages/i18n/src/locales/it/overview.json index 4d3d7f96..016791cf 100644 --- a/packages/i18n/src/locales/it/overview.json +++ b/packages/i18n/src/locales/it/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Il tuo accesso", + "subtitle": "Ti è stato concesso l'accesso ad alcune parti della dashboard di questo server.", + "empty": "Non hai ancora accesso a nessun modulo." + }, + "errors": { "analyticsUnavailable": "Impossibile caricare le analisi." } } diff --git a/packages/i18n/src/locales/ja/overview.json b/packages/i18n/src/locales/ja/overview.json index 6cd78223..f6fa3287 100644 --- a/packages/i18n/src/locales/ja/overview.json +++ b/packages/i18n/src/locales/ja/overview.json @@ -29,5 +29,11 @@ "title": "実行トレンド", "success": "成功", "errors": "エラー" - } + }, + "access": { + "title": "あなたのアクセス権", + "subtitle": "このサーバーのダッシュボードの一部へのアクセスが許可されています。", + "empty": "まだどのモジュールへのアクセス権もありません。" + }, + "errors": { "analyticsUnavailable": "分析データを読み込めませんでした。" } } diff --git a/packages/i18n/src/locales/ko/overview.json b/packages/i18n/src/locales/ko/overview.json index a4b8ec3c..c1389f83 100644 --- a/packages/i18n/src/locales/ko/overview.json +++ b/packages/i18n/src/locales/ko/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "내 액세스 권한", + "subtitle": "이 서버 대시보드의 일부에 대한 액세스 권한이 부여되었습니다.", + "empty": "아직 접근 가능한 모듈이 없습니다." + }, + "errors": { "analyticsUnavailable": "분석 데이터를 불러올 수 없습니다." } } diff --git a/packages/i18n/src/locales/lt/overview.json b/packages/i18n/src/locales/lt/overview.json index 14a95f1c..7588bee8 100644 --- a/packages/i18n/src/locales/lt/overview.json +++ b/packages/i18n/src/locales/lt/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Jūsų prieiga", + "subtitle": "Jums suteikta prieiga prie šio serverio skydelio dalių.", + "empty": "Kol kas neturite prieigos prie jokių modulių." + }, + "errors": { "analyticsUnavailable": "Nepavyko įkelti analitikos." } } diff --git a/packages/i18n/src/locales/lv/overview.json b/packages/i18n/src/locales/lv/overview.json index f0b0c611..7414ccd4 100644 --- a/packages/i18n/src/locales/lv/overview.json +++ b/packages/i18n/src/locales/lv/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Jūsu piekļuve", + "subtitle": "Jums ir piešķirta piekļuve šī servera paneļa daļām.", + "empty": "Jums vēl nav piekļuves nevienam modulim." + }, + "errors": { "analyticsUnavailable": "Neizdevās ielādēt analītiku." } } diff --git a/packages/i18n/src/locales/ms/overview.json b/packages/i18n/src/locales/ms/overview.json index 10fa3a76..2dd02982 100644 --- a/packages/i18n/src/locales/ms/overview.json +++ b/packages/i18n/src/locales/ms/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Akses anda", + "subtitle": "Anda telah diberikan akses kepada sebahagian papan pemuka pelayan ini.", + "empty": "Anda belum mempunyai akses kepada mana-mana modul." + }, + "errors": { "analyticsUnavailable": "Analitik tidak dapat dimuatkan." } } diff --git a/packages/i18n/src/locales/nl/overview.json b/packages/i18n/src/locales/nl/overview.json index a826c759..156758d6 100644 --- a/packages/i18n/src/locales/nl/overview.json +++ b/packages/i18n/src/locales/nl/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Jouw toegang", + "subtitle": "Je hebt toegang gekregen tot delen van het dashboard van deze server.", + "empty": "Je hebt nog geen toegang tot modules." + }, + "errors": { "analyticsUnavailable": "Analyses konden niet worden geladen." } } diff --git a/packages/i18n/src/locales/no/overview.json b/packages/i18n/src/locales/no/overview.json index cb260449..e270bcb1 100644 --- a/packages/i18n/src/locales/no/overview.json +++ b/packages/i18n/src/locales/no/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Din tilgang", + "subtitle": "Du har fått tilgang til deler av dashbordet for denne serveren.", + "empty": "Du har ikke tilgang til noen moduler ennå." + }, + "errors": { "analyticsUnavailable": "Kunne ikke laste inn analyser." } } diff --git a/packages/i18n/src/locales/pl/overview.json b/packages/i18n/src/locales/pl/overview.json index c8c1c70d..57d9e1ba 100644 --- a/packages/i18n/src/locales/pl/overview.json +++ b/packages/i18n/src/locales/pl/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Twój dostęp", + "subtitle": "Przyznano Ci dostęp do części panelu tego serwera.", + "empty": "Nie masz jeszcze dostępu do żadnych modułów." + }, + "errors": { "analyticsUnavailable": "Nie udało się załadować analityki." } } diff --git a/packages/i18n/src/locales/pt/overview.json b/packages/i18n/src/locales/pt/overview.json index c6fc331d..5a88b1f3 100644 --- a/packages/i18n/src/locales/pt/overview.json +++ b/packages/i18n/src/locales/pt/overview.json @@ -29,5 +29,11 @@ "title": "Tendência de Execução", "success": "Sucesso", "errors": "Erros" - } + }, + "access": { + "title": "O seu acesso", + "subtitle": "Foi-lhe concedido acesso a partes do painel deste servidor.", + "empty": "Ainda não tem acesso a nenhum módulo." + }, + "errors": { "analyticsUnavailable": "Não foi possível carregar as análises." } } diff --git a/packages/i18n/src/locales/ro/overview.json b/packages/i18n/src/locales/ro/overview.json index 6f3d82f0..3e719ce0 100644 --- a/packages/i18n/src/locales/ro/overview.json +++ b/packages/i18n/src/locales/ro/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Accesul tău", + "subtitle": "Ți s-a acordat acces la părți din panoul acestui server.", + "empty": "Încă nu ai acces la niciun modul." + }, + "errors": { "analyticsUnavailable": "Analizele nu au putut fi încărcate." } } diff --git a/packages/i18n/src/locales/ru/overview.json b/packages/i18n/src/locales/ru/overview.json index 2ac2a8fe..c3d5dd58 100644 --- a/packages/i18n/src/locales/ru/overview.json +++ b/packages/i18n/src/locales/ru/overview.json @@ -29,5 +29,11 @@ "title": "Динамика выполнений", "success": "Успешно", "errors": "Ошибки" - } + }, + "access": { + "title": "Ваш доступ", + "subtitle": "Вам предоставлен доступ к некоторым разделам панели управления этого сервера.", + "empty": "У вас пока нет доступа ни к одному модулю." + }, + "errors": { "analyticsUnavailable": "Не удалось загрузить аналитику." } } diff --git a/packages/i18n/src/locales/sk/overview.json b/packages/i18n/src/locales/sk/overview.json index c9364ace..61755ccb 100644 --- a/packages/i18n/src/locales/sk/overview.json +++ b/packages/i18n/src/locales/sk/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Váš prístup", + "subtitle": "Bol vám udelený prístup k častiam ovládacieho panela tohto servera.", + "empty": "Zatiaľ nemáte prístup k žiadnym modulom." + }, + "errors": { "analyticsUnavailable": "Analytiku sa nepodarilo načítať." } } diff --git a/packages/i18n/src/locales/sl/overview.json b/packages/i18n/src/locales/sl/overview.json index d3a2ac49..7e01b196 100644 --- a/packages/i18n/src/locales/sl/overview.json +++ b/packages/i18n/src/locales/sl/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Vaš dostop", + "subtitle": "Dodeljen vam je dostop do delov nadzorne plošče tega strežnika.", + "empty": "Do nobenega modula še nimate dostopa." + }, + "errors": { "analyticsUnavailable": "Analitike ni bilo mogoče naložiti." } } diff --git a/packages/i18n/src/locales/sr/overview.json b/packages/i18n/src/locales/sr/overview.json index 94270c6e..f68732ba 100644 --- a/packages/i18n/src/locales/sr/overview.json +++ b/packages/i18n/src/locales/sr/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Vaš pristup", + "subtitle": "Dobili ste pristup delovima kontrolne table ovog servera.", + "empty": "Još uvek nemate pristup nijednom modulu." + }, + "errors": { "analyticsUnavailable": "Analitiku nije bilo moguće učitati." } } diff --git a/packages/i18n/src/locales/sv/overview.json b/packages/i18n/src/locales/sv/overview.json index 7a84eea1..767a3f7a 100644 --- a/packages/i18n/src/locales/sv/overview.json +++ b/packages/i18n/src/locales/sv/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Din åtkomst", + "subtitle": "Du har fått åtkomst till delar av den här serverns instrumentpanel.", + "empty": "Du har ännu inte åtkomst till några moduler." + }, + "errors": { "analyticsUnavailable": "Analyser kunde inte läsas in." } } diff --git a/packages/i18n/src/locales/sw/overview.json b/packages/i18n/src/locales/sw/overview.json index 4d2f8499..4424ed1c 100644 --- a/packages/i18n/src/locales/sw/overview.json +++ b/packages/i18n/src/locales/sw/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Ufikiaji wako", + "subtitle": "Umepewa ufikiaji wa sehemu za dashibodi ya seva hii.", + "empty": "Bado huna ufikiaji wa moduli yoyote." + }, + "errors": { "analyticsUnavailable": "Takwimu hazikuweza kupakiwa." } } diff --git a/packages/i18n/src/locales/ta/overview.json b/packages/i18n/src/locales/ta/overview.json index 289c96ff..46a19d89 100644 --- a/packages/i18n/src/locales/ta/overview.json +++ b/packages/i18n/src/locales/ta/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "உங்கள் அணுகல்", + "subtitle": "இந்த சர்வரின் டாஷ்போர்டின் சில பகுதிகளுக்கு உங்களுக்கு அணுகல் வழங்கப்பட்டுள்ளது.", + "empty": "உங்களுக்கு இதுவரை எந்த தொகுதிக்கும் அணுகல் இல்லை." + }, + "errors": { "analyticsUnavailable": "பகுப்பாய்வுகளை ஏற்ற முடியவில்லை." } } diff --git a/packages/i18n/src/locales/th/overview.json b/packages/i18n/src/locales/th/overview.json index 1a652124..5afb3d75 100644 --- a/packages/i18n/src/locales/th/overview.json +++ b/packages/i18n/src/locales/th/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "สิทธิ์การเข้าถึงของคุณ", + "subtitle": "คุณได้รับสิทธิ์เข้าถึงบางส่วนของแดชบอร์ดเซิร์ฟเวอร์นี้", + "empty": "คุณยังไม่มีสิทธิ์เข้าถึงโมดูลใด ๆ" + }, + "errors": { "analyticsUnavailable": "ไม่สามารถโหลดข้อมูลวิเคราะห์ได้" } } diff --git a/packages/i18n/src/locales/tr/overview.json b/packages/i18n/src/locales/tr/overview.json index c26799ca..0c42ed3a 100644 --- a/packages/i18n/src/locales/tr/overview.json +++ b/packages/i18n/src/locales/tr/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Erişiminiz", + "subtitle": "Bu sunucunun panosunun bazı bölümlerine erişim izni verildi.", + "empty": "Henüz hiçbir modüle erişiminiz yok." + }, + "errors": { "analyticsUnavailable": "Analizler yüklenemedi." } } diff --git a/packages/i18n/src/locales/uk/overview.json b/packages/i18n/src/locales/uk/overview.json index d3b33500..ee9c0763 100644 --- a/packages/i18n/src/locales/uk/overview.json +++ b/packages/i18n/src/locales/uk/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Ваш доступ", + "subtitle": "Вам надано доступ до частин панелі керування цього сервера.", + "empty": "У вас поки немає доступу до жодного модуля." + }, + "errors": { "analyticsUnavailable": "Не вдалося завантажити аналітику." } } diff --git a/packages/i18n/src/locales/ur/overview.json b/packages/i18n/src/locales/ur/overview.json index c5111374..e54063a4 100644 --- a/packages/i18n/src/locales/ur/overview.json +++ b/packages/i18n/src/locales/ur/overview.json @@ -29,5 +29,11 @@ "title": "ایگزیکیوشن رجحان", "success": "کامیابی", "errors": "خرابیاں" - } + }, + "access": { + "title": "آپ کی رسائی", + "subtitle": "آپ کو اس سرور کے ڈیش بورڈ کے کچھ حصوں تک رسائی دی گئی ہے۔", + "empty": "ابھی تک آپ کو کسی بھی ماڈیول تک رسائی حاصل نہیں ہے۔" + }, + "errors": { "analyticsUnavailable": "تجزیات لوڈ نہیں ہو سکے۔" } } diff --git a/packages/i18n/src/locales/vi/overview.json b/packages/i18n/src/locales/vi/overview.json index 886d2cc1..6e9614d2 100644 --- a/packages/i18n/src/locales/vi/overview.json +++ b/packages/i18n/src/locales/vi/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "Quyền truy cập của bạn", + "subtitle": "Bạn đã được cấp quyền truy cập vào một số phần trong bảng điều khiển của máy chủ này.", + "empty": "Bạn chưa có quyền truy cập vào bất kỳ mô-đun nào." + }, + "errors": { "analyticsUnavailable": "Không thể tải dữ liệu phân tích." } } diff --git a/packages/i18n/src/locales/zh-CN/overview.json b/packages/i18n/src/locales/zh-CN/overview.json index 140f1ec8..c744f9cb 100644 --- a/packages/i18n/src/locales/zh-CN/overview.json +++ b/packages/i18n/src/locales/zh-CN/overview.json @@ -29,5 +29,11 @@ "title": "执行趋势", "success": "成功", "errors": "错误" - } + }, + "access": { + "title": "您的权限", + "subtitle": "您已获得此服务器仪表盘部分内容的访问权限。", + "empty": "您目前还没有任何模块的访问权限。" + }, + "errors": { "analyticsUnavailable": "无法加载分析数据。" } } diff --git a/packages/i18n/src/locales/zh-TW/overview.json b/packages/i18n/src/locales/zh-TW/overview.json index aa5250cd..56f7cd4a 100644 --- a/packages/i18n/src/locales/zh-TW/overview.json +++ b/packages/i18n/src/locales/zh-TW/overview.json @@ -29,5 +29,11 @@ "title": "Execution Trend", "success": "Success", "errors": "Errors" - } + }, + "access": { + "title": "您的權限", + "subtitle": "您已獲得此伺服器儀表板部分內容的存取權限。", + "empty": "您目前尚未擁有任何模組的存取權限。" + }, + "errors": { "analyticsUnavailable": "無法載入分析資料。" } } From e17f8d1f3370eaa8a2bfe6d7e1b4028d922b7605 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 21:12:56 +0300 Subject: [PATCH 24/36] fix(overview): namespace-prefix AccessSummary nav labels AccessSummary's t() defaulted to the "overview" namespace, so nav links rendered raw keys like "nav.tickets" instead of translated labels in every locale. Prefix with common: and strengthen the OverviewPage tests to assert the prefixed key and full three-state exclusivity. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/features/overview/components/AccessSummary.tsx | 2 +- .../tests/client/routes/guild/OverviewPage.test.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/client/features/overview/components/AccessSummary.tsx b/apps/dashboard/src/client/features/overview/components/AccessSummary.tsx index 4b818dd6..3a128cff 100644 --- a/apps/dashboard/src/client/features/overview/components/AccessSummary.tsx +++ b/apps/dashboard/src/client/features/overview/components/AccessSummary.tsx @@ -46,7 +46,7 @@ export function AccessSummary({ guildId }: { guildId: string }) { className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-surface-high" > - {t(item.i18nKey)} + {t(`common:${item.i18nKey}`)} ))} diff --git a/apps/dashboard/tests/client/routes/guild/OverviewPage.test.tsx b/apps/dashboard/tests/client/routes/guild/OverviewPage.test.tsx index e17efdcd..42f39200 100644 --- a/apps/dashboard/tests/client/routes/guild/OverviewPage.test.tsx +++ b/apps/dashboard/tests/client/routes/guild/OverviewPage.test.tsx @@ -46,6 +46,7 @@ describe("OverviewPage", () => { expect(screen.getByTestId("access-summary")).toBeInTheDocument(); expect(screen.queryByTestId("analytics-stats")).not.toBeInTheDocument(); + expect(screen.queryByTestId("analytics-error")).not.toBeInTheDocument(); }); it("does not fetch analytics the user cannot see", () => { @@ -62,8 +63,8 @@ describe("OverviewPage", () => { render(); const summary = screen.getByTestId("access-summary"); - expect(within(summary).getByText("nav.tickets")).toBeInTheDocument(); - expect(within(summary).queryByText("nav.moderation")).not.toBeInTheDocument(); + expect(within(summary).getByText("common:nav.tickets")).toBeInTheDocument(); + expect(within(summary).queryByText("common:nav.moderation")).not.toBeInTheDocument(); }); it("renders an error state rather than an endless skeleton when analytics fails", () => { @@ -78,6 +79,8 @@ describe("OverviewPage", () => { render(); expect(screen.getByTestId("analytics-error")).toBeInTheDocument(); + expect(screen.queryByTestId("access-summary")).not.toBeInTheDocument(); + expect(screen.queryByTestId("analytics-stats")).not.toBeInTheDocument(); }); it("renders the analytics dashboard, not the access summary or an error, once permitted data has loaded", () => { From 600776e8010caa6b6028a2c798d5516a8814dfe2 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 21:23:41 +0300 Subject: [PATCH 25/36] feat(permissions): warn when a role lacks picker access --- .../permissions/hooks/usePermissions.ts | 2 +- .../features/permissions/lookupsWarning.ts | 15 ++++++++ .../routes/guild/$guildId/permissions.tsx | 24 ++++++++++++ .../permissions/lookupsWarning.test.ts | 31 +++++++++++++++ .../guild/$guildId/permissions.test.tsx | 38 +++++++++++-------- packages/i18n/src/locales/af/permissions.json | 5 ++- packages/i18n/src/locales/ar/permissions.json | 5 ++- packages/i18n/src/locales/bg/permissions.json | 5 ++- packages/i18n/src/locales/bn/permissions.json | 5 ++- packages/i18n/src/locales/ca/permissions.json | 5 ++- packages/i18n/src/locales/cs/permissions.json | 5 ++- packages/i18n/src/locales/da/permissions.json | 5 ++- packages/i18n/src/locales/de/permissions.json | 5 ++- packages/i18n/src/locales/el/permissions.json | 5 ++- packages/i18n/src/locales/en/permissions.json | 5 ++- packages/i18n/src/locales/es/permissions.json | 5 ++- packages/i18n/src/locales/et/permissions.json | 5 ++- packages/i18n/src/locales/eu/permissions.json | 5 ++- packages/i18n/src/locales/fa/permissions.json | 5 ++- packages/i18n/src/locales/fi/permissions.json | 5 ++- .../i18n/src/locales/fil/permissions.json | 5 ++- packages/i18n/src/locales/fr/permissions.json | 5 ++- packages/i18n/src/locales/gl/permissions.json | 5 ++- packages/i18n/src/locales/he/permissions.json | 5 ++- packages/i18n/src/locales/hi/permissions.json | 5 ++- packages/i18n/src/locales/hr/permissions.json | 5 ++- packages/i18n/src/locales/hu/permissions.json | 5 ++- packages/i18n/src/locales/id/permissions.json | 5 ++- packages/i18n/src/locales/it/permissions.json | 5 ++- packages/i18n/src/locales/ja/permissions.json | 5 ++- packages/i18n/src/locales/ko/permissions.json | 5 ++- packages/i18n/src/locales/lt/permissions.json | 5 ++- packages/i18n/src/locales/lv/permissions.json | 5 ++- packages/i18n/src/locales/ms/permissions.json | 5 ++- packages/i18n/src/locales/nl/permissions.json | 5 ++- packages/i18n/src/locales/no/permissions.json | 5 ++- packages/i18n/src/locales/pl/permissions.json | 5 ++- packages/i18n/src/locales/pt/permissions.json | 5 ++- packages/i18n/src/locales/ro/permissions.json | 5 ++- packages/i18n/src/locales/ru/permissions.json | 5 ++- packages/i18n/src/locales/sk/permissions.json | 5 ++- packages/i18n/src/locales/sl/permissions.json | 5 ++- packages/i18n/src/locales/sr/permissions.json | 5 ++- packages/i18n/src/locales/sv/permissions.json | 5 ++- packages/i18n/src/locales/sw/permissions.json | 5 ++- packages/i18n/src/locales/ta/permissions.json | 5 ++- packages/i18n/src/locales/th/permissions.json | 5 ++- packages/i18n/src/locales/tr/permissions.json | 5 ++- packages/i18n/src/locales/uk/permissions.json | 5 ++- packages/i18n/src/locales/ur/permissions.json | 5 ++- packages/i18n/src/locales/vi/permissions.json | 5 ++- .../i18n/src/locales/zh-CN/permissions.json | 5 ++- .../i18n/src/locales/zh-TW/permissions.json | 5 ++- 53 files changed, 286 insertions(+), 64 deletions(-) create mode 100644 apps/dashboard/src/client/features/permissions/lookupsWarning.ts create mode 100644 apps/dashboard/tests/client/features/permissions/lookupsWarning.test.ts diff --git a/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts b/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts index f52b658d..20a4168b 100644 --- a/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts +++ b/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts @@ -17,7 +17,7 @@ import { // ─── Permission Matching (client-side mirror of server logic) ─── -function matchPermission(granted: Set, required: string): boolean { +export function matchPermission(granted: Set, required: string): boolean { if (granted.has("*")) return true; if (granted.has(required)) return true; diff --git a/apps/dashboard/src/client/features/permissions/lookupsWarning.ts b/apps/dashboard/src/client/features/permissions/lookupsWarning.ts new file mode 100644 index 00000000..d6e3f11a --- /dev/null +++ b/apps/dashboard/src/client/features/permissions/lookupsWarning.ts @@ -0,0 +1,15 @@ +import { matchPermission } from "./hooks/usePermissions"; + +/** + * Channel/role/member pickers on nearly every page call the Discord lookup + * routes, which require dashboard.lookups.view. A role that can configure + * things but cannot use pickers renders empty dropdowns — worth warning about + * while the role is being edited rather than after it is assigned. + */ +export function needsLookupsPermission(granted: Set): boolean { + if (matchPermission(granted, "dashboard.lookups.view")) return false; + + return [...granted].some( + (perm) => perm.endsWith(".manage") || perm.endsWith(".*") || perm === "*", + ); +} diff --git a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx index a4784b9a..406cb771 100644 --- a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx +++ b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx @@ -45,6 +45,7 @@ import { useDashboardAuditLog, usePermissionRegistry, } from "../../../features/permissions/hooks/usePermissions"; +import { needsLookupsPermission } from "../../../features/permissions/lookupsWarning"; import type { DashboardRole } from "../../../shared/lib/schemas"; import { ROLE_PRESETS } from "@fluxcore/types"; @@ -274,6 +275,8 @@ function RoleEditor({ }); } + const needsLookups = needsLookupsPermission(permissions); + function toggleModuleWildcard(moduleKey: string) { const wildcard = `${moduleKey}.*`; setPermissions((prev) => { @@ -339,6 +342,22 @@ function RoleEditor({
+ {needsLookups && ( + + +
+ {t("roleEditor.lookupsWarning")} + +
+
+ )} + {/* Permission Grid */}
@@ -415,6 +434,11 @@ function RoleEditor({
{permLabel}

{perm.key}

+ {perm.key === "dashboard.roles.manage" && ( +

+ {t("roleEditor.admissionNote")} +

+ )}
); diff --git a/apps/dashboard/tests/client/features/permissions/lookupsWarning.test.ts b/apps/dashboard/tests/client/features/permissions/lookupsWarning.test.ts new file mode 100644 index 00000000..382cd96a --- /dev/null +++ b/apps/dashboard/tests/client/features/permissions/lookupsWarning.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { needsLookupsPermission } from "../../../../src/client/features/permissions/lookupsWarning"; + +describe("needsLookupsPermission", () => { + it("warns when a role can configure things but cannot use pickers", () => { + expect(needsLookupsPermission(new Set(["tickets.panels.manage"]))).toBe(true); + }); + + it("stays quiet once lookups are granted", () => { + expect( + needsLookupsPermission(new Set(["tickets.panels.manage", "dashboard.lookups.view"])), + ).toBe(false); + }); + + it("stays quiet for a view-only role", () => { + expect(needsLookupsPermission(new Set(["tickets.list.view"]))).toBe(false); + }); + + it("stays quiet for a role whose wildcard already covers lookups", () => { + expect(needsLookupsPermission(new Set(["dashboard.*"]))).toBe(false); + expect(needsLookupsPermission(new Set(["*"]))).toBe(false); + }); + + it("warns for a module wildcard that does not cover lookups", () => { + expect(needsLookupsPermission(new Set(["tickets.*"]))).toBe(true); + }); + + it("stays quiet for an empty role", () => { + expect(needsLookupsPermission(new Set())).toBe(false); + }); +}); diff --git a/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx index e7bd73ee..f393e4fc 100644 --- a/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx +++ b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx @@ -46,21 +46,29 @@ const ROLE = { updatedAt: "2026-01-01T00:00:00.000Z", }; -vi.mock("../../../../../src/client/features/permissions/hooks/usePermissions", () => ({ - usePermissions: () => ({ isOwner: true, isLoading: false }), - useDashboardRoles: () => ({ data: [ROLE], isLoading: false }), - useCreateDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), - useUpdateDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), - useDeleteDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), - useCreateRoleFromPreset: () => ({ mutate: vi.fn(), isPending: false }), - useDashboardSettings: () => ({ - data: { guildId: "g1", auditRetentionDays: 30, requirePermissions: true }, - isLoading: false, - }), - useUpdateDashboardSettings: () => ({ mutate: vi.fn(), isPending: false }), - useDashboardAuditLog: () => ({ data: { entries: [], total: 0, page: 1, pages: 1 }, isLoading: false }), - usePermissionRegistry: () => registryState.box, -})); +vi.mock("../../../../../src/client/features/permissions/hooks/usePermissions", async (importOriginal) => { + // matchPermission is kept real (rather than re-stubbed) so RoleEditor's + // lookups-warning check — which imports it via lookupsWarning.ts — behaves + // exactly as it does outside tests; everything else here is a fixed mock. + const actual = + await importOriginal(); + return { + matchPermission: actual.matchPermission, + usePermissions: () => ({ isOwner: true, isLoading: false }), + useDashboardRoles: () => ({ data: [ROLE], isLoading: false }), + useCreateDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), + useUpdateDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), + useCreateRoleFromPreset: () => ({ mutate: vi.fn(), isPending: false }), + useDashboardSettings: () => ({ + data: { guildId: "g1", auditRetentionDays: 30, requirePermissions: true }, + isLoading: false, + }), + useUpdateDashboardSettings: () => ({ mutate: vi.fn(), isPending: false }), + useDashboardAuditLog: () => ({ data: { entries: [], total: 0, page: 1, pages: 1 }, isLoading: false }), + usePermissionRegistry: () => registryState.box, + }; +}); import { PermissionsPage } from "../../../../../src/client/routes/guild/$guildId/permissions"; diff --git a/packages/i18n/src/locales/af/permissions.json b/packages/i18n/src/locales/af/permissions.json index 3eb98031..c45c2bed 100644 --- a/packages/i18n/src/locales/af/permissions.json +++ b/packages/i18n/src/locales/af/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord-rolle", "permissionLabel": "{{action}} {{resource}}", "registryError": "Die toestemminglys kon nie gelaai word nie. Probeer om die bladsy te herlaai.", - "registryEmpty": "Daar is nog geen toestemmings beskikbaar om toe te ken nie." + "registryEmpty": "Daar is nog geen toestemmings beskikbaar om toe te ken nie.", + "lookupsWarning": "Hierdie rol kan modules opstel, maar kan nie kanale, rolle of lede opsoek nie — sy kiesers sal leeg wees.", + "grantLookups": "Verleen kieser-toegang", + "admissionNote": "Laat houers ook toe om ander lede dashboard-toegang te gee." }, "roleForm": { "name": "Rolnaam", diff --git a/packages/i18n/src/locales/ar/permissions.json b/packages/i18n/src/locales/ar/permissions.json index fdd9a41a..489862e3 100644 --- a/packages/i18n/src/locales/ar/permissions.json +++ b/packages/i18n/src/locales/ar/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "أدوار Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "تعذر تحميل قائمة الأذونات. حاول تحديث الصفحة.", - "registryEmpty": "لا توجد أذونات متاحة للتخصيص بعد." + "registryEmpty": "لا توجد أذونات متاحة للتخصيص بعد.", + "lookupsWarning": "يمكن لهذا الدور إعداد الوحدات، لكن لا يمكنه البحث عن القنوات أو الأدوار أو الأعضاء — ستكون قوائمه فارغة.", + "grantLookups": "منح إمكانية الوصول إلى أدوات البحث", + "admissionNote": "كما يتيح لحامليه منح أعضاء آخرين إمكانية الوصول إلى لوحة التحكم." }, "roleForm": { "name": "اسم الدور", diff --git a/packages/i18n/src/locales/bg/permissions.json b/packages/i18n/src/locales/bg/permissions.json index 4a2b85ce..6951f758 100644 --- a/packages/i18n/src/locales/bg/permissions.json +++ b/packages/i18n/src/locales/bg/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord роли", "permissionLabel": "{{action}} {{resource}}", "registryError": "Списъкът с разрешения не можа да се зареди. Опитайте да презаредите страницата.", - "registryEmpty": "Все още няма налични разрешения за присвояване." + "registryEmpty": "Все още няма налични разрешения за присвояване.", + "lookupsWarning": "Тази роля може да конфигурира модули, но не може да търси канали, роли или членове — нейните падащи менюта ще бъдат празни.", + "grantLookups": "Предоставяне на достъп до търсене", + "admissionNote": "Позволява на притежателите също да предоставят на други членове достъп до таблото." }, "roleForm": { "name": "Име на ролята", diff --git a/packages/i18n/src/locales/bn/permissions.json b/packages/i18n/src/locales/bn/permissions.json index f15482ef..9dac8301 100644 --- a/packages/i18n/src/locales/bn/permissions.json +++ b/packages/i18n/src/locales/bn/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord রোল", "permissionLabel": "{{resource}} {{action}}", "registryError": "অনুমতির তালিকা লোড করা যায়নি। পৃষ্ঠাটি রিফ্রেশ করার চেষ্টা করুন।", - "registryEmpty": "এখনও বরাদ্দ করার জন্য কোনো অনুমতি উপলব্ধ নেই।" + "registryEmpty": "এখনও বরাদ্দ করার জন্য কোনো অনুমতি উপলব্ধ নেই।", + "lookupsWarning": "এই ভূমিকা মডিউল কনফিগার করতে পারে কিন্তু চ্যানেল, ভূমিকা বা সদস্যদের খুঁজে বের করতে পারে না — এর পিকারগুলি খালি থাকবে।", + "grantLookups": "পিকার অ্যাক্সেস দিন", + "admissionNote": "এটি ধারকদের অন্যান্য সদস্যদের ড্যাশবোর্ড অ্যাক্সেস দেওয়ারও অনুমতি দেয়।" }, "roleForm": { "name": "রোলের নাম", diff --git a/packages/i18n/src/locales/ca/permissions.json b/packages/i18n/src/locales/ca/permissions.json index 15c99f06..031a1b95 100644 --- a/packages/i18n/src/locales/ca/permissions.json +++ b/packages/i18n/src/locales/ca/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Rols de Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "No s'ha pogut carregar la llista de permisos. Torna a carregar la pàgina.", - "registryEmpty": "Encara no hi ha permisos disponibles per assignar." + "registryEmpty": "Encara no hi ha permisos disponibles per assignar.", + "lookupsWarning": "Aquest rol pot configurar mòduls però no pot cercar canals, rols o membres — els seus selectors estaran buits.", + "grantLookups": "Concedeix accés als selectors", + "admissionNote": "També permet als titulars donar accés al tauler a altres membres." }, "roleForm": { "name": "Nom del rol", diff --git a/packages/i18n/src/locales/cs/permissions.json b/packages/i18n/src/locales/cs/permissions.json index d2f27666..4485e280 100644 --- a/packages/i18n/src/locales/cs/permissions.json +++ b/packages/i18n/src/locales/cs/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord role", "permissionLabel": "{{action}} {{resource}}", "registryError": "Seznam oprávnění se nepodařilo načíst. Zkuste stránku obnovit.", - "registryEmpty": "Zatím nejsou k dispozici žádná oprávnění k přiřazení." + "registryEmpty": "Zatím nejsou k dispozici žádná oprávnění k přiřazení.", + "lookupsWarning": "Tato role může konfigurovat moduly, ale nemůže vyhledávat kanály, role ani členy — její výběry budou prázdné.", + "grantLookups": "Udělit přístup k výběrům", + "admissionNote": "Také umožňuje držitelům udělit ostatním členům přístup k nástěnce." }, "roleForm": { "name": "Název role", diff --git a/packages/i18n/src/locales/da/permissions.json b/packages/i18n/src/locales/da/permissions.json index 4816f2c4..2d47cd7b 100644 --- a/packages/i18n/src/locales/da/permissions.json +++ b/packages/i18n/src/locales/da/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord-roller", "permissionLabel": "{{action}} {{resource}}", "registryError": "Tilladelseslisten kunne ikke indlæses. Prøv at genindlæse siden.", - "registryEmpty": "Der er endnu ingen tilladelser tilgængelige at tildele." + "registryEmpty": "Der er endnu ingen tilladelser tilgængelige at tildele.", + "lookupsWarning": "Denne rolle kan konfigurere moduler, men kan ikke slå kanaler, roller eller medlemmer op — dens vælgere vil være tomme.", + "grantLookups": "Giv adgang til vælgere", + "admissionNote": "Giver også indehavere mulighed for at give andre medlemmer adgang til dashboardet." }, "roleForm": { "name": "Rollenavn", diff --git a/packages/i18n/src/locales/de/permissions.json b/packages/i18n/src/locales/de/permissions.json index 9999912e..edf037c6 100644 --- a/packages/i18n/src/locales/de/permissions.json +++ b/packages/i18n/src/locales/de/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord-Rollen", "permissionLabel": "{{resource}} {{action}}", "registryError": "Die Berechtigungsliste konnte nicht geladen werden. Versuche, die Seite neu zu laden.", - "registryEmpty": "Es sind noch keine Berechtigungen zum Zuweisen verfügbar." + "registryEmpty": "Es sind noch keine Berechtigungen zum Zuweisen verfügbar.", + "lookupsWarning": "Diese Rolle kann Module konfigurieren, aber keine Kanäle, Rollen oder Mitglieder nachschlagen — ihre Auswahlfelder bleiben leer.", + "grantLookups": "Auswahlzugriff gewähren", + "admissionNote": "Erlaubt Inhabern außerdem, anderen Mitgliedern Dashboard-Zugriff zu gewähren." }, "roleForm": { "name": "Rollenname", diff --git a/packages/i18n/src/locales/el/permissions.json b/packages/i18n/src/locales/el/permissions.json index 150a3ae5..3b138848 100644 --- a/packages/i18n/src/locales/el/permissions.json +++ b/packages/i18n/src/locales/el/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Ρόλοι Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Δεν ήταν δυνατή η φόρτωση της λίστας δικαιωμάτων. Δοκιμάστε να ανανεώσετε τη σελίδα.", - "registryEmpty": "Δεν υπάρχουν ακόμη διαθέσιμα δικαιώματα για ανάθεση." + "registryEmpty": "Δεν υπάρχουν ακόμη διαθέσιμα δικαιώματα για ανάθεση.", + "lookupsWarning": "Αυτός ο ρόλος μπορεί να διαμορφώνει ενότητες αλλά δεν μπορεί να αναζητήσει κανάλια, ρόλους ή μέλη — οι επιλογείς του θα είναι κενοί.", + "grantLookups": "Παραχώρηση πρόσβασης σε επιλογείς", + "admissionNote": "Επιτρέπει επίσης στους κατόχους να παραχωρούν σε άλλα μέλη πρόσβαση στον πίνακα ελέγχου." }, "roleForm": { "name": "Όνομα ρόλου", diff --git a/packages/i18n/src/locales/en/permissions.json b/packages/i18n/src/locales/en/permissions.json index dee6e611..63d8218f 100644 --- a/packages/i18n/src/locales/en/permissions.json +++ b/packages/i18n/src/locales/en/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord Roles", "permissionLabel": "{{action}} {{resource}}", "registryError": "The permission list could not be loaded. Try refreshing the page.", - "registryEmpty": "No permissions are available to assign yet." + "registryEmpty": "No permissions are available to assign yet.", + "lookupsWarning": "This role can configure modules but cannot look up channels, roles, or members — its pickers will be empty.", + "grantLookups": "Grant picker access", + "admissionNote": "Also lets holders give other members dashboard access." }, "roleForm": { "name": "Role Name", diff --git a/packages/i18n/src/locales/es/permissions.json b/packages/i18n/src/locales/es/permissions.json index 9b64d858..42d7e0ff 100644 --- a/packages/i18n/src/locales/es/permissions.json +++ b/packages/i18n/src/locales/es/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Roles de Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "No se pudo cargar la lista de permisos. Intenta actualizar la página.", - "registryEmpty": "Aún no hay permisos disponibles para asignar." + "registryEmpty": "Aún no hay permisos disponibles para asignar.", + "lookupsWarning": "Este rol puede configurar módulos, pero no puede buscar canales, roles ni miembros — sus selectores estarán vacíos.", + "grantLookups": "Conceder acceso a los selectores", + "admissionNote": "También permite a sus titulares dar acceso al panel a otros miembros." }, "roleForm": { "name": "Nombre del rol", diff --git a/packages/i18n/src/locales/et/permissions.json b/packages/i18n/src/locales/et/permissions.json index c5476973..fc938b6a 100644 --- a/packages/i18n/src/locales/et/permissions.json +++ b/packages/i18n/src/locales/et/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord rollid", "permissionLabel": "{{action}} {{resource}}", "registryError": "Õiguste loendit ei õnnestunud laadida. Proovi lehte värskendada.", - "registryEmpty": "Määramiseks pole veel ühtegi õigust saadaval." + "registryEmpty": "Määramiseks pole veel ühtegi õigust saadaval.", + "lookupsWarning": "See roll saab mooduleid seadistada, kuid ei saa otsida kanaleid, rolle ega liikmeid — selle valikuloendid jäävad tühjaks.", + "grantLookups": "Anna juurdepääs valikuloenditele", + "admissionNote": "Lubab omanikel anda ka teistele liikmetele juhtpaneelile juurdepääsu." }, "roleForm": { "name": "Rolli nimi", diff --git a/packages/i18n/src/locales/eu/permissions.json b/packages/i18n/src/locales/eu/permissions.json index a80ade93..25b107d6 100644 --- a/packages/i18n/src/locales/eu/permissions.json +++ b/packages/i18n/src/locales/eu/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord rolak", "permissionLabel": "{{resource}} {{action}}", "registryError": "Baimen-zerrenda ezin izan da kargatu. Saiatu orria freskatzen.", - "registryEmpty": "Oraindik ez dago esleitzeko baimenik erabilgarri." + "registryEmpty": "Oraindik ez dago esleitzeko baimenik erabilgarri.", + "lookupsWarning": "Rol honek moduluak konfiguratu ditzake, baina ezin ditu kanalak, rolak edo kideak bilatu — bere hautatzaileak hutsik egongo dira.", + "grantLookups": "Eman hautatzaileetarako sarbidea", + "admissionNote": "Titularrei beste kideei kontrol-paneleko sarbidea emateko ere aukera ematen die." }, "roleForm": { "name": "Rol-izena", diff --git a/packages/i18n/src/locales/fa/permissions.json b/packages/i18n/src/locales/fa/permissions.json index 5e12ef2c..6d84fee7 100644 --- a/packages/i18n/src/locales/fa/permissions.json +++ b/packages/i18n/src/locales/fa/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "نقش‌های Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "فهرست دسترسی‌ها بارگذاری نشد. صفحه را دوباره بارگذاری کنید.", - "registryEmpty": "هنوز هیچ دسترسی‌ای برای اختصاص دادن وجود ندارد." + "registryEmpty": "هنوز هیچ دسترسی‌ای برای اختصاص دادن وجود ندارد.", + "lookupsWarning": "این نقش می‌تواند ماژول‌ها را پیکربندی کند اما نمی‌تواند کانال‌ها، نقش‌ها یا اعضا را جستجو کند — انتخابگرهای آن خالی خواهند بود.", + "grantLookups": "اعطای دسترسی به انتخابگرها", + "admissionNote": "همچنین به دارندگان اجازه می‌دهد به اعضای دیگر دسترسی به داشبورد بدهند." }, "roleForm": { "name": "نام نقش", diff --git a/packages/i18n/src/locales/fi/permissions.json b/packages/i18n/src/locales/fi/permissions.json index 42d0d6fd..510b7726 100644 --- a/packages/i18n/src/locales/fi/permissions.json +++ b/packages/i18n/src/locales/fi/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord-roolit", "permissionLabel": "{{action}} {{resource}}", "registryError": "Käyttöoikeusluetteloa ei voitu ladata. Yritä päivittää sivu.", - "registryEmpty": "Jaettavia käyttöoikeuksia ei ole vielä saatavilla." + "registryEmpty": "Jaettavia käyttöoikeuksia ei ole vielä saatavilla.", + "lookupsWarning": "Tämä rooli voi määrittää moduuleja, mutta ei voi hakea kanavia, rooleja tai jäseniä — sen valitsimet jäävät tyhjiksi.", + "grantLookups": "Myönnä pääsy valitsimiin", + "admissionNote": "Antaa haltijoille myös mahdollisuuden myöntää muille jäsenille pääsyn hallintapaneeliin." }, "roleForm": { "name": "Roolin nimi", diff --git a/packages/i18n/src/locales/fil/permissions.json b/packages/i18n/src/locales/fil/permissions.json index 18571aee..1f77dfdb 100644 --- a/packages/i18n/src/locales/fil/permissions.json +++ b/packages/i18n/src/locales/fil/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Mga Discord Role", "permissionLabel": "{{action}} {{resource}}", "registryError": "Hindi ma-load ang listahan ng pahintulot. Subukang i-refresh ang pahina.", - "registryEmpty": "Wala pang mga pahintulot na available na itatalaga." + "registryEmpty": "Wala pang mga pahintulot na available na itatalaga.", + "lookupsWarning": "Puwedeng i-configure ng role na ito ang mga module pero hindi puwedeng maghanap ng mga channel, role, o miyembro — magiging walang laman ang mga picker nito.", + "grantLookups": "Bigyan ng access sa picker", + "admissionNote": "Nagbibigay-daan din sa may hawak nitong bigyan ang ibang miyembro ng access sa dashboard." }, "roleForm": { "name": "Pangalan ng Role", diff --git a/packages/i18n/src/locales/fr/permissions.json b/packages/i18n/src/locales/fr/permissions.json index 94fe98fe..aecd7c86 100644 --- a/packages/i18n/src/locales/fr/permissions.json +++ b/packages/i18n/src/locales/fr/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Roles Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Impossible de charger la liste des permissions. Essayez d'actualiser la page.", - "registryEmpty": "Aucune permission n'est encore disponible à attribuer." + "registryEmpty": "Aucune permission n'est encore disponible à attribuer.", + "lookupsWarning": "Ce rôle peut configurer des modules mais ne peut pas rechercher de salons, rôles ou membres — ses sélecteurs seront vides.", + "grantLookups": "Accorder l'accès aux sélecteurs", + "admissionNote": "Permet également aux détenteurs de donner à d'autres membres l'accès au tableau de bord." }, "roleForm": { "name": "Nom du role", diff --git a/packages/i18n/src/locales/gl/permissions.json b/packages/i18n/src/locales/gl/permissions.json index 19262c09..a0327722 100644 --- a/packages/i18n/src/locales/gl/permissions.json +++ b/packages/i18n/src/locales/gl/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Roles de Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Non se puido cargar a lista de permisos. Proba a actualizar a páxina.", - "registryEmpty": "Aínda non hai permisos dispoñibles para asignar." + "registryEmpty": "Aínda non hai permisos dispoñibles para asignar.", + "lookupsWarning": "Este rol pode configurar módulos, pero non pode buscar canles, roles ou membros — os seus selectores estarán baleiros.", + "grantLookups": "Conceder acceso aos selectores", + "admissionNote": "Tamén permite aos titulares dar acceso ao panel a outros membros." }, "roleForm": { "name": "Nome do Rol", diff --git a/packages/i18n/src/locales/he/permissions.json b/packages/i18n/src/locales/he/permissions.json index 0f974e53..a60f437d 100644 --- a/packages/i18n/src/locales/he/permissions.json +++ b/packages/i18n/src/locales/he/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "תפקידי Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "לא ניתן היה לטעון את רשימת ההרשאות. נסה לרענן את הדף.", - "registryEmpty": "אין עדיין הרשאות זמינות להקצאה." + "registryEmpty": "אין עדיין הרשאות זמינות להקצאה.", + "lookupsWarning": "תפקיד זה יכול להגדיר מודולים אך אינו יכול לחפש ערוצים, תפקידים או חברים — הבוררים שלו יהיו ריקים.", + "grantLookups": "הענק גישה לבוררים", + "admissionNote": "מאפשר גם למחזיקים בו להעניק לחברים אחרים גישה ללוח הבקרה." }, "roleForm": { "name": "שם תפקיד", diff --git a/packages/i18n/src/locales/hi/permissions.json b/packages/i18n/src/locales/hi/permissions.json index 21aeb0de..a4ec7f10 100644 --- a/packages/i18n/src/locales/hi/permissions.json +++ b/packages/i18n/src/locales/hi/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord रोल", "permissionLabel": "{{resource}} {{action}}", "registryError": "अनुमति सूची लोड नहीं हो सकी। पृष्ठ को रीफ्रेश करने का प्रयास करें।", - "registryEmpty": "अभी तक असाइन करने के लिए कोई अनुमति उपलब्ध नहीं है।" + "registryEmpty": "अभी तक असाइन करने के लिए कोई अनुमति उपलब्ध नहीं है।", + "lookupsWarning": "यह भूमिका मॉड्यूल कॉन्फ़िगर कर सकती है लेकिन चैनल, भूमिकाएँ या सदस्य खोज नहीं सकती — इसके पिकर खाली रहेंगे।", + "grantLookups": "पिकर एक्सेस दें", + "admissionNote": "यह धारकों को अन्य सदस्यों को डैशबोर्ड एक्सेस देने की भी अनुमति देता है।" }, "roleForm": { "name": "रोल का नाम", diff --git a/packages/i18n/src/locales/hr/permissions.json b/packages/i18n/src/locales/hr/permissions.json index afa1e42c..03f09afa 100644 --- a/packages/i18n/src/locales/hr/permissions.json +++ b/packages/i18n/src/locales/hr/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord uloge", "permissionLabel": "{{action}} {{resource}}", "registryError": "Popis dozvola nije uspio učitati. Pokušajte osvježiti stranicu.", - "registryEmpty": "Trenutno nema dostupnih dozvola za dodjelu." + "registryEmpty": "Trenutno nema dostupnih dozvola za dodjelu.", + "lookupsWarning": "Ova uloga može konfigurirati module, ali ne može pretraživati kanale, uloge ili članove — njezini birači bit će prazni.", + "grantLookups": "Dodijeli pristup biračima", + "admissionNote": "Također omogućuje nositeljima da drugim članovima dodijele pristup nadzornoj ploči." }, "roleForm": { "name": "Naziv uloge", diff --git a/packages/i18n/src/locales/hu/permissions.json b/packages/i18n/src/locales/hu/permissions.json index 1ea0468b..1bb4ded4 100644 --- a/packages/i18n/src/locales/hu/permissions.json +++ b/packages/i18n/src/locales/hu/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord Roles", "permissionLabel": "{{resource}} {{action}}", "registryError": "A jogosultságlistát nem sikerült betölteni. Próbáld frissíteni az oldalt.", - "registryEmpty": "Még nincsenek kiosztható jogosultságok." + "registryEmpty": "Még nincsenek kiosztható jogosultságok.", + "lookupsWarning": "Ez a szerepkör képes modulokat konfigurálni, de nem tud csatornákat, szerepköröket vagy tagokat keresni — a választói üresek lesznek.", + "grantLookups": "Választói hozzáférés megadása", + "admissionNote": "A birtokosok más tagoknak is hozzáférést adhatnak az irányítópulthoz." }, "roleForm": { "name": "Role Name", diff --git a/packages/i18n/src/locales/id/permissions.json b/packages/i18n/src/locales/id/permissions.json index 1cc8ba04..66011c99 100644 --- a/packages/i18n/src/locales/id/permissions.json +++ b/packages/i18n/src/locales/id/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Peran Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Daftar izin tidak dapat dimuat. Coba segarkan halaman.", - "registryEmpty": "Belum ada izin yang tersedia untuk diberikan." + "registryEmpty": "Belum ada izin yang tersedia untuk diberikan.", + "lookupsWarning": "Peran ini dapat mengonfigurasi modul tetapi tidak dapat mencari saluran, peran, atau anggota — pemilihnya akan kosong.", + "grantLookups": "Berikan akses pemilih", + "admissionNote": "Juga memungkinkan pemegangnya memberikan akses dasbor kepada anggota lain." }, "roleForm": { "name": "Nama Peran", diff --git a/packages/i18n/src/locales/it/permissions.json b/packages/i18n/src/locales/it/permissions.json index 56268c16..cce76c7d 100644 --- a/packages/i18n/src/locales/it/permissions.json +++ b/packages/i18n/src/locales/it/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Ruoli Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Impossibile caricare l'elenco dei permessi. Prova ad aggiornare la pagina.", - "registryEmpty": "Non ci sono ancora permessi disponibili da assegnare." + "registryEmpty": "Non ci sono ancora permessi disponibili da assegnare.", + "lookupsWarning": "Questo ruolo può configurare i moduli ma non può cercare canali, ruoli o membri — i suoi selettori saranno vuoti.", + "grantLookups": "Concedi accesso ai selettori", + "admissionNote": "Consente inoltre ai titolari di concedere ad altri membri l'accesso alla dashboard." }, "roleForm": { "name": "Nome del ruolo", diff --git a/packages/i18n/src/locales/ja/permissions.json b/packages/i18n/src/locales/ja/permissions.json index b9bc3b75..bddb56eb 100644 --- a/packages/i18n/src/locales/ja/permissions.json +++ b/packages/i18n/src/locales/ja/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discordロール", "permissionLabel": "{{resource}}{{action}}", "registryError": "権限リストを読み込めませんでした。ページを更新してください。", - "registryEmpty": "割り当てられる権限はまだありません。" + "registryEmpty": "割り当てられる権限はまだありません。", + "lookupsWarning": "このロールはモジュールを設定できますが、チャンネル、ロール、メンバーを検索できません — ピッカーは空になります。", + "grantLookups": "ピッカーへのアクセスを許可", + "admissionNote": "保持者が他のメンバーにダッシュボードへのアクセスを付与できるようにもなります。" }, "roleForm": { "name": "ロール名", diff --git a/packages/i18n/src/locales/ko/permissions.json b/packages/i18n/src/locales/ko/permissions.json index 34f648c7..be966c7b 100644 --- a/packages/i18n/src/locales/ko/permissions.json +++ b/packages/i18n/src/locales/ko/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord 역할", "permissionLabel": "{{resource}} {{action}}", "registryError": "권한 목록을 불러올 수 없습니다. 페이지를 새로고침해 보세요.", - "registryEmpty": "아직 할당할 수 있는 권한이 없습니다." + "registryEmpty": "아직 할당할 수 있는 권한이 없습니다.", + "lookupsWarning": "이 역할은 모듈을 구성할 수 있지만 채널, 역할 또는 멤버를 조회할 수 없습니다 — 선택기가 비어 있게 됩니다.", + "grantLookups": "선택기 접근 권한 부여", + "admissionNote": "보유자가 다른 멤버에게 대시보드 접근 권한을 부여할 수도 있게 합니다." }, "roleForm": { "name": "역할 이름", diff --git a/packages/i18n/src/locales/lt/permissions.json b/packages/i18n/src/locales/lt/permissions.json index 2e843ab3..77a5e4b5 100644 --- a/packages/i18n/src/locales/lt/permissions.json +++ b/packages/i18n/src/locales/lt/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord roles", "permissionLabel": "{{action}} {{resource}}", "registryError": "Nepavyko įkelti leidimų sąrašo. Pabandykite iš naujo įkelti puslapį.", - "registryEmpty": "Kol kas nėra priskirtinų leidimų." + "registryEmpty": "Kol kas nėra priskirtinų leidimų.", + "lookupsWarning": "Šis vaidmuo gali konfigūruoti modulius, bet negali ieškoti kanalų, vaidmenų ar narių — jo rinkikliai bus tušti.", + "grantLookups": "Suteikti prieigą prie rinkiklių", + "admissionNote": "Taip pat leidžia turėtojams suteikti kitiems nariams prieigą prie skydelio." }, "roleForm": { "name": "Roles pavadinimas", diff --git a/packages/i18n/src/locales/lv/permissions.json b/packages/i18n/src/locales/lv/permissions.json index 5ae4ebfa..9da0609c 100644 --- a/packages/i18n/src/locales/lv/permissions.json +++ b/packages/i18n/src/locales/lv/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord lomas", "permissionLabel": "{{action}} {{resource}}", "registryError": "Neizdevās ielādēt atļauju sarakstu. Mēģiniet atsvaidzināt lapu.", - "registryEmpty": "Pagaidām nav pieejamu atļauju piešķiršanai." + "registryEmpty": "Pagaidām nav pieejamu atļauju piešķiršanai.", + "lookupsWarning": "Šī loma var konfigurēt moduļus, bet nevar meklēt kanālus, lomas vai dalībniekus — tās atlasītāji būs tukši.", + "grantLookups": "Piešķirt piekļuvi atlasītājiem", + "admissionNote": "Ļauj turētājiem arī piešķirt citiem dalībniekiem piekļuvi vadības panelim." }, "roleForm": { "name": "Lomas nosaukums", diff --git a/packages/i18n/src/locales/ms/permissions.json b/packages/i18n/src/locales/ms/permissions.json index bb58224d..cd46081d 100644 --- a/packages/i18n/src/locales/ms/permissions.json +++ b/packages/i18n/src/locales/ms/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Peranan Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Senarai kebenaran tidak dapat dimuatkan. Cuba muat semula halaman.", - "registryEmpty": "Belum ada kebenaran tersedia untuk diberikan." + "registryEmpty": "Belum ada kebenaran tersedia untuk diberikan.", + "lookupsWarning": "Peranan ini boleh mengkonfigurasi modul tetapi tidak boleh mencari saluran, peranan atau ahli — pemilihnya akan kosong.", + "grantLookups": "Berikan akses pemilih", + "admissionNote": "Juga membolehkan pemegangnya memberikan akses papan pemuka kepada ahli lain." }, "roleForm": { "name": "Nama Peranan", diff --git a/packages/i18n/src/locales/nl/permissions.json b/packages/i18n/src/locales/nl/permissions.json index 8511debb..4dcd3fa9 100644 --- a/packages/i18n/src/locales/nl/permissions.json +++ b/packages/i18n/src/locales/nl/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord-rollen", "permissionLabel": "{{resource}} {{action}}", "registryError": "De machtigingenlijst kon niet worden geladen. Probeer de pagina te vernieuwen.", - "registryEmpty": "Er zijn nog geen machtigingen beschikbaar om toe te wijzen." + "registryEmpty": "Er zijn nog geen machtigingen beschikbaar om toe te wijzen.", + "lookupsWarning": "Deze rol kan modules configureren, maar kan geen kanalen, rollen of leden opzoeken — de kiezers ervan blijven leeg.", + "grantLookups": "Kiezertoegang verlenen", + "admissionNote": "Stelt houders ook in staat om andere leden dashboardtoegang te geven." }, "roleForm": { "name": "Rolnaam", diff --git a/packages/i18n/src/locales/no/permissions.json b/packages/i18n/src/locales/no/permissions.json index 72ac718e..a81a86d7 100644 --- a/packages/i18n/src/locales/no/permissions.json +++ b/packages/i18n/src/locales/no/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord-roller", "permissionLabel": "{{action}} {{resource}}", "registryError": "Kunne ikke laste inn tillatelseslisten. Prøv å oppdatere siden.", - "registryEmpty": "Det finnes ingen tillatelser å tildele ennå." + "registryEmpty": "Det finnes ingen tillatelser å tildele ennå.", + "lookupsWarning": "Denne rollen kan konfigurere moduler, men kan ikke slå opp kanaler, roller eller medlemmer — velgerne vil være tomme.", + "grantLookups": "Gi tilgang til velgere", + "admissionNote": "Lar også innehavere gi andre medlemmer tilgang til dashbordet." }, "roleForm": { "name": "Rollenavn", diff --git a/packages/i18n/src/locales/pl/permissions.json b/packages/i18n/src/locales/pl/permissions.json index 36ee429d..a82f5996 100644 --- a/packages/i18n/src/locales/pl/permissions.json +++ b/packages/i18n/src/locales/pl/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Role Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Nie udało się wczytać listy uprawnień. Spróbuj odświeżyć stronę.", - "registryEmpty": "Nie ma jeszcze żadnych uprawnień do przypisania." + "registryEmpty": "Nie ma jeszcze żadnych uprawnień do przypisania.", + "lookupsWarning": "Ta rola może konfigurować moduły, ale nie może wyszukiwać kanałów, ról ani członków — jej selektory będą puste.", + "grantLookups": "Przyznaj dostęp do selektorów", + "admissionNote": "Pozwala też posiadaczom nadawać innym członkom dostęp do panelu." }, "roleForm": { "name": "Nazwa roli", diff --git a/packages/i18n/src/locales/pt/permissions.json b/packages/i18n/src/locales/pt/permissions.json index 8b112e67..04b49261 100644 --- a/packages/i18n/src/locales/pt/permissions.json +++ b/packages/i18n/src/locales/pt/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Cargos do Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Não foi possível carregar a lista de permissões. Tente atualizar a página.", - "registryEmpty": "Ainda não há permissões disponíveis para atribuir." + "registryEmpty": "Ainda não há permissões disponíveis para atribuir.", + "lookupsWarning": "Este cargo pode configurar módulos, mas não pode pesquisar canais, cargos ou membros — os seus seletores ficarão vazios.", + "grantLookups": "Conceder acesso aos seletores", + "admissionNote": "Também permite que os titulares deem a outros membros acesso ao painel." }, "roleForm": { "name": "Nome do cargo", diff --git a/packages/i18n/src/locales/ro/permissions.json b/packages/i18n/src/locales/ro/permissions.json index 9080d7fd..9db5c9b4 100644 --- a/packages/i18n/src/locales/ro/permissions.json +++ b/packages/i18n/src/locales/ro/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Roluri Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Lista de permisiuni nu a putut fi încărcată. Încearcă să reîmprospătezi pagina.", - "registryEmpty": "Nu există încă permisiuni disponibile pentru alocare." + "registryEmpty": "Nu există încă permisiuni disponibile pentru alocare.", + "lookupsWarning": "Acest rol poate configura module, dar nu poate căuta canale, roluri sau membri — selectoarele sale vor fi goale.", + "grantLookups": "Acordă acces la selectoare", + "admissionNote": "De asemenea, le permite deținătorilor să acorde altor membri acces la panou." }, "roleForm": { "name": "Numele Rolului", diff --git a/packages/i18n/src/locales/ru/permissions.json b/packages/i18n/src/locales/ru/permissions.json index 257c9ed5..e2e1d9b2 100644 --- a/packages/i18n/src/locales/ru/permissions.json +++ b/packages/i18n/src/locales/ru/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Роли Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Не удалось загрузить список разрешений. Попробуйте обновить страницу.", - "registryEmpty": "Пока нет доступных разрешений для назначения." + "registryEmpty": "Пока нет доступных разрешений для назначения.", + "lookupsWarning": "Эта роль может настраивать модули, но не может искать каналы, роли или участников — её списки выбора будут пустыми.", + "grantLookups": "Предоставить доступ к спискам выбора", + "admissionNote": "Также позволяет обладателям предоставлять другим участникам доступ к панели управления." }, "roleForm": { "name": "Название роли", diff --git a/packages/i18n/src/locales/sk/permissions.json b/packages/i18n/src/locales/sk/permissions.json index 4f91d2e7..9a8172f4 100644 --- a/packages/i18n/src/locales/sk/permissions.json +++ b/packages/i18n/src/locales/sk/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord Roles", "permissionLabel": "{{action}} {{resource}}", "registryError": "Zoznam oprávnení sa nepodarilo načítať. Skúste stránku obnoviť.", - "registryEmpty": "Zatiaľ nie sú k dispozícii žiadne oprávnenia na priradenie." + "registryEmpty": "Zatiaľ nie sú k dispozícii žiadne oprávnenia na priradenie.", + "lookupsWarning": "Táto rola môže konfigurovať moduly, ale nemôže vyhľadávať kanály, role ani členov — jej výbery budú prázdne.", + "grantLookups": "Udeliť prístup k výberom", + "admissionNote": "Tiež umožňuje držiteľom udeliť ostatným členom prístup k nástenke." }, "roleForm": { "name": "Role Name", diff --git a/packages/i18n/src/locales/sl/permissions.json b/packages/i18n/src/locales/sl/permissions.json index e22a9e0f..62edb5cb 100644 --- a/packages/i18n/src/locales/sl/permissions.json +++ b/packages/i18n/src/locales/sl/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord vloge", "permissionLabel": "{{action}} {{resource}}", "registryError": "Seznama dovoljenj ni bilo mogoče naložiti. Poskusite osvežiti stran.", - "registryEmpty": "Trenutno ni na voljo nobenih dovoljenj za dodelitev." + "registryEmpty": "Trenutno ni na voljo nobenih dovoljenj za dodelitev.", + "lookupsWarning": "Ta vloga lahko konfigurira module, vendar ne more iskati kanalov, vlog ali članov — njeni izbirniki bodo prazni.", + "grantLookups": "Dodeli dostop do izbirnikov", + "admissionNote": "Prav tako imetnikom omogoča, da drugim članom dodelijo dostop do nadzorne plošče." }, "roleForm": { "name": "Ime vloge", diff --git a/packages/i18n/src/locales/sr/permissions.json b/packages/i18n/src/locales/sr/permissions.json index 1703c6c7..6898eb8c 100644 --- a/packages/i18n/src/locales/sr/permissions.json +++ b/packages/i18n/src/locales/sr/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord uloge", "permissionLabel": "{{action}} {{resource}}", "registryError": "Lista dozvola nije mogla da se ucita. Pokusajte da osvezite stranicu.", - "registryEmpty": "Trenutno nema dostupnih dozvola za dodelu." + "registryEmpty": "Trenutno nema dostupnih dozvola za dodelu.", + "lookupsWarning": "Ova uloga moze da konfigurise module, ali ne moze da pretrazuje kanale, uloge ili clanove — njeni biraci ce biti prazni.", + "grantLookups": "Dodeli pristup biracima", + "admissionNote": "Takodje omogucava nosiocima da dodele drugim clanovima pristup kontrolnoj tabli." }, "roleForm": { "name": "Naziv uloge", diff --git a/packages/i18n/src/locales/sv/permissions.json b/packages/i18n/src/locales/sv/permissions.json index f9f1950e..91e5e16a 100644 --- a/packages/i18n/src/locales/sv/permissions.json +++ b/packages/i18n/src/locales/sv/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord-roller", "permissionLabel": "{{action}} {{resource}}", "registryError": "Behörighetslistan kunde inte läsas in. Försök uppdatera sidan.", - "registryEmpty": "Det finns inga behörigheter att tilldela än." + "registryEmpty": "Det finns inga behörigheter att tilldela än.", + "lookupsWarning": "Denna roll kan konfigurera moduler men kan inte slå upp kanaler, roller eller medlemmar — dess väljare kommer att vara tomma.", + "grantLookups": "Ge åtkomst till väljare", + "admissionNote": "Låter även innehavare ge andra medlemmar åtkomst till instrumentpanelen." }, "roleForm": { "name": "Rollnamn", diff --git a/packages/i18n/src/locales/sw/permissions.json b/packages/i18n/src/locales/sw/permissions.json index b83c9780..2b04a311 100644 --- a/packages/i18n/src/locales/sw/permissions.json +++ b/packages/i18n/src/locales/sw/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Majukumu ya Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Orodha ya ruhusa haikuweza kupakiwa. Jaribu kuonyesha upya ukurasa.", - "registryEmpty": "Hakuna ruhusa zinazopatikana kugawiwa bado." + "registryEmpty": "Hakuna ruhusa zinazopatikana kugawiwa bado.", + "lookupsWarning": "Jukumu hili linaweza kusanidi moduli lakini haliwezi kutafuta chaneli, majukumu, au wanachama — vichaguzi vyake vitakuwa tupu.", + "grantLookups": "Toa ufikiaji wa vichaguzi", + "admissionNote": "Pia huwaruhusu wamiliki kuwapa wanachama wengine ufikiaji wa dashibodi." }, "roleForm": { "name": "Jina la Jukumu", diff --git a/packages/i18n/src/locales/ta/permissions.json b/packages/i18n/src/locales/ta/permissions.json index 494b99b3..9de7d316 100644 --- a/packages/i18n/src/locales/ta/permissions.json +++ b/packages/i18n/src/locales/ta/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord பங்குகள்", "permissionLabel": "{{resource}} {{action}}", "registryError": "அனுமதிப் பட்டியலை ஏற்ற முடியவில்லை. பக்கத்தை புதுப்பிக்க முயற்சிக்கவும்.", - "registryEmpty": "இதுவரை ஒதுக்க அனுமதிகள் எதுவும் இல்லை." + "registryEmpty": "இதுவரை ஒதுக்க அனுமதிகள் எதுவும் இல்லை.", + "lookupsWarning": "இந்தப் பங்கு தொகுதிகளை உள்ளமைக்க முடியும், ஆனால் சேனல்கள், பங்குகள் அல்லது உறுப்பினர்களைத் தேட முடியாது — அதன் தேர்வுக் கருவிகள் காலியாக இருக்கும்.", + "grantLookups": "தேர்வுக் கருவி அணுகலை வழங்கு", + "admissionNote": "இது வைத்திருப்பவர்கள் மற்ற உறுப்பினர்களுக்கு டாஷ்போர்டு அணுகலை வழங்கவும் அனுமதிக்கிறது." }, "roleForm": { "name": "பங்கு பெயர்", diff --git a/packages/i18n/src/locales/th/permissions.json b/packages/i18n/src/locales/th/permissions.json index aa2cf305..48e8ae5e 100644 --- a/packages/i18n/src/locales/th/permissions.json +++ b/packages/i18n/src/locales/th/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "บทบาท Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "ไม่สามารถโหลดรายการสิทธิ์ได้ ลองรีเฟรชหน้านี้", - "registryEmpty": "ยังไม่มีสิทธิ์ที่สามารถกำหนดได้" + "registryEmpty": "ยังไม่มีสิทธิ์ที่สามารถกำหนดได้", + "lookupsWarning": "บทบาทนี้สามารถกำหนดค่าโมดูลได้ แต่ไม่สามารถค้นหาช่อง บทบาท หรือสมาชิกได้ — ตัวเลือกของมันจะว่างเปล่า", + "grantLookups": "ให้สิทธิ์เข้าถึงตัวเลือก", + "admissionNote": "ยังอนุญาตให้ผู้ถือสิทธิ์มอบสิทธิ์เข้าถึงแดชบอร์ดให้สมาชิกคนอื่นได้ด้วย" }, "roleForm": { "name": "ชื่อบทบาท", diff --git a/packages/i18n/src/locales/tr/permissions.json b/packages/i18n/src/locales/tr/permissions.json index 9901c765..1cd57d21 100644 --- a/packages/i18n/src/locales/tr/permissions.json +++ b/packages/i18n/src/locales/tr/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord Rolleri", "permissionLabel": "{{resource}} {{action}}", "registryError": "İzin listesi yüklenemedi. Sayfayı yenilemeyi deneyin.", - "registryEmpty": "Henüz atanabilecek izin yok." + "registryEmpty": "Henüz atanabilecek izin yok.", + "lookupsWarning": "Bu rol modülleri yapılandırabilir ancak kanalları, rolleri veya üyeleri arayamaz — seçicileri boş kalacaktır.", + "grantLookups": "Seçici erişimi ver", + "admissionNote": "Ayrıca sahiplerinin diğer üyelere pano erişimi vermesine de olanak tanır." }, "roleForm": { "name": "Rol Adı", diff --git a/packages/i18n/src/locales/uk/permissions.json b/packages/i18n/src/locales/uk/permissions.json index 84364e18..b6fc68b0 100644 --- a/packages/i18n/src/locales/uk/permissions.json +++ b/packages/i18n/src/locales/uk/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Ролі Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Не вдалося завантажити список дозволів. Спробуйте оновити сторінку.", - "registryEmpty": "Поки що немає дозволів, доступних для призначення." + "registryEmpty": "Поки що немає дозволів, доступних для призначення.", + "lookupsWarning": "Ця роль може налаштовувати модулі, але не може шукати канали, ролі чи учасників — її списки вибору будуть порожніми.", + "grantLookups": "Надати доступ до списків вибору", + "admissionNote": "Також дозволяє власникам надавати іншим учасникам доступ до панелі керування." }, "roleForm": { "name": "Назва ролі", diff --git a/packages/i18n/src/locales/ur/permissions.json b/packages/i18n/src/locales/ur/permissions.json index e13c8815..576904d3 100644 --- a/packages/i18n/src/locales/ur/permissions.json +++ b/packages/i18n/src/locales/ur/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord رولز", "permissionLabel": "{{resource}} {{action}}", "registryError": "اجازتوں کی فہرست لوڈ نہیں ہو سکی۔ صفحہ ریفریش کرنے کی کوشش کریں۔", - "registryEmpty": "ابھی تفویض کرنے کے لیے کوئی اجازت دستیاب نہیں ہے۔" + "registryEmpty": "ابھی تفویض کرنے کے لیے کوئی اجازت دستیاب نہیں ہے۔", + "lookupsWarning": "یہ کردار ماڈیولز کنفیگر کر سکتا ہے لیکن چینلز، کردار، یا اراکین تلاش نہیں کر سکتا — اس کے پکرز خالی ہوں گے۔", + "grantLookups": "پکر تک رسائی دیں", + "admissionNote": "یہ حاملین کو دوسرے اراکین کو ڈیش بورڈ تک رسائی دینے کی بھی اجازت دیتا ہے۔" }, "roleForm": { "name": "رول کا نام", diff --git a/packages/i18n/src/locales/vi/permissions.json b/packages/i18n/src/locales/vi/permissions.json index 530d8539..8f81d7b5 100644 --- a/packages/i18n/src/locales/vi/permissions.json +++ b/packages/i18n/src/locales/vi/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Vai Trò Discord", "permissionLabel": "{{action}} {{resource}}", "registryError": "Không thể tải danh sách quyền. Hãy thử làm mới trang.", - "registryEmpty": "Hiện chưa có quyền nào khả dụng để gán." + "registryEmpty": "Hiện chưa có quyền nào khả dụng để gán.", + "lookupsWarning": "Vai trò này có thể cấu hình các mô-đun nhưng không thể tra cứu kênh, vai trò hoặc thành viên — các bộ chọn của nó sẽ trống.", + "grantLookups": "Cấp quyền truy cập bộ chọn", + "admissionNote": "Cũng cho phép người giữ vai trò cấp quyền truy cập bảng điều khiển cho các thành viên khác." }, "roleForm": { "name": "Tên Vai Trò", diff --git a/packages/i18n/src/locales/zh-CN/permissions.json b/packages/i18n/src/locales/zh-CN/permissions.json index 2d7ba0ff..c8ef368c 100644 --- a/packages/i18n/src/locales/zh-CN/permissions.json +++ b/packages/i18n/src/locales/zh-CN/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord 角色", "permissionLabel": "{{action}}{{resource}}", "registryError": "无法加载权限列表。请尝试刷新页面。", - "registryEmpty": "暂无可分配的权限。" + "registryEmpty": "暂无可分配的权限。", + "lookupsWarning": "此角色可以配置模块,但无法查找频道、角色或成员 — 其选择器将为空。", + "grantLookups": "授予选择器访问权限", + "admissionNote": "还允许持有者授予其他成员仪表盘访问权限。" }, "roleForm": { "name": "角色名称", diff --git a/packages/i18n/src/locales/zh-TW/permissions.json b/packages/i18n/src/locales/zh-TW/permissions.json index 27d46e2e..628414c5 100644 --- a/packages/i18n/src/locales/zh-TW/permissions.json +++ b/packages/i18n/src/locales/zh-TW/permissions.json @@ -44,7 +44,10 @@ "discordRoles": "Discord 角色", "permissionLabel": "{{action}}{{resource}}", "registryError": "無法載入權限清單。請嘗試重新整理頁面。", - "registryEmpty": "目前沒有可指派的權限。" + "registryEmpty": "目前沒有可指派的權限。", + "lookupsWarning": "此身分組可以設定模組,但無法查詢頻道、身分組或成員 — 其選擇器將是空的。", + "grantLookups": "授予選擇器存取權", + "admissionNote": "也允許持有者授予其他成員儀表板存取權。" }, "roleForm": { "name": "角色名稱", From 0dcae31d15cf1e365f7d20b321722eb9fdd3ac27 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 21:35:19 +0300 Subject: [PATCH 26/36] fix(permissions): warning variant styling + render coverage for lookups warning Alert was missing variant="warning", so the container fell back to the neutral default style with only the icon signaling severity. Also add render tests for the warning Alert, the wildcard-covers-lookups case (proves matchPermission, not Set.has, drives the check), the Grant picker access button actually clearing the warning, and the dashboard.roles.manage admission note appearing/not appearing. Co-Authored-By: Claude Opus 5 (1M context) --- .../routes/guild/$guildId/permissions.tsx | 2 +- .../guild/$guildId/permissions.test.tsx | 124 +++++++++++++++++- 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx index 406cb771..37cba76d 100644 --- a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx +++ b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx @@ -343,7 +343,7 @@ function RoleEditor({
{needsLookups && ( - +
{t("roleEditor.lookupsWarning")} diff --git a/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx index f393e4fc..c5ec3c0d 100644 --- a/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx +++ b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeAll } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within, fireEvent } from "@testing-library/react"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -34,7 +34,22 @@ const registryState = vi.hoisted((): { box: RegistryQueryState } => ({ }, })); -const ROLE = { +// Mirrors registryState: a mutable box so individual tests can drive which +// permissions the selected role starts with, since RoleEditor seeds its +// local permissions Set straight from role.permissions on mount. +interface RoleFixture { + id: string; + name: string; + color: string; + position: number; + isDefault: boolean; + permissions: string[]; + memberCount: number; + createdAt: string; + updatedAt: string; +} + +const ROLE_BASE: RoleFixture = { id: "role-1", name: "Moderators", color: "#a3a6ff", @@ -46,6 +61,20 @@ const ROLE = { updatedAt: "2026-01-01T00:00:00.000Z", }; +const roleState = vi.hoisted((): { box: RoleFixture } => ({ + box: { + id: "role-1", + name: "Moderators", + color: "#a3a6ff", + position: 0, + isDefault: false, + permissions: [], + memberCount: 2, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, +})); + vi.mock("../../../../../src/client/features/permissions/hooks/usePermissions", async (importOriginal) => { // matchPermission is kept real (rather than re-stubbed) so RoleEditor's // lookups-warning check — which imports it via lookupsWarning.ts — behaves @@ -55,7 +84,7 @@ vi.mock("../../../../../src/client/features/permissions/hooks/usePermissions", a return { matchPermission: actual.matchPermission, usePermissions: () => ({ isOwner: true, isLoading: false }), - useDashboardRoles: () => ({ data: [ROLE], isLoading: false }), + useDashboardRoles: () => ({ data: [roleState.box], isLoading: false }), useCreateDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), useUpdateDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), useDeleteDashboardRole: () => ({ mutate: vi.fn(), isPending: false }), @@ -143,3 +172,92 @@ describe("PermissionsPage — permission registry loading/error/empty states", ( expect(screen.queryByTestId("permission-registry-loading")).not.toBeInTheDocument(); }); }); + +const REGISTRY_WITH_DASHBOARD = [ + ...REGISTRY, + { + key: "dashboard", + icon: "Shield", + labelKey: "permissions:permissionCategories.dashboard", + permissions: [ + { + key: "dashboard.roles.manage", + resourceKey: "permissions:resources.roles", + actionKey: "permissions:permissionActions.manage", + }, + { + key: "dashboard.lookups.view", + resourceKey: "permissions:resources.lookups", + actionKey: "permissions:permissionActions.view", + }, + ], + }, +]; + +describe("PermissionsPage — lookups warning and admission note", () => { + it("warns when the role can manage something but cannot use pickers", async () => { + roleState.box = { ...ROLE_BASE, permissions: ["dashboard.roles.manage"] }; + registryState.box = { data: REGISTRY_WITH_DASHBOARD, isLoading: false, isError: false }; + render(); + + const warning = await screen.findByTestId("lookups-warning"); + expect(warning).toBeInTheDocument(); + expect(warning).toHaveTextContent("roleEditor.lookupsWarning"); + }); + + it("stays quiet once the role also holds dashboard.lookups.view directly", async () => { + roleState.box = { + ...ROLE_BASE, + permissions: ["dashboard.roles.manage", "dashboard.lookups.view"], + }; + registryState.box = { data: REGISTRY_WITH_DASHBOARD, isLoading: false, isError: false }; + render(); + + // Anchor on the loaded grid so the assertion below isn't racing the fetch. + await screen.findByText("permissions:permissionCategories.dashboard"); + expect(screen.queryByTestId("lookups-warning")).not.toBeInTheDocument(); + }); + + it("stays quiet when a dashboard.* wildcard already covers lookups", async () => { + // Proves the check goes through matchPermission's wildcard handling + // rather than a plain Set.has("dashboard.lookups.view"). + roleState.box = { ...ROLE_BASE, permissions: ["dashboard.*"] }; + registryState.box = { data: REGISTRY_WITH_DASHBOARD, isLoading: false, isError: false }; + render(); + + await screen.findByText("permissions:permissionCategories.dashboard"); + expect(screen.queryByTestId("lookups-warning")).not.toBeInTheDocument(); + }); + + it("clears the warning once Grant picker access is clicked", async () => { + roleState.box = { ...ROLE_BASE, permissions: ["dashboard.roles.manage"] }; + registryState.box = { data: REGISTRY_WITH_DASHBOARD, isLoading: false, isError: false }; + render(); + + expect(await screen.findByTestId("lookups-warning")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("roleEditor.grantLookups")); + + expect(screen.queryByTestId("lookups-warning")).not.toBeInTheDocument(); + }); + + it("renders the admission note beside dashboard.roles.manage when it is offered", async () => { + roleState.box = { ...ROLE_BASE, permissions: ["dashboard.lookups.view"] }; + registryState.box = { data: REGISTRY_WITH_DASHBOARD, isLoading: false, isError: false }; + render(); + + const permKey = await screen.findByText("dashboard.roles.manage"); + const row = permKey.parentElement; + if (!row) throw new Error("expected dashboard.roles.manage to render inside a container element"); + expect(within(row).getByText("roleEditor.admissionNote")).toBeInTheDocument(); + }); + + it("does not render the admission note when dashboard.roles.manage is not offered", async () => { + roleState.box = { ...ROLE_BASE, permissions: [] }; + registryState.box = { data: REGISTRY, isLoading: false, isError: false }; + render(); + + await screen.findByText("permissions:permissionCategories.tickets"); + expect(screen.queryByText("roleEditor.admissionNote")).not.toBeInTheDocument(); + }); +}); From 5f2f5e8f8b35baf821e90d6a422e429169dde278 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 22:11:15 +0300 Subject: [PATCH 27/36] test(permissions): cover delegated grant resolution against the real DB Calls the real resolveUserPermissions() against a real Postgres test DB, mocking only the Discord API layer (owner/admin/member/non-member), so the tests fail if delegated resolution breaks rather than only asserting Prisma round-trips. Covers: a non-admin role assignment resolving to exactly that role's permissions; identical resolution regardless of requirePermissions for non-admins; per-user override merging; isDefault roles applying only to admins under requirePermissions; non-members with stale grant rows resolving empty; and cache invalidation after an assignment is removed. --- .../dashboard-delegated-access.test.ts | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 packages/systems/tests/integration/dashboard-delegated-access.test.ts diff --git a/packages/systems/tests/integration/dashboard-delegated-access.test.ts b/packages/systems/tests/integration/dashboard-delegated-access.test.ts new file mode 100644 index 00000000..f187bf18 --- /dev/null +++ b/packages/systems/tests/integration/dashboard-delegated-access.test.ts @@ -0,0 +1,275 @@ +/** + * Integration tests: delegated dashboard access end to end. + * + * Exercises the real `resolveUserPermissions()` (the single function the + * whole "dashboard access without MANAGE_GUILD" feature turns on) against a + * REAL PostgreSQL test database. Only the Discord API layer is mocked — + * `getGuildOwnerId` / `getGuildMember` / `getGuildRoles` — so we can drive + * owner / admin / plain-member / non-member without hitting Discord. Every + * DB read (roles, assignments, per-user overrides, guild settings) is real. + * + * These tests fail if delegated resolution breaks — unlike a test that only + * creates rows and reads them back through Prisma, which would pass even if + * `resolveUserPermissions` never consulted them. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest"; +import { getPrisma } from "@fluxcore/database"; +import { setupTestDatabase, teardownTestDatabase } from "../helpers/db.js"; + +const MANAGE_GUILD = BigInt(0x20); + +// Mock only the Discord API layer. `resolveUserPermissions` -> `getGuildAuthority` +// -> these three functions. Everything else (Prisma, the DB) stays real. +const mockGetGuildOwnerId = vi.fn(); +const mockGetGuildMember = vi.fn(); +const mockGetGuildRoles = vi.fn(); + +vi.mock("../../../../apps/dashboard/src/server/shared/discordApi.js", () => ({ + getGuildOwnerId: (...args: unknown[]) => mockGetGuildOwnerId(...args), + getGuildMember: (...args: unknown[]) => mockGetGuildMember(...args), + getGuildRoles: (...args: unknown[]) => mockGetGuildRoles(...args), +})); + +// Dynamic import so this module (and its transitive import of discordApi.js) +// resolves AFTER the mock above and after the const declarations it closes +// over — a static top-of-file import would be hoisted above the `const`s and +// throw a TDZ ReferenceError when the mock factory runs (vitest 4 gotcha). +const { resolveUserPermissions, invalidatePermissionCache } = await import( + "../../../../apps/dashboard/src/server/shared/permissions.js" +); + +/** A plain member with no admin-granting role bits, in a guild with no other roles. */ +function mockPlainMember(guildId: string): void { + mockGetGuildOwnerId.mockResolvedValue("owner-of-" + guildId); + mockGetGuildMember.mockResolvedValue({ roles: [] }); + mockGetGuildRoles.mockResolvedValue([ + { id: guildId, name: "@everyone", color: 0, permissions: "0" }, + ]); +} + +/** A member whose role carries Manage Server, in a guild with no other roles. */ +function mockAdminMember(guildId: string): void { + mockGetGuildOwnerId.mockResolvedValue("owner-of-" + guildId); + mockGetGuildMember.mockResolvedValue({ roles: ["role-admin"] }); + mockGetGuildRoles.mockResolvedValue([ + { id: guildId, name: "@everyone", color: 0, permissions: "0" }, + { id: "role-admin", name: "Admin", color: 0, permissions: MANAGE_GUILD.toString() }, + ]); +} + +/** A user who has left the guild (or was never in it). */ +function mockNonMember(guildId: string): void { + mockGetGuildOwnerId.mockResolvedValue("owner-of-" + guildId); + mockGetGuildMember.mockResolvedValue(null); + mockGetGuildRoles.mockResolvedValue([]); +} + +async function cleanDashboardTables(): Promise { + const prisma = getPrisma(); + await prisma.$executeRawUnsafe(` + TRUNCATE TABLE + "DashboardAuditLog", + "DashboardRoleAssignment", + "DashboardUserPermission", + "DashboardRole", + "DashboardGuildSettings" + CASCADE + `); +} + +describe("delegated dashboard access — resolveUserPermissions", () => { + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + await cleanDashboardTables(); + }); + + afterAll(async () => { + await teardownTestDatabase(); + }); + + it("resolves a non-admin member with a dashboard role assignment to exactly that role's permissions", async () => { + const prisma = getPrisma(); + const guildId = "dda-role-assign"; + const userId = "user-1"; + mockPlainMember(guildId); + + const role = await prisma.dashboardRole.create({ + data: { + guildId, + name: "Ticket Staff", + permissions: JSON.stringify(["tickets.list.view", "tickets.list.manage"]), + }, + }); + await prisma.dashboardRoleAssignment.create({ + data: { guildId, userId, roleId: role.id, assignedBy: "owner" }, + }); + + const resolved = await resolveUserPermissions(userId, guildId); + + expect(resolved.permissions).toEqual( + new Set(["tickets.list.view", "tickets.list.manage"]), + ); + expect(resolved.isOwner).toBe(false); + expect(resolved.isGuildAdmin).toBe(false); + expect(resolved.isGuildMember).toBe(true); + }); + + it("resolves a non-admin member's role assignment identically whether requirePermissions is true or false", async () => { + const prisma = getPrisma(); + const userId = "user-2"; + const guildOff = "dda-toggle-off"; + const guildOn = "dda-toggle-on"; + + for (const guildId of [guildOff, guildOn]) { + const role = await prisma.dashboardRole.create({ + data: { + guildId, + name: "Support", + permissions: JSON.stringify(["logging.entries.view"]), + }, + }); + await prisma.dashboardRoleAssignment.create({ + data: { guildId, userId, roleId: role.id, assignedBy: "owner" }, + }); + } + await prisma.dashboardGuildSettings.create({ + data: { guildId: guildOff, requirePermissions: false }, + }); + await prisma.dashboardGuildSettings.create({ + data: { guildId: guildOn, requirePermissions: true }, + }); + + mockPlainMember(guildOff); + const resolvedOff = await resolveUserPermissions(userId, guildOff); + + mockPlainMember(guildOn); + const resolvedOn = await resolveUserPermissions(userId, guildOn); + + const expected = new Set(["logging.entries.view"]); + expect(resolvedOff.permissions).toEqual(expected); + expect(resolvedOn.permissions).toEqual(expected); + expect(resolvedOff.isGuildAdmin).toBe(false); + expect(resolvedOn.isGuildAdmin).toBe(false); + }); + + it("merges a per-user permission override in with the role's permissions", async () => { + const prisma = getPrisma(); + const guildId = "dda-user-override"; + const userId = "user-3"; + mockPlainMember(guildId); + + const role = await prisma.dashboardRole.create({ + data: { guildId, name: "Support", permissions: JSON.stringify(["logging.entries.view"]) }, + }); + await prisma.dashboardRoleAssignment.create({ + data: { guildId, userId, roleId: role.id, assignedBy: "owner" }, + }); + await prisma.dashboardUserPermission.create({ + data: { guildId, userId, permission: "tickets.list.manage", grantedBy: "owner" }, + }); + + const resolved = await resolveUserPermissions(userId, guildId); + + expect(resolved.permissions).toEqual( + new Set(["logging.entries.view", "tickets.list.manage"]), + ); + }); + + it("does not apply an isDefault role to a non-admin member", async () => { + const prisma = getPrisma(); + const guildId = "dda-default-non-admin"; + const userId = "user-4"; + mockPlainMember(guildId); + + await prisma.dashboardRole.create({ + data: { + guildId, + name: "Baseline", + isDefault: true, + permissions: JSON.stringify(["logging.entries.view"]), + }, + }); + // No assignment for this user — only the isDefault role exists. + + const resolved = await resolveUserPermissions(userId, guildId); + + expect(resolved.permissions).toEqual(new Set()); + expect(resolved.isGuildAdmin).toBe(false); + expect(resolved.isGuildMember).toBe(true); + }); + + it("applies an isDefault role to an admin when requirePermissions is true", async () => { + const prisma = getPrisma(); + const guildId = "dda-default-admin"; + const userId = "user-5"; + mockAdminMember(guildId); + + await prisma.dashboardGuildSettings.create({ + data: { guildId, requirePermissions: true }, + }); + await prisma.dashboardRole.create({ + data: { + guildId, + name: "Baseline", + isDefault: true, + permissions: JSON.stringify(["logging.entries.view"]), + }, + }); + + const resolved = await resolveUserPermissions(userId, guildId); + + expect(resolved.permissions).toEqual(new Set(["logging.entries.view"])); + expect(resolved.isGuildAdmin).toBe(true); + expect(resolved.isOwner).toBe(false); + }); + + it("resolves a non-member to an empty permission set even with a stale grant row", async () => { + const prisma = getPrisma(); + const guildId = "dda-non-member"; + const userId = "user-6"; + + const role = await prisma.dashboardRole.create({ + data: { guildId, name: "Ghost Grant", permissions: JSON.stringify(["tickets.list.view"]) }, + }); + await prisma.dashboardRoleAssignment.create({ + data: { guildId, userId, roleId: role.id, assignedBy: "owner" }, + }); + + mockNonMember(guildId); + + const resolved = await resolveUserPermissions(userId, guildId); + + expect(resolved.permissions).toEqual(new Set()); + expect(resolved.isGuildMember).toBe(false); + expect(resolved.isGuildAdmin).toBe(false); + expect(resolved.isOwner).toBe(false); + }); + + it("resolves to an empty set after the assignment is removed and the cache is invalidated", async () => { + const prisma = getPrisma(); + const guildId = "dda-cache-invalidate"; + const userId = "user-7"; + mockPlainMember(guildId); + + const role = await prisma.dashboardRole.create({ + data: { guildId, name: "Temp Staff", permissions: JSON.stringify(["tickets.list.view"]) }, + }); + await prisma.dashboardRoleAssignment.create({ + data: { guildId, userId, roleId: role.id, assignedBy: "owner" }, + }); + + const before = await resolveUserPermissions(userId, guildId); + expect(before.permissions).toEqual(new Set(["tickets.list.view"])); + + await prisma.dashboardRoleAssignment.deleteMany({ where: { guildId, userId } }); + invalidatePermissionCache(guildId, userId); + + const after = await resolveUserPermissions(userId, guildId); + expect(after.permissions).toEqual(new Set()); + }); +}); From 24c0adb5006881f8ef0bff8ade939d74947886ea Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 22:19:49 +0300 Subject: [PATCH 28/36] docs(permissions): record delegated access in the feature spec The original gate model said MANAGE_GUILD was required to reach the dashboard at all. Authority now comes from ownership, live Discord admin rights, or an explicit dashboard grant. Co-Authored-By: Claude Opus 5 (1M context) --- docs/features/dashboard-permissions.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/features/dashboard-permissions.md b/docs/features/dashboard-permissions.md index 2cf307ad..d1f2e028 100644 --- a/docs/features/dashboard-permissions.md +++ b/docs/features/dashboard-permissions.md @@ -9,7 +9,12 @@ Granular role-based + per-user permission system for the admin dashboard. Currently, any user with Discord's `MANAGE_GUILD` permission has **full access** to every dashboard feature. This system adds fine-grained control so guild owners can delegate specific modules/actions to specific administrators. -**Gate model:** `MANAGE_GUILD` remains the entry gate — only users with that Discord permission can access the dashboard at all. The permission system adds granularity *within* that gate. +**Gate model:** authority comes from three sources — guild ownership, live Discord admin rights (Administrator or Manage Server), or an explicit dashboard grant (a `DashboardRoleAssignment` or `DashboardUserPermission` row). A guild member holding a grant reaches the dashboard without `MANAGE_GUILD` and is then narrowed by each route's required permission. + +`requirePermissions` governs whether **admins** are constrained; it never gates explicit grants, which resolve the same way in both modes. `isDefault` roles apply to admins only — otherwise enabling the toggle would admit every member of the server at once. + +> Superseded 2026-07-28. This replaces the original admin-only gate model. See +> `docs/superpowers/specs/2026-07-28-delegated-dashboard-access-design.md`. ## Design Decisions @@ -18,7 +23,7 @@ Granular role-based + per-user permission system for the admin dashboard. Curren | Permission format | String keys (`module.resource.action`) | Self-documenting, unlimited scalability, easy to add new modules | | Deny rules | No (allow-only) | MANAGE_GUILD already gates entry; simpler mental model | | Per-user overrides | Yes | Guild owner can grant specific permissions to individuals beyond their roles | -| Non-MANAGE_GUILD access | No | Dashboard remains admin-only; permissions control what admins can do | +| Non-MANAGE_GUILD access | Yes, via explicit grants (revised 2026-07-28) | Delegation is inert if only Discord admins can reach the dashboard. A member with a role assignment or user override gets in; `isDefault` roles never apply to them | | Built-in presets | Yes | Ship "Moderator", "Content Manager" templates | | Audit retention | 90 days default, configurable per guild | Balance storage vs compliance needs | | Wildcard support | Yes (`module.*`, `*`) | Reduces assignment burden for broad access | From bbdcdd52fef1de89056a0ce28b023c9205b66af6 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 22:32:52 +0300 Subject: [PATCH 29/36] fix(permissions): gate isDefault promotion behind the escalation guard PUT /api/guilds/:guildId/dashboard-roles/:roleId applied `isDefault` unconditionally while the escalation check only ran inside `if (body.permissions)`. A delegated `dashboard.roles.manage` holder could send `{"isDefault": true}` alone to promote any existing role (e.g. one holding `*`) to the guild default, granting its permissions to every live Discord admin with no permissions field in the request to catch. Promoting a role to default is equivalent to granting everyone its permissions, so run the same matchPermission loop used by the other four guards in this file, but against the role's existing persisted permissions (parsed with safeParsePermissions) since the request body may not include `permissions` at all. Demotion (isDefault: false) cannot escalate and stays unguarded. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/permissions/roles-routes.ts | 18 +++ .../permissions/dashboardRoles.test.ts | 138 ++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/apps/dashboard/src/server/features/permissions/roles-routes.ts b/apps/dashboard/src/server/features/permissions/roles-routes.ts index b7cc64a1..f3f063af 100644 --- a/apps/dashboard/src/server/features/permissions/roles-routes.ts +++ b/apps/dashboard/src/server/features/permissions/roles-routes.ts @@ -277,6 +277,24 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void { } } + // Promoting a role to the guild default grants its permissions to every + // live Discord admin, so it is equivalent to granting those permissions + // to the caller: gate it the same way, against the role's *existing* + // persisted permissions (the request body may not even touch `permissions`). + if (body.isDefault === true && !request.resolvedPermissions?.isOwner) { + const userPerms = request.resolvedPermissions!.permissions; + const existingPerms = safeParsePermissions(existing.permissions); + for (const perm of existingPerms) { + if (!matchPermission(userPerms, perm)) { + reply.code(403).send({ + error: "Cannot make a role default unless you hold all of its permissions", + permission: perm, + }); + return; + } + } + } + const update: Record = {}; if (body.name !== undefined) update.name = body.name.trim(); if (body.color !== undefined) update.color = body.color; diff --git a/apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts b/apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts index e8634bc4..e80a9bf3 100644 --- a/apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts +++ b/apps/dashboard/tests/server/features/permissions/dashboardRoles.test.ts @@ -254,6 +254,144 @@ describe("dashboard role routes", () => { }); }); + describe("PUT /api/guilds/:guildId/dashboard-roles/:roleId", () => { + it("refuses to promote a role to default when the caller lacks the role's permissions", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage", "tickets.list.view"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + mockPrisma.dashboardRole.findUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Full Admin", + permissions: JSON.stringify(["*"]), + isDefault: false, + }); + + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/dashboard-roles/role-1", + cookies: { session: app.signCookie("valid") }, + payload: { isDefault: true }, + }); + + expect(res.statusCode).toBe(403); + expect(mockPrisma.dashboardRole.update).not.toHaveBeenCalled(); + }); + + it("lets the owner promote any role to default", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + isGuildAdmin: true, + isGuildMember: true, + }); + mockPrisma.dashboardRole.findUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Full Admin", + permissions: JSON.stringify(["*"]), + isDefault: false, + }); + mockPrisma.dashboardRole.update.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Full Admin", + color: null, + position: 1, + isDefault: true, + permissions: JSON.stringify(["*"]), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/dashboard-roles/role-1", + cookies: { session: app.signCookie("valid") }, + payload: { isDefault: true }, + }); + + expect(res.statusCode).toBe(200); + expect(mockPrisma.dashboardRole.update).toHaveBeenCalled(); + }); + + it("lets a caller holding the role's permissions promote it to default", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage", "tickets.*"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + mockPrisma.dashboardRole.findUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Ticket Staff", + permissions: JSON.stringify(["tickets.list.view"]), + isDefault: false, + }); + mockPrisma.dashboardRole.update.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Ticket Staff", + color: null, + position: 1, + isDefault: true, + permissions: JSON.stringify(["tickets.list.view"]), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/dashboard-roles/role-1", + cookies: { session: app.signCookie("valid") }, + payload: { isDefault: true }, + }); + + expect(res.statusCode).toBe(200); + }); + + it("does not block demoting a role from default (isDefault: false)", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage", "tickets.list.view"]), + isOwner: false, + isGuildAdmin: false, + isGuildMember: true, + }); + mockPrisma.dashboardRole.findUnique.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Full Admin", + permissions: JSON.stringify(["*"]), + isDefault: true, + }); + mockPrisma.dashboardRole.update.mockResolvedValue({ + id: "role-1", + guildId: "guild-1", + name: "Full Admin", + color: null, + position: 1, + isDefault: false, + permissions: JSON.stringify(["*"]), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/dashboard-roles/role-1", + cookies: { session: app.signCookie("valid") }, + payload: { isDefault: false }, + }); + + expect(res.statusCode).toBe(200); + expect(mockPrisma.dashboardRole.update).toHaveBeenCalled(); + }); + }); + describe("POST /api/guilds/:guildId/dashboard-roles/:roleId/members", () => { it("refuses to assign a role holding permissions the caller lacks", async () => { mockResolveUserPermissions.mockResolvedValue({ From e34f756e99d3d58a7b53bed1ccd0d870be230b60 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Tue, 28 Jul 2026 23:33:46 +0300 Subject: [PATCH 30/36] fix(permissions): stop asserting security posture on 403; disable ungrantable checkboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related permissions-page fixes surfaced in final review: - useDashboardSettings requires dashboard.settings.manage, but the page itself is reachable with just dashboard.roles.view (e.g. the built-in Viewer preset). On a 403 there, `settings` was undefined and the code silently fell back to requirePermissions = false, rendering "The permission system is disabled... all admins have full access" on a guild where it is actually enabled — a false statement about the guild's security posture shown to the exact user class this branch admits. The status card and disabled banner now render only once the settings fetch actually succeeds; nothing is guessed. The Create Role button and Audit Log tab also rendered unconditionally and 403'd on use — both now gate on can("dashboard.roles.manage") / can("dashboard.audit.view"). - The role editor's permission grid only ever disabled checkboxes for hasWildcard; a delegated user could tick a box for a permission they don't hold, save, and get a raw untranslated server error. Each checkbox is now also disabled when the current user is not the owner and doesn't hold that permission themselves (via the client matchPermission against usePermissions(guildId)'s own resolved set), with a tooltip explaining why. New tooltip copy (roleEditor.cannotGrantTooltip) is translated in all 48 locales; sr stays Latin-script per convention. Co-Authored-By: Claude Opus 5 (1M context) --- .../routes/guild/$guildId/permissions.tsx | 135 +++++++---- .../guild/$guildId/permissions.test.tsx | 221 ++++++++++++++++-- packages/i18n/src/locales/af/permissions.json | 3 +- packages/i18n/src/locales/ar/permissions.json | 3 +- packages/i18n/src/locales/bg/permissions.json | 3 +- packages/i18n/src/locales/bn/permissions.json | 3 +- packages/i18n/src/locales/ca/permissions.json | 3 +- packages/i18n/src/locales/cs/permissions.json | 3 +- packages/i18n/src/locales/da/permissions.json | 3 +- packages/i18n/src/locales/de/permissions.json | 3 +- packages/i18n/src/locales/el/permissions.json | 3 +- packages/i18n/src/locales/en/permissions.json | 3 +- packages/i18n/src/locales/es/permissions.json | 3 +- packages/i18n/src/locales/et/permissions.json | 3 +- packages/i18n/src/locales/eu/permissions.json | 3 +- packages/i18n/src/locales/fa/permissions.json | 3 +- packages/i18n/src/locales/fi/permissions.json | 3 +- .../i18n/src/locales/fil/permissions.json | 3 +- packages/i18n/src/locales/fr/permissions.json | 3 +- packages/i18n/src/locales/gl/permissions.json | 3 +- packages/i18n/src/locales/he/permissions.json | 3 +- packages/i18n/src/locales/hi/permissions.json | 3 +- packages/i18n/src/locales/hr/permissions.json | 3 +- packages/i18n/src/locales/hu/permissions.json | 3 +- packages/i18n/src/locales/id/permissions.json | 3 +- packages/i18n/src/locales/it/permissions.json | 3 +- packages/i18n/src/locales/ja/permissions.json | 3 +- packages/i18n/src/locales/ko/permissions.json | 3 +- packages/i18n/src/locales/lt/permissions.json | 3 +- packages/i18n/src/locales/lv/permissions.json | 3 +- packages/i18n/src/locales/ms/permissions.json | 3 +- packages/i18n/src/locales/nl/permissions.json | 3 +- packages/i18n/src/locales/no/permissions.json | 3 +- packages/i18n/src/locales/pl/permissions.json | 3 +- packages/i18n/src/locales/pt/permissions.json | 3 +- packages/i18n/src/locales/ro/permissions.json | 3 +- packages/i18n/src/locales/ru/permissions.json | 3 +- packages/i18n/src/locales/sk/permissions.json | 3 +- packages/i18n/src/locales/sl/permissions.json | 3 +- packages/i18n/src/locales/sr/permissions.json | 3 +- packages/i18n/src/locales/sv/permissions.json | 3 +- packages/i18n/src/locales/sw/permissions.json | 3 +- packages/i18n/src/locales/ta/permissions.json | 3 +- packages/i18n/src/locales/th/permissions.json | 3 +- packages/i18n/src/locales/tr/permissions.json | 3 +- packages/i18n/src/locales/uk/permissions.json | 3 +- packages/i18n/src/locales/ur/permissions.json | 3 +- packages/i18n/src/locales/vi/permissions.json | 3 +- .../i18n/src/locales/zh-CN/permissions.json | 3 +- .../i18n/src/locales/zh-TW/permissions.json | 3 +- 50 files changed, 390 insertions(+), 110 deletions(-) diff --git a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx index 37cba76d..87b2d53c 100644 --- a/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx +++ b/apps/dashboard/src/client/routes/guild/$guildId/permissions.tsx @@ -35,6 +35,7 @@ import { } from "../../../shared/ui/dialog"; import { usePermissions, + matchPermission, useDashboardRoles, useCreateDashboardRole, useUpdateDashboardRole, @@ -45,6 +46,11 @@ import { useDashboardAuditLog, usePermissionRegistry, } from "../../../features/permissions/hooks/usePermissions"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "../../../shared/ui/tooltip"; import { needsLookupsPermission } from "../../../features/permissions/lookupsWarning"; import type { DashboardRole } from "../../../shared/lib/schemas"; import { ROLE_PRESETS } from "@fluxcore/types"; @@ -54,10 +60,12 @@ import { ROLE_PRESETS } from "@fluxcore/types"; export function PermissionsPage() { const { guildId } = useParams({ from: "/guild/$guildId" }); const { t } = useTranslation("permissions"); - const { isOwner, isLoading: permLoading } = usePermissions(guildId); + const { isOwner, can, isLoading: permLoading } = usePermissions(guildId); const { data: roles, isLoading: rolesLoading } = useDashboardRoles(guildId); const { data: settings, isLoading: settingsLoading } = useDashboardSettings(guildId); const updateSettings = useUpdateDashboardSettings(guildId); + const canManageRoles = can("dashboard.roles.manage"); + const canViewAudit = can("dashboard.audit.view"); const [selectedRoleId, setSelectedRoleId] = useState(null); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -76,6 +84,13 @@ export function PermissionsPage() { if (permLoading || rolesLoading || settingsLoading) return ; + // `settings` requires dashboard.settings.manage, which the nav gate + // (dashboard.roles.view) does not guarantee — e.g. the built-in Viewer + // preset. When the fetch 403s, `settings` is undefined; don't guess at + // requirePermissions in that case, since the "system disabled" banner is a + // security claim about the guild and rendering it here would be false for + // guilds where it's actually enabled. + const settingsLoaded = settings !== undefined; const requirePermissions = settings?.requirePermissions ?? false; return ( @@ -84,46 +99,50 @@ export function PermissionsPage() { title={t("title")} subtitle={t("subtitle")} actions={ - + canManageRoles ? ( + + ) : undefined } /> {/* Enable/Disable Toggle */} - - -
-

{t("permissionSystem.title")}

-

- {requirePermissions - ? t("permissionSystem.active") - : t("permissionSystem.inactive")} -

-
- { - updateSettings.mutate( - { requirePermissions: checked }, - { - onSuccess: () => - toast.success( - checked - ? t("permissionSystem.enabledToast") - : t("permissionSystem.disabledToast"), - ), - onError: (err) => toast.error(err.message), - }, - ); - }} - /> -
-
+ {settingsLoaded && ( + + +
+

{t("permissionSystem.title")}

+

+ {requirePermissions + ? t("permissionSystem.active") + : t("permissionSystem.inactive")} +

+
+ { + updateSettings.mutate( + { requirePermissions: checked }, + { + onSuccess: () => + toast.success( + checked + ? t("permissionSystem.enabledToast") + : t("permissionSystem.disabledToast"), + ), + onError: (err) => toast.error(err.message), + }, + ); + }} + /> +
+
+ )} - {!requirePermissions && ( + {settingsLoaded && !requirePermissions && (
{t("warning.disabled")} @@ -133,7 +152,7 @@ export function PermissionsPage() { {t("tabs.roles")} - {t("tabs.auditLog")} + {canViewAudit && {t("tabs.auditLog")}} @@ -193,9 +212,11 @@ export function PermissionsPage() {
- - - + {canViewAudit && ( + + + + )} void; }) { const { t } = useTranslation("permissions"); + const { isOwner: currentUserIsOwner, permissions: myPermissions } = usePermissions(guildId); + const myPermissionSet = useMemo(() => new Set(myPermissions), [myPermissions]); const { data: registry = [], isLoading: registryLoading, isError: registryError } = usePermissionRegistry(guildId); const updateRole = useUpdateDashboardRole(guildId); const deleteRole = useDeleteDashboardRole(guildId); @@ -415,22 +438,40 @@ function RoleEditor({
{mod.permissions.map((perm) => { const checked = hasWildcard || permissions.has(perm.key); + const cannotGrant = + !currentUserIsOwner && !matchPermission(myPermissionSet, perm.key); const permLabel = t("roleEditor.permissionLabel", { action: t(perm.actionKey), resource: t(perm.resourceKey), }); + const checkbox = ( + togglePermission(perm.key)} + className="mt-0.5" + aria-label={`${role.name} — ${permLabel}`} + /> + ); return (