Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
93eb846
docs(permissions): spec delegated dashboard access + generated registry
Abdulkhalek-1 Jul 28, 2026
934b045
docs(permissions): implementation plan for delegated dashboard access
Abdulkhalek-1 Jul 28, 2026
ea411de
docs(permissions): correct plan test commands for Docker and target t…
Abdulkhalek-1 Jul 28, 2026
1204c1b
refactor(dashboard): answer owner/admin/member in one authority lookup
Abdulkhalek-1 Jul 28, 2026
213a38a
feat(permissions): resolve explicit grants for non-admin guild members
Abdulkhalek-1 Jul 28, 2026
859d19f
feat(permissions): gate guild routes on any authority, not admin-ness
Abdulkhalek-1 Jul 28, 2026
7715ec0
fix(permissions): drop new test cast, name the size>0 discriminator
Abdulkhalek-1 Jul 28, 2026
a04bcd1
feat(permissions): require dashboard.lookups.view for Discord passthr…
Abdulkhalek-1 Jul 28, 2026
0381607
fix(tests): strengthen dashboard.lookups.view 403 test to assert the key
Abdulkhalek-1 Jul 28, 2026
a9d2f31
fix(permissions): block privilege escalation via role assignment
Abdulkhalek-1 Jul 28, 2026
2370a42
feat(guilds): list guilds where the user holds dashboard grants
Abdulkhalek-1 Jul 28, 2026
eeac7b2
feat(guilds): badge servers reached through delegated access
Abdulkhalek-1 Jul 28, 2026
232a9b5
feat(permissions): derive the permission registry from the route table
Abdulkhalek-1 Jul 28, 2026
36834fc
fix(permissions): make the registry drift-guard actually run in CI
Abdulkhalek-1 Jul 28, 2026
a35b824
refactor(permissions): drop the hand-maintained permission registry
Abdulkhalek-1 Jul 28, 2026
0e7993e
test(permissions): cover wildcard preset entries in the drift test
Abdulkhalek-1 Jul 28, 2026
4e9a9c2
feat(permissions): render the permission grid from the served registry
Abdulkhalek-1 Jul 28, 2026
cb998a0
fix(permissions): show loading and error states for the registry fetch
Abdulkhalek-1 Jul 28, 2026
cfc29e8
fix(permissions): remove test cast and re-indent nested grid JSX
Abdulkhalek-1 Jul 28, 2026
946f3a3
i18n(permissions): translate registry module, resource, and action la…
Abdulkhalek-1 Jul 28, 2026
7023432
fix(i18n): move permission-keys test into dashboard suite, drop i18n …
Abdulkhalek-1 Jul 28, 2026
b38179a
fix(i18n): re-translate sr additions into Serbian Latin, not Cyrillic
Abdulkhalek-1 Jul 28, 2026
d2599e4
fix(overview): render for users without analytics permission
Abdulkhalek-1 Jul 28, 2026
e17f8d1
fix(overview): namespace-prefix AccessSummary nav labels
Abdulkhalek-1 Jul 28, 2026
600776e
feat(permissions): warn when a role lacks picker access
Abdulkhalek-1 Jul 28, 2026
0dcae31
fix(permissions): warning variant styling + render coverage for looku…
Abdulkhalek-1 Jul 28, 2026
5f2f5e8
test(permissions): cover delegated grant resolution against the real DB
Abdulkhalek-1 Jul 28, 2026
24c0adb
docs(permissions): record delegated access in the feature spec
Abdulkhalek-1 Jul 28, 2026
bbdcdd5
fix(permissions): gate isDefault promotion behind the escalation guard
Abdulkhalek-1 Jul 28, 2026
e34f756
fix(permissions): stop asserting security posture on 403; disable ung…
Abdulkhalek-1 Jul 28, 2026
b915ae3
fix(permissions): reuse safeParsePermissions in the auth path; pin ge…
Abdulkhalek-1 Jul 28, 2026
ec01e9e
chore(permissions): remove dead code and fix stale docs
Abdulkhalek-1 Jul 28, 2026
ea08fd7
fix(permissions): gate the module select-all checkbox behind the esca…
Abdulkhalek-1 Jul 28, 2026
820a928
feat(permissions): assign dashboard roles to members
Abdulkhalek-1 Jul 28, 2026
973028d
fix(permissions): close the self-assign auth race and dedupe member s…
Abdulkhalek-1 Jul 28, 2026
0210131
fix(permissions): gate self-assign exclusion on confirmed identity, n…
Abdulkhalek-1 Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="p-6" data-testid="access-summary">
<h2 className="text-lg font-bold tracking-tight">{t("overview:access.title")}</h2>
<p className="mt-1 text-sm text-text-muted">{t("overview:access.subtitle")}</p>

{roles.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1">
{roles.map((role) => (
<Badge key={role.id} variant="outline">
{role.name}
</Badge>
))}
</div>
)}

{available.length === 0 ? (
<p className="mt-6 text-sm text-text-muted">{t("overview:access.empty")}</p>
) : (
<ul className="mt-6 grid grid-cols-1 gap-2 sm:grid-cols-2">
{available.map((item) => (
<li key={item.path}>
<Link
to={item.path}
params={{ guildId }}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-surface-high"
>
<Icon name={item.icon} size={18} />
{t(`common:${item.i18nKey}`)}
</Link>
</li>
))}
</ul>
)}
</Card>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnalyticsResponse>({
queryKey: ["guilds", guildId, "actions", "analytics", { days }],
queryFn: async () => {
Expand All @@ -11,5 +11,6 @@ export function useAnalytics(guildId: string, days: number = 7) {
);
return AnalyticsResponseSchema.parse(data);
},
enabled: enabled && Boolean(guildId),
});
}
Original file line number Diff line number Diff line change
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
id={triggerId}
type="button"
disabled={disabled}
className="flex h-9 w-full items-center gap-2 rounded-sm border border-dashed border-outline-variant/40 px-3 text-sm text-text-muted transition-colors hover:bg-surface-high/50 hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 sm:w-72"
>
<Icon name="person_add" size={16} />
{t("roleEditor.membersSection.addPlaceholder")}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-2">
<MemberSearchList
guildId={guildId}
excludeIds={excludeIds}
onSelect={handleSelect}
searchPlaceholder={t("roleEditor.membersSection.searchPlaceholder")}
emptyQueryHint={t("roleEditor.membersSection.searchHint")}
loadingLabel={t("roleEditor.membersSection.searchLoading")}
noResultsLabel={t("roleEditor.membersSection.searchNoResults")}
listAriaLabel={t("roleEditor.membersSection.addPlaceholder")}
/>
</PopoverContent>
</Popover>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>, required: string): boolean {
export function matchPermission(granted: Set<string>, required: string): boolean {
if (granted.has("*")) return true;
if (granted.has(required)) return true;

Expand Down Expand Up @@ -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<PermissionModuleView[]>({
queryKey: ["guilds", guildId, "permission-registry"],
queryFn: async () => {
const raw = await apiFetch<unknown>(
`/api/guilds/${guildId}/permission-registry`,
);
return PermissionRegistrySchema.parse(raw);
},
staleTime: Infinity,
enabled: Boolean(guildId),
});
}

// ─── Dashboard Roles ───

export function useDashboardRoles(guildId: string) {
Expand Down
15 changes: 15 additions & 0 deletions apps/dashboard/src/client/features/permissions/lookupsWarning.ts
Original file line number Diff line number Diff line change
@@ -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<string>): boolean {
if (matchPermission(granted, "dashboard.lookups.view")) return false;

return [...granted].some(
(perm) => perm.endsWith(".manage") || perm.endsWith(".*") || perm === "*",
);
}
33 changes: 30 additions & 3 deletions apps/dashboard/src/client/routes/guild/$guildId/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,50 @@ 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";

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 <CardGridSkeleton />;
if (permissionsLoading) return <CardGridSkeleton />;

if (!canViewAnalytics) {
return (
<div className="space-y-8">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<AccessSummary guildId={guildId} />
</div>
);
}

if (isLoading) return <CardGridSkeleton />;

if (isError || !analytics) {
return (
<div className="space-y-8">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<Card className="p-6 text-sm text-text-muted" data-testid="analytics-error">
{t("errors.analyticsUnavailable")}
</Card>
</div>
);
}

const { summary } = analytics;

Expand All @@ -30,7 +57,7 @@ export function OverviewPage() {
subtitle={t("subtitle")}
/>

<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4" data-testid="analytics-stats">
<StatsCard
label={t("stats.actionRules")}
value={summary.totalRules}
Expand Down
Loading
Loading