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..3a128cff --- /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")}

+ ) : ( + + )} +
+ ); +} 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/features/permissions/components/RoleMemberPicker.tsx b/apps/dashboard/src/client/features/permissions/components/RoleMemberPicker.tsx new file mode 100644 index 00000000..d25a036a --- /dev/null +++ b/apps/dashboard/src/client/features/permissions/components/RoleMemberPicker.tsx @@ -0,0 +1,65 @@ +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Popover, PopoverContent, PopoverTrigger } from "../../../shared/ui/popover"; +import { Icon } from "../../../shared/components/Icon"; +import { MemberSearchList } from "../../../shared/ui/member-search-list"; +import type { GuildMember } from "../../../shared/lib/schemas"; + +interface RoleMemberPickerProps { + guildId: string; + /** Ids to hide from results: members already on the role, plus the caller's + * own id when they are not the owner (the self-assign 403 stays unreachable + * rather than merely explained). */ + excludeIds: string[]; + disabled?: boolean; + onSelect: (member: GuildMember) => void; +} + +/** + * Single-select "add a member to this role" control. + * + * A slimmed-down sibling of MemberMultiSelect: both are thin popover shells + * around the shared `MemberSearchList` (search input, debounce, results). + * This one resolves the pending action on a single pick and closes the + * popover immediately — there is no multi-chip state to manage here, since + * the role's assigned-member list is rendered separately by the caller, with + * provenance, from `useRoleMembers`. + */ +export function RoleMemberPicker({ guildId, excludeIds, disabled, onSelect }: RoleMemberPickerProps) { + const { t } = useTranslation("permissions"); + const [open, setOpen] = useState(false); + const triggerId = useId(); + + function handleSelect(member: GuildMember) { + onSelect(member); + setOpen(false); + } + + return ( + + + + + + + + + ); +} diff --git a/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts b/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts index b5d269ee..20a4168b 100644 --- a/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts +++ b/apps/dashboard/src/client/features/permissions/hooks/usePermissions.ts @@ -6,16 +6,18 @@ 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) ─── -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; @@ -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/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/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")} /> -
+
(null); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -75,6 +90,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 ( @@ -83,46 +105,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")} @@ -132,7 +158,7 @@ export function PermissionsPage() { {t("tabs.roles")} - {t("tabs.auditLog")} + {canViewAudit && {t("tabs.auditLog")}} @@ -192,9 +218,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); + const { data: currentUser } = useAuth(); + const { + data: roleMembers = [], + isLoading: roleMembersLoading, + isError: roleMembersError, + } = useRoleMembers(guildId, role.id); + const assignMember = useAssignRoleMember(guildId); + const removeMember = useRemoveRoleMember(guildId); const [name, setName] = useState(role.name); const [color, setColor] = useState(role.color ?? "#a3a6ff"); const [isDefault, setIsDefault] = useState(role.isDefault); @@ -264,6 +303,94 @@ function RoleEditor({ }); } + // A "confirmed identity" is either owner status (self-assignment is a + // non-issue for the owner — the server never checks it) or a resolved + // currentUser.userId. `useAuth` is an independent query with no route + // loader forcing it to resolve before RoleEditor mounts, and — per review + // — a plain `useQuery` with `retry: false` settles at `isLoading: false, + // data: undefined` on a non-401 fetch error, same shape as "never fetched + // yet". So the gate below is NOT "loading vs. not" (that left the errored + // case open); it is "do we have an id or not" — loading, errored, and a + // null/undefined body all fail closed identically. Both the disable check + // and the self-exclusion list derive from this one flag so they cannot + // drift apart. + const callerIdKnown = currentUserIsOwner || Boolean(currentUser?.userId); + + // Mirrors the server's assignment gate exactly: against `role.permissions` + // (the persisted grant), not the locally-edited `permissions` state above — + // assigning applies immediately and independently of the pending Save. + const cannotAssignRole = + !currentUserIsOwner && + (!callerIdKnown || !role.permissions.every((p) => matchPermission(myPermissionSet, p))); + + const assignedMemberIds = useMemo(() => roleMembers.map((m) => m.userId), [roleMembers]); + + // Already-assigned members are never offered again; a non-owner caller's + // own id is excluded too, so the server's self-assign 403 is unreachable + // here rather than merely explained. + const addExcludeIds = useMemo(() => { + const ids = new Set(assignedMemberIds); + if (!currentUserIsOwner && callerIdKnown && currentUser?.userId) ids.add(currentUser.userId); + return [...ids]; + }, [assignedMemberIds, currentUserIsOwner, callerIdKnown, currentUser]); + + // assignedBy ids resolve alongside member ids in the same batched lookup — + // a member and their assigner are both just Discord user ids to resolve. + const resolveIds = useMemo(() => { + const ids = new Set(); + for (const m of roleMembers) { + ids.add(m.userId); + ids.add(m.assignedBy); + } + return [...ids]; + }, [roleMembers]); + const { data: resolvedMembers = [] } = useMembersByIds(guildId, resolveIds); + const resolvedById = useMemo(() => { + const map = new Map(); + for (const m of resolvedMembers) map.set(m.id, m); + return map; + }, [resolvedMembers]); + + function handleAddMember(member: GuildMember) { + assignMember.mutate( + { roleId: role.id, userId: member.id }, + { + onSuccess: () => + toast.success(t("toast.memberAdded", { name: member.displayName })), + onError: (err) => toast.error(err.message), + }, + ); + } + + // Matches the automation rule-delete pattern (rules.tsx confirmDelete): + // the removal itself is not deferred — it fires immediately, like every + // other action in this section — but the success toast offers Undo, which + // re-assigns the same role/user pair rather than holding the request back. + function handleRemoveMember(userId: string, displayName: string) { + removeMember.mutate( + { roleId: role.id, userId }, + { + onSuccess: () => + toast.success(t("toast.memberRemoved", { name: displayName }), { + action: { + label: t("common:actions.undo"), + onClick: () => { + assignMember.mutate( + { roleId: role.id, userId }, + { + onSuccess: () => + toast.success(t("toast.memberRestored", { name: displayName })), + onError: (err) => toast.error(err.message), + }, + ); + }, + }, + }), + onError: (err) => toast.error(err.message), + }, + ); + } + function togglePermission(key: string) { setPermissions((prev) => { const next = new Set(prev); @@ -273,6 +400,8 @@ function RoleEditor({ }); } + const needsLookups = needsLookupsPermission(permissions); + function toggleModuleWildcard(moduleKey: string) { const wildcard = `${moduleKey}.*`; setPermissions((prev) => { @@ -281,7 +410,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); } @@ -338,65 +467,245 @@ function RoleEditor({
+ {needsLookups && ( + + +
+ {t("roleEditor.lookupsWarning")} + +
+
+ )} + {/* Permission Grid */}
-
- {PERMISSION_REGISTRY.map((mod) => { - const wildcard = `${mod.key}.*`; - const hasWildcard = permissions.has(wildcard); - const allGranted = - hasWildcard || - mod.permissions.every((p) => permissions.has(p.key)); - - return ( -
-
- toggleModuleWildcard(mod.key)} - aria-label={`${mod.label} — ${t("roleEditor.allBadge")}`} - /> - - {mod.label} - - {hasWildcard && ( - - {t("roleEditor.allBadge")} - - )} + {registryLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ +
-
- {mod.permissions.map((perm) => { - const checked = hasWildcard || permissions.has(perm.key); - return ( -
+ ))} +
+ ) : registryError ? ( + + {t("roleEditor.registryError")} + + ) : registry.length === 0 ? ( +

+ {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); + // toggleModuleWildcard only ever ADDS a new key (the wildcard + // itself) when the wildcard isn't already present — even when + // `allGranted` is already true via every individual permission, + // clicking still swaps those for the wildcard key, which the + // server validates as its own permission. So the only state + // that matters here is `hasWildcard`, not `allGranted`: mirror + // the server's exact check (matchPermission against the + // wildcard key `toggleModuleWildcard` adds), and only block the + // add direction — removing the wildcard is de-escalation and + // must stay allowed, same as isDefault: false in Fix 1. + const cannotAddWildcard = + !currentUserIsOwner && !hasWildcard && !matchPermission(myPermissionSet, wildcard); + const moduleCheckbox = ( + toggleModuleWildcard(mod.key)} + aria-label={`${modLabel} — ${t("roleEditor.allBadge")}`} + /> + ); + + return ( +
+
+ {cannotAddWildcard ? ( + + + + {moduleCheckbox} + + + + {t("roleEditor.cannotGrantTooltip")} + + + ) : ( + moduleCheckbox + )} + + {modLabel} + + {hasWildcard && ( + + {t("roleEditor.allBadge")} + + )} +
+
+ {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} — ${perm.label}`} + aria-label={`${role.name} — ${permLabel}`} /> -
- {perm.label} -

- {perm.description} -

-
- - ); - })} + ); + return ( + + ); + })} +
-
+ ); + })} +
+ )} + +
+ + {/* Members — grants/revokes access immediately, independent of Save */} +
+ + {cannotAssignRole ? ( + + + + + + + {t("roleEditor.cannotGrantTooltip")} + + ) : ( + + )} + + {roleMembersLoading ? ( +
+ + +
+ ) : roleMembersError ? ( + + {t("roleEditor.membersSection.error")} + + ) : roleMembers.length === 0 ? ( +

+ {t("roleEditor.membersSection.empty")} +

+ ) : ( +
    + {roleMembers.map((m) => { + const member = resolvedById.get(m.userId); + const displayName = member?.displayName ?? m.userId; + const assignedByName = resolvedById.get(m.assignedBy)?.displayName ?? m.assignedBy; + const isRemoving = + removeMember.isPending && removeMember.variables?.userId === m.userId; + return ( +
  • +
    + {displayName} +
    +

    {displayName}

    +

    + {t("roleEditor.membersSection.assignedBy", { + name: assignedByName, + date: new Date(m.createdAt).toLocaleDateString(), + })} +

    +
    +
    + +
  • ); })} -
- + + )}
{/* Actions */} @@ -623,3 +932,9 @@ function setsEqual(a: Set, b: Set): boolean { for (const v of a) if (!b.has(v)) return false; return true; } + +function memberAvatarUrl(userId: string, avatar: string | null): string { + return avatar + ? `https://cdn.discordapp.com/avatars/${userId}/${avatar}.png?size=64` + : "https://cdn.discordapp.com/embed/avatars/0.png"; +} 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/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/hooks/useDebounced.ts b/apps/dashboard/src/client/shared/hooks/useDebounced.ts new file mode 100644 index 00000000..11fdc089 --- /dev/null +++ b/apps/dashboard/src/client/shared/hooks/useDebounced.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +/** Debounces a value so typing does not fire one request per keystroke. */ +export function useDebounced(value: T, ms: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), ms); + return () => clearTimeout(id); + }, [value, ms]); + return debounced; +} diff --git a/apps/dashboard/src/client/shared/lib/schemas.ts b/apps/dashboard/src/client/shared/lib/schemas.ts index cdd7fb1a..17374bf2 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; @@ -790,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/src/client/shared/ui/member-multi-select.tsx b/apps/dashboard/src/client/shared/ui/member-multi-select.tsx index 25b74152..2b5243ad 100644 --- a/apps/dashboard/src/client/shared/ui/member-multi-select.tsx +++ b/apps/dashboard/src/client/shared/ui/member-multi-select.tsx @@ -1,10 +1,11 @@ -import { useState, useMemo, useId, useEffect } from "react"; +import { useState, useId } from "react"; import { useTranslation } from "react-i18next"; import { Popover, PopoverContent, PopoverTrigger } from "./popover"; import { Label } from "./label"; import { Badge } from "./badge"; import { Icon } from "../components/Icon"; -import { useMemberSearch, useMembersByIds } from "../hooks/useMembers"; +import { useMembersByIds } from "../hooks/useMembers"; +import { MemberSearchList } from "./member-search-list"; interface MemberMultiSelectProps { guildId: string; @@ -15,16 +16,6 @@ interface MemberMultiSelectProps { chipColor?: "secondary" | "destructive"; } -/** Debounces a value so typing does not fire one request per keystroke. */ -function useDebounced(value: T, ms: number): T { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const id = setTimeout(() => setDebounced(value), ms); - return () => clearTimeout(id); - }, [value, ms]); - return debounced; -} - /** * Member picker for trigger filters. * @@ -42,16 +33,10 @@ export function MemberMultiSelect({ }: MemberMultiSelectProps) { const { t } = useTranslation(["common", "rules"]); const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const debouncedSearch = useDebounced(search, 250); const triggerId = useId(); - const { data: results = [], isLoading } = useMemberSearch(guildId, debouncedSearch); const { data: selected = [] } = useMembersByIds(guildId, selectedIds); - const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); - const options = results.filter((m) => !selectedSet.has(m.id)); - // A member who has since left the guild will not resolve; show the raw id // rather than dropping them silently, so the filter stays editable. const chips = selectedIds.map((id) => { @@ -80,45 +65,16 @@ export function MemberMultiSelect({ - setSearch(e.target.value)} - placeholder={t("common:form.search")} - aria-label={t("common:form.search")} - className="mb-2 w-full rounded-sm bg-surface-lowest px-2.5 py-1.5 text-sm text-text placeholder:text-outline focus:outline-none" + onChange([...selectedIds, member.id])} + searchPlaceholder={t("common:form.search")} + emptyQueryHint={t("rules:conditions.memberSearchHint")} + loadingLabel={t("common:form.loading")} + noResultsLabel={t("common:form.noResults")} + listAriaLabel={label} /> -
- {isLoading && ( -

{t("common:form.loading")}

- )} - {!isLoading && debouncedSearch.trim() === "" && ( -

- {t("rules:conditions.memberSearchHint")} -

- )} - {!isLoading && debouncedSearch.trim() !== "" && options.length === 0 && ( -

{t("common:form.noResults")}

- )} - {options.map((member) => ( - - ))} -
diff --git a/apps/dashboard/src/client/shared/ui/member-search-list.tsx b/apps/dashboard/src/client/shared/ui/member-search-list.tsx new file mode 100644 index 00000000..8b01f48c --- /dev/null +++ b/apps/dashboard/src/client/shared/ui/member-search-list.tsx @@ -0,0 +1,90 @@ +import { useMemo, useState } from "react"; +import { useMemberSearch } from "../hooks/useMembers"; +import { useDebounced } from "../hooks/useDebounced"; +import type { GuildMember } from "../lib/schemas"; + +interface MemberSearchListProps { + guildId: string; + /** Ids to hide from the results (already-selected members, self, etc.). */ + excludeIds: string[]; + onSelect: (member: GuildMember) => void; + searchPlaceholder: string; + emptyQueryHint: string; + loadingLabel: string; + noResultsLabel: string; + /** aria-label for the results `listbox`. */ + listAriaLabel: string; +} + +/** + * Debounced member search box + results list, shared by every member picker + * (`MemberMultiSelect`'s trigger-filter chips, `RoleMemberPicker`'s single- + * select add control). Owns the search input, the 250ms debounce, the + * `useMemberSearch` call, and the loading/empty-query-hint/no-results/option + * branching — callers own everything around it (the popover trigger, whether + * picking a result also closes the popover, multi- vs. single-select state). + * + * All four states below are intentionally not mutually exclusive in the + * markup (no early return): `isLoading` gates the loading message, the hint + * and no-results messages are keyed off the debounced query, and the option + * buttons render from whatever `results` currently holds. In production this + * self-resolves (the hook returns `[]` while disabled on an empty query), so + * only one of them is ever visible at a time in practice. + */ +export function MemberSearchList({ + guildId, + excludeIds, + onSelect, + searchPlaceholder, + emptyQueryHint, + loadingLabel, + noResultsLabel, + listAriaLabel, +}: MemberSearchListProps) { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounced(search, 250); + + const { data: results = [], isLoading } = useMemberSearch(guildId, debouncedSearch); + const excludeSet = useMemo(() => new Set(excludeIds), [excludeIds]); + const options = results.filter((m) => !excludeSet.has(m.id)); + + function handleSelect(member: GuildMember) { + onSelect(member); + setSearch(""); + } + + return ( + <> + setSearch(e.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="mb-2 w-full rounded-sm bg-surface-lowest px-2.5 py-1.5 text-sm text-text placeholder:text-outline focus:outline-none" + /> +
+ {isLoading &&

{loadingLabel}

} + {!isLoading && debouncedSearch.trim() === "" && ( +

{emptyQueryHint}

+ )} + {!isLoading && debouncedSearch.trim() !== "" && options.length === 0 && ( +

{noResultsLabel}

+ )} + {options.map((member) => ( + + ))} +
+ + ); +} 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..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, requireGuildAdmin } 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, requireGuildAdmin], + 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, requireGuildAdmin], + 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, requireGuildAdmin], + 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, requireGuildAdmin], + 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/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..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 — `requireGuildAdmin` - * 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/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..624f254f 100644 --- a/apps/dashboard/src/server/features/permissions/roles-routes.ts +++ b/apps/dashboard/src/server/features/permissions/roles-routes.ts @@ -2,14 +2,19 @@ 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, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; +import { + requireAuth, + requireGuildAccess, + requirePermission, + getDeclaredPermissions, +} from "../../shared/middleware.js"; import { createDashboardAuditLog, invalidatePermissionCache, + safeParsePermissions, } from "../../shared/permissions.js"; import { deleteDashboardRoleWithAudit } from "../../shared/dashboardRoleDelete.js"; @@ -20,7 +25,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("*")) { @@ -39,7 +44,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 +76,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 +198,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"] }, @@ -262,6 +267,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; @@ -315,7 +338,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 +380,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 +412,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"] }, @@ -417,6 +440,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: { @@ -462,7 +507,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 +552,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 +563,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..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, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; + requireAuth, + requireGuildAccess, + requirePermission, + getDeclaredPermissions, +} from "../../shared/middleware.js"; +import { buildPermissionRegistry } from "../../shared/permissionRegistry.js"; import { resolveUserPermissions, createDashboardAuditLog, @@ -25,7 +27,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 }; @@ -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, requireGuildAdmin], + preHandler: [requireAuth, requireGuildAccess], }, async (_request, reply) => { - reply.send(PERMISSION_REGISTRY); + reply.send(buildPermissionRegistry(getDeclaredPermissions())); }, ); @@ -71,7 +76,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 }; @@ -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, }); }, @@ -102,7 +110,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 +227,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 +260,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 +284,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 +356,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 }; @@ -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/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/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/guildAuthz.ts b/apps/dashboard/src/server/shared/guildAuthz.ts index ed72b1e3..3b04f88e 100644 --- a/apps/dashboard/src/server/shared/guildAuthz.ts +++ b/apps/dashboard/src/server/shared/guildAuthz.ts @@ -26,27 +26,42 @@ 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, + }; } diff --git a/apps/dashboard/src/server/shared/middleware.ts b/apps/dashboard/src/server/shared/middleware.ts index c6b9c26f..f425f87e 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"), @@ -88,12 +94,25 @@ export async function requireGuildAdmin( 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 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[]) { + 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/src/server/shared/permissions.ts b/apps/dashboard/src/server/shared/permissions.ts index d6af722c..6175c30f 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 safeParsePermissions(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; } /** @@ -216,10 +246,21 @@ export async function createDashboardAuditLog( // ─── Helpers ─── -function safeJsonParse(json: string, fallback: T): T { +/** + * Parse a `DashboardRole.permissions` (or similar) JSON column into a string + * array, tolerating both malformed JSON and syntactically-valid-but-wrong-shaped + * JSON (e.g. `"5"` parses to the number 5, not an array). Callers on the auth + * path iterate the result directly, so returning anything other than a real + * string[] — a number, a string that indexes to individual characters — would + * throw or silently misbehave instead of failing safe to no permissions. + */ +export function safeParsePermissions(json: string): string[] { try { - return JSON.parse(json) as T; + const parsed: unknown = JSON.parse(json); + return Array.isArray(parsed) + ? parsed.filter((p): p is string => typeof p === "string") + : []; } catch { - return fallback; + return []; } } diff --git a/apps/dashboard/src/server/shared/session.ts b/apps/dashboard/src/server/shared/session.ts index 5fc32809..24b14b83 100644 --- a/apps/dashboard/src/server/shared/session.ts +++ b/apps/dashboard/src/server/shared/session.ts @@ -264,32 +264,6 @@ export async function forceRefreshSessionGuilds( return refreshSessionGuilds(id, cached.session.accessToken, cached); } -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. - */ -export async function ensureFreshGuilds( - id: string, -): Promise { - const cached = sessionCache.get(id); - if (!cached) { - const session = await getSession(id); - if (!session) return null; - const reloaded = sessionCache.get(id); - if (!reloaded) return session.guilds; - if (Date.now() - reloaded.guildsRefreshedAt <= FRESH_GUILD_THRESHOLD) { - return reloaded.session.guilds; - } - return refreshSessionGuilds(id, session.accessToken, reloaded); - } - if (Date.now() - cached.guildsRefreshedAt <= FRESH_GUILD_THRESHOLD) { - return cached.session.guilds; - } - return refreshSessionGuilds(id, cached.session.accessToken, cached); -} - // Test-only hook export function __setSessionCacheForTest( id: string, 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/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/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/routes/guild/$guildId/permissions.test.tsx b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx new file mode 100644 index 00000000..c567bdfc --- /dev/null +++ b/apps/dashboard/tests/client/routes/guild/$guildId/permissions.test.tsx @@ -0,0 +1,915 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; +import { render, screen, within, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => (o ? `${k}:${JSON.stringify(o)}` : k), + }), +})); + +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ guildId: "g1" }), +})); + +// Typed via the implementation passed to vi.fn() (not `any`/`as`) so a test +// that inspects a toast's `action` (the Undo affordance) gets a real, +// narrow structural type back instead of falling through to implicit any. +interface ToastActionOptions { + action?: { label: string; onClick: () => void }; +} + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn((_message: string, _options?: ToastActionOptions) => {}), + error: vi.fn((_message: string) => {}), + }, +})); + +// 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. +interface RegistryQueryState { + data: unknown; + isLoading: boolean; + isError: boolean; +} + +const registryState = vi.hoisted((): { box: RegistryQueryState } => ({ + box: { + data: undefined, + isLoading: false, + isError: false, + }, +})); + +// 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", + position: 0, + isDefault: false, + permissions: [], + memberCount: 2, + createdAt: "2026-01-01T00:00:00.000Z", + 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", + }, +})); + +// Drives the current user's own resolved permissions/isOwner as returned by +// usePermissions(guildId) — consumed both by PermissionsPage's gates (Create +// Role button, audit tab) and by RoleEditor's per-checkbox escalation guard. +interface PermissionsFixture { + isOwner: boolean; + isLoading: boolean; + permissions: string[]; +} + +const permissionsState = vi.hoisted((): { box: PermissionsFixture } => ({ + box: { isOwner: true, isLoading: false, permissions: ["*"] }, +})); + +// Drives useDashboardSettings(guildId) — in particular isError, to exercise +// the "settings fetch 403s" path a dashboard.roles.view-only viewer hits. +interface SettingsFixture { + data: { guildId: string; auditRetentionDays: number; requirePermissions: boolean } | undefined; + isLoading: boolean; + isError: boolean; +} + +const settingsState = vi.hoisted((): { box: SettingsFixture } => ({ + box: { + data: { guildId: "g1", auditRetentionDays: 30, requirePermissions: true }, + isLoading: false, + isError: false, + }, +})); + +// Drives useRoleMembers(guildId, roleId) — the assigned-member list rendered +// with provenance below the permission grid. +interface RoleMemberFixture { + id: string; + userId: string; + assignedBy: string; + createdAt: string; +} + +interface RoleMembersQueryState { + data: RoleMemberFixture[] | undefined; + isLoading: boolean; + isError: boolean; +} + +const roleMembersState = vi.hoisted((): { box: RoleMembersQueryState } => ({ + box: { data: [], isLoading: false, isError: false }, +})); + +// Drives useAssignRoleMember/useRemoveRoleMember. `mutate` records every call +// so tests can assert the exact {roleId, userId} sent, and synchronously +// invokes the caller's onSuccess/onError (mirroring RoleEditor's own +// success/error toast handling) so a `shouldFail` fixture can prove a failed +// mutation surfaces an error rather than looking like a success. +interface RoleMemberMutationFixture { + isPending: boolean; + shouldFail: boolean; + calls: Array<{ roleId: string; userId: string }>; + variables: { roleId: string; userId: string } | undefined; +} + +function freshMutationFixture(): RoleMemberMutationFixture { + return { isPending: false, shouldFail: false, calls: [], variables: undefined }; +} + +const assignMemberState = vi.hoisted((): { box: RoleMemberMutationFixture } => ({ + box: { isPending: false, shouldFail: false, calls: [], variables: undefined }, +})); + +const removeMemberState = vi.hoisted((): { box: RoleMemberMutationFixture } => ({ + box: { isPending: false, shouldFail: false, calls: [], variables: undefined }, +})); + +function mutationMock(state: { box: RoleMemberMutationFixture }) { + return { + isPending: state.box.isPending, + variables: state.box.variables, + mutate: ( + vars: { roleId: string; userId: string }, + opts?: { onSuccess?: () => void; onError?: (err: Error) => void }, + ) => { + state.box.calls.push(vars); + state.box.variables = vars; + if (state.box.shouldFail) opts?.onError?.(new Error("Request failed")); + else opts?.onSuccess?.(); + }, + }; +} + +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: permissionsState.box.isOwner, + isLoading: permissionsState.box.isLoading, + permissions: permissionsState.box.permissions, + can: (key: string) => + permissionsState.box.isOwner || + actual.matchPermission(new Set(permissionsState.box.permissions), key), + }), + useDashboardRoles: () => ({ data: [roleState.box], 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: () => settingsState.box, + useUpdateDashboardSettings: () => ({ mutate: vi.fn(), isPending: false }), + useDashboardAuditLog: () => ({ data: { entries: [], total: 0, page: 1, pages: 1 }, isLoading: false }), + usePermissionRegistry: () => registryState.box, + useRoleMembers: () => roleMembersState.box, + useAssignRoleMember: () => mutationMock(assignMemberState), + useRemoveRoleMember: () => mutationMock(removeMemberState), + }; +}); + +// Drives useMemberSearch/useMembersByIds — both return the same fixed roster +// regardless of the query/ids passed in, since filtering (excludeIds) is the +// component's own job, not the mocked hook's. +interface MemberFixture { + id: string; + username: string; + displayName: string; + avatar: string | null; +} + +const memberDirectoryState = vi.hoisted((): { box: MemberFixture[] } => ({ box: [] })); + +vi.mock("../../../../../src/client/shared/hooks/useMembers", () => ({ + useMemberSearch: () => ({ data: memberDirectoryState.box, isLoading: false }), + useMembersByIds: () => ({ data: memberDirectoryState.box, isLoading: false }), +})); + +// Drives useAuth() — `.data.userId` (RoleEditor's self-id, for excluding the +// caller from their own add-control results) and `.isLoading` (there is no +// route loader forcing this independent query to resolve before RoleEditor +// mounts, so a non-owner caller's identity can still be unknown on render — +// the add control must fail closed in that window, not assume ownership). +interface AuthFixture { + userId: string; + username: string; + avatar: string | null; +} + +interface AuthQueryState { + data: AuthFixture | null; + isLoading: boolean; +} + +const authState = vi.hoisted((): { box: AuthQueryState } => ({ + box: { data: { userId: "u-current", username: "current", avatar: null }, isLoading: false }, +})); + +vi.mock("../../../../../src/client/shared/hooks/useAuth", () => ({ + useAuth: () => authState.box, +})); + +import { PermissionsPage } from "../../../../../src/client/routes/guild/$guildId/permissions"; +import { TooltipProvider } from "../../../../../src/client/shared/ui/tooltip"; +import { toast } from "sonner"; + +// 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; +}); + +// Production wraps every route in a root-level TooltipProvider (routes/__root.tsx); +// RoleEditor's escalation-guard tooltips (Fix 3) rely on that context being present, +// so tests reproduce it here rather than have the component special-case its absence. +function renderPage() { + return render( + + + , + ); +} + +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 }; + renderPage(); + + // 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 }; + renderPage(); + + 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 }; + renderPage(); + + 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 }; + renderPage(); + + 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(); + }); +}); + +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 }; + renderPage(); + + 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 }; + renderPage(); + + // 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 }; + renderPage(); + + 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 }; + renderPage(); + + 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 }; + renderPage(); + + 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 }; + renderPage(); + + await screen.findByText("permissions:permissionCategories.tickets"); + expect(screen.queryByText("roleEditor.admissionNote")).not.toBeInTheDocument(); + }); +}); + +describe("PermissionsPage — settings fetch failure never asserts a security posture", () => { + it("renders neither the status card nor the disabled banner when dashboard-settings 403s", async () => { + permissionsState.box = { isOwner: false, isLoading: false, permissions: ["dashboard.roles.view"] }; + settingsState.box = { data: undefined, isLoading: false, isError: true }; + roleState.box = { ...ROLE_BASE, permissions: [] }; + registryState.box = { data: REGISTRY, isLoading: false, isError: false }; + renderPage(); + + await screen.findByText("permissions:permissionCategories.tickets"); + expect(screen.queryByText("permissionSystem.title")).not.toBeInTheDocument(); + expect(screen.queryByText("warning.disabled")).not.toBeInTheDocument(); + }); + + it("renders the status card and disabled banner once the settings fetch succeeds", async () => { + permissionsState.box = { isOwner: true, isLoading: false, permissions: ["*"] }; + settingsState.box = { + data: { guildId: "g1", auditRetentionDays: 30, requirePermissions: false }, + isLoading: false, + isError: false, + }; + roleState.box = { ...ROLE_BASE, permissions: [] }; + registryState.box = { data: REGISTRY, isLoading: false, isError: false }; + renderPage(); + + expect(await screen.findByText("permissionSystem.title")).toBeInTheDocument(); + expect(screen.getByText("warning.disabled")).toBeInTheDocument(); + }); +}); + +describe("PermissionsPage — Create Role and Audit Log gated on their own permissions", () => { + it("hides Create Role when the caller lacks dashboard.roles.manage", async () => { + permissionsState.box = { isOwner: false, isLoading: false, permissions: ["tickets.list.view"] }; + settingsState.box = { + data: { guildId: "g1", auditRetentionDays: 30, requirePermissions: true }, + isLoading: false, + isError: false, + }; + roleState.box = { ...ROLE_BASE, permissions: [] }; + registryState.box = { data: REGISTRY, isLoading: false, isError: false }; + renderPage(); + + await screen.findByText("permissions:permissionCategories.tickets"); + expect(screen.queryByText("actions.createRole")).not.toBeInTheDocument(); + }); + + it("shows Create Role when the caller holds dashboard.roles.manage", async () => { + permissionsState.box = { isOwner: false, isLoading: false, permissions: ["dashboard.roles.manage"] }; + settingsState.box = { + data: { guildId: "g1", auditRetentionDays: 30, requirePermissions: true }, + isLoading: false, + isError: false, + }; + roleState.box = { ...ROLE_BASE, permissions: [] }; + registryState.box = { data: REGISTRY, isLoading: false, isError: false }; + renderPage(); + + expect(await screen.findByText("actions.createRole")).toBeInTheDocument(); + }); + + it("hides the Audit Log tab when the caller lacks dashboard.audit.view", async () => { + permissionsState.box = { isOwner: false, isLoading: false, permissions: ["dashboard.roles.manage"] }; + settingsState.box = { + data: { guildId: "g1", auditRetentionDays: 30, requirePermissions: true }, + isLoading: false, + isError: false, + }; + roleState.box = { ...ROLE_BASE, permissions: [] }; + registryState.box = { data: REGISTRY, isLoading: false, isError: false }; + renderPage(); + + await screen.findByText("permissions:permissionCategories.tickets"); + expect(screen.queryByText("tabs.auditLog")).not.toBeInTheDocument(); + }); + + it("shows the Audit Log tab when the caller holds dashboard.audit.view", async () => { + permissionsState.box = { isOwner: false, isLoading: false, permissions: ["dashboard.audit.view"] }; + settingsState.box = { + data: { guildId: "g1", auditRetentionDays: 30, requirePermissions: true }, + isLoading: false, + isError: false, + }; + roleState.box = { ...ROLE_BASE, permissions: [] }; + registryState.box = { data: REGISTRY, isLoading: false, isError: false }; + renderPage(); + + expect(await screen.findByText("tabs.auditLog")).toBeInTheDocument(); + }); +}); + +const REGISTRY_MULTI_MODULE = [ + ...REGISTRY, + { + key: "moderation", + icon: "Shield", + labelKey: "permissions:permissionCategories.moderation", + permissions: [ + { + key: "moderation.cases.view", + resourceKey: "permissions:resources.cases", + actionKey: "permissions:permissionActions.view", + }, + ], + }, +]; + +function checkboxForPermissionKey(keyText: string): HTMLElement { + const el = screen.getByText(keyText); + const label = el.closest("label"); + if (!label) throw new Error(`expected ${keyText} to render inside a