Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .agents/rules/frontend/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ The dashboard does **not** depend on react-hook-form or zod. Use controlled inpu

The JWT carries **only role names**. `auth-context.tsx` fetches the effective permission list from `GET /api/v1/identity/permissions` (`getMyPermissions()` in `src/api/identity.ts`), caches it in `tokenStore` under `fsh.dashboard.permissions`, and exposes a `permissionsHydrated` flag so gated UI doesn't flash while the fetch is in flight (re-fetched on login/impersonation swaps; `refreshPermissions()` for role changes). Nav items are gated via `perm`/`anyPerm` in `src/components/layout/nav-data.ts`. `ProtectedRoute` is still **auth-only** (no per-route permission gating) — don't add `RouteGuard`-style gating here.

A query that mounts outside its own nav-gated page (topbar badges, overview cards) must set `enabled: permissionsHydrated && user.permissions.includes(<the endpoint's RequirePermission>)` and hide or replace its UI without it; otherwise every poll is a 403 the API logs as an error. `refetch()` ignores `enabled`, so gate manual refetches too. Playwright: `installShellMocks` grants `SHELL_PERMISSIONS` (the Basic-role grants these queries need); specs testing the missing-permission path re-mock `/identity/permissions`.

## Routing & realtime/SSE

- Every route element is wrapped in `withSuspense(node)` (per-route skeleton fallback). No per-route permission guards.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { MessageCircle } from "lucide-react";
import { listMyChannels } from "@/api/chat";
import { useAuth } from "@/auth/use-auth";
import { cn } from "@/lib/cn";

/**
Expand All @@ -17,11 +18,18 @@ import { cn } from "@/lib/cn";
* - markChannelRead (chat-page) invalidates it when the user reads
* - the composer invalidates it when the user sends
*/
// GET /chat/channels requires this; without it every poll is a 403.
const CHANNELS_VIEW_PERMISSION = "Permissions.Chat.Channels.View";

export function ChatUnreadBadge() {
const navigate = useNavigate();
const { user, permissionsHydrated } = useAuth();
const canViewChannels =
permissionsHydrated && (user?.permissions.includes(CHANNELS_VIEW_PERMISSION) ?? false);
const { data: channels } = useQuery({
queryKey: ["chat", "my-channels"],
queryFn: () => listMyChannels({ pageSize: 100 }),
enabled: canViewChannels,
staleTime: 30_000,
});

Expand All @@ -30,6 +38,8 @@ export function ChatUnreadBadge() {
[channels],
);

if (!canViewChannels) return null;

return (
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
markNotificationRead,
type NotificationDto,
} from "@/api/notifications";
import { useAuth } from "@/auth/use-auth";
import { useRealtimeEvent } from "@/realtime/realtime-context";
import {
DropdownMenu,
Expand All @@ -25,14 +26,21 @@ import { cn } from "@/lib/cn";
* NotificationCreated event lands, we patch cache so the badge updates
* without a refetch.
*/
// GET /notifications/unread-count requires this; without it every poll is a 403.
const INBOX_VIEW_PERMISSION = "Permissions.Notifications.Inbox.View";

export function NotificationBell() {
const { user, permissionsHydrated } = useAuth();
const canViewInbox =
permissionsHydrated && (user?.permissions.includes(INBOX_VIEW_PERMISSION) ?? false);
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();

const unreadQuery = useQuery({
queryKey: ["notifications", "unread-count"],
queryFn: getUnreadCount,
enabled: canViewInbox,
staleTime: 30_000,
});

Expand Down Expand Up @@ -84,6 +92,8 @@ export function NotificationBell() {
}
};

if (!canViewInbox) return null;

return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>
Expand Down
26 changes: 22 additions & 4 deletions clients/dashboard/src/pages/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,9 @@ function recentEventTypeIcon(eventType: AuditEventType): React.ComponentType<{ c
/** Mirrors the permission GET /api/v1/audits enforces server-side. */
const AUDIT_VIEW_PERMISSION = "Permissions.AuditTrails.View";

/** Mirrors the permission GET /api/v1/billing/usage enforces server-side. */
const BILLING_VIEW_PERMISSION = "Permissions.Billing.View";

function RecentAuditsSkeleton() {
return (
<ul className="space-y-2.5">
Expand Down Expand Up @@ -972,12 +975,16 @@ export function OverviewPage() {
// hold it, so everyone else gets a quiet no-access state instead of a 403.
const canViewAudits =
permissionsHydrated && (user?.permissions.includes(AUDIT_VIEW_PERMISSION) ?? false);
// Same for GET /billing/usage and Billing.View.
const canViewUsage =
permissionsHydrated && (user?.permissions.includes(BILLING_VIEW_PERMISSION) ?? false);
const { status: sseStatus, eventCount } = useSseStatus();
const { events } = useSseEvents();

const usage = useQuery({
queryKey: ["billing", "usage"],
queryFn: () => getUsageSnapshots(),
enabled: canViewUsage,
staleTime: 60_000,
});

Expand Down Expand Up @@ -1029,7 +1036,8 @@ export function OverviewPage() {

const refreshing = usage.isFetching || subscription.isFetching || status.isFetching;
const onRefresh = () => {
void usage.refetch();
// refetch() bypasses `enabled`, so it needs the same gate.
if (canViewUsage) void usage.refetch();
void subscription.refetch();
void status.refetch();
};
Expand Down Expand Up @@ -1108,12 +1116,17 @@ export function OverviewPage() {
? `until ${new Date(validity.targetUtc).toLocaleDateString("en-US", validityDateFmt)}`
: "no end date";

const resourcesValue = usage.isLoading ? (
const usageDenied = permissionsHydrated && !canViewUsage;
const resourcesValue = usageDenied ? (
"—"
) : usage.isPending ? (
<Skeleton className="h-5 w-10" />
) : (
formatNumber(totalsView.resourceCount)
);
const resourcesSub = (
const resourcesSub = usageDenied ? (
"No access"
) : (
<>
<span className="tabular-nums">{totalsView.avgUtilization.toFixed(0)}%</span>{" "}
avg utilization
Expand Down Expand Up @@ -1283,7 +1296,12 @@ export function OverviewPage() {
totalsView.overage > 0 ? <Badge variant="danger">overage</Badge> : undefined
}
>
{usage.isLoading ? (
{usageDenied ? (
<UsageEmpty
title="No access to usage"
description="Usage needs the Billing permission. Ask an administrator if you need it."
/>
) : usage.isPending ? (
<UsageSkeleton />
) : usage.isError ? (
<UsageEmpty
Expand Down
2 changes: 1 addition & 1 deletion clients/dashboard/tests/activity/activity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ test.describe("activity (/activity)", () => {
});

test("without Audit trail access, no audits call and a live-only notice", async ({ page }) => {
// installShellMocks grants no permissions ([]).
// installShellMocks grants only SHELL_PERMISSIONS, not AuditTrails.View.
await mockSseOnce(page);
const audits = countAuditCalls(page);

Expand Down
4 changes: 2 additions & 2 deletions clients/dashboard/tests/helpers/auth-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import type { Page } from "@playwright/test";
* decoder reads sub/email/given_name/family_name/tenant out of the
* payload. Permissions are NOT read from the JWT: at runtime the app
* fetches them from GET /api/v1/identity/permissions (installShellMocks
* stubs that route to []; permission-gated specs re-mock it with the
* grants they need — see tests/system/trash.spec.ts).
* stubs that route to SHELL_PERMISSIONS; permission-gated specs re-mock it
* with the grants they need — see tests/system/trash.spec.ts).
*/
export type SeededUser = {
sub: string;
Expand Down
14 changes: 12 additions & 2 deletions clients/dashboard/tests/helpers/shell-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export const DEFAULT_PROFILE = {
imageUrl: null,
} as const;

/** Basic-role grants behind the shell's own queries. */
export const SHELL_PERMISSIONS = [
"Permissions.Notifications.Inbox.View",
"Permissions.Chat.Channels.View",
"Permissions.Billing.View",
];

/**
* Mock every API call the authenticated AppShell fires on load so any
* protected page can be visited in isolation without hanging on the
Expand All @@ -43,9 +50,12 @@ export async function installShellMocks(page: Page): Promise<void> {
// Topbar chat unread badge.
await mockJsonResponse(page, "**/api/v1/chat/channels**", []);

// Defensive: profile + permissions (harmless if a page re-reads them).
// Profile + permissions. The default grants match the Basic role and are
// the ones the shell's own queries need (topbar badges, overview usage);
// without them those queries never fire. Permission-gated specs re-mock
// this route with the exact grants they need.
await mockJsonResponse(page, "**/api/v1/identity/profile", DEFAULT_PROFILE);
await mockJsonResponse(page, "**/api/v1/identity/permissions", []);
await mockJsonResponse(page, "**/api/v1/identity/permissions", SHELL_PERMISSIONS);

// Tenant status drives the global expiry/grace banner mounted in the
// AppShell. Default to a healthy, far-future tenant so the banner stays
Expand Down
67 changes: 67 additions & 0 deletions clients/dashboard/tests/layout/permission-gated-queries.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, test, type Page } from "@playwright/test";
import { mockJsonResponse } from "../helpers/api-mocks";
import { installShellMocks } from "../helpers/shell-mocks";
import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";

// The topbar badges and the overview usage card query endpoints that each
// require a permission. A user without it must not fire the request at all:
// every attempt is a 403 the API logs as an error.

const GATED_ENDPOINTS = [
"/api/v1/notifications/unread-count",
"/api/v1/chat/channels",
"/api/v1/billing/usage",
] as const;

const GRANTS = [
"Permissions.Notifications.Inbox.View",
"Permissions.Chat.Channels.View",
"Permissions.Billing.View",
];

function countGatedRequests(page: Page): Record<string, number> {
const counts: Record<string, number> = Object.fromEntries(GATED_ENDPOINTS.map((p) => [p, 0]));
page.on("request", (req) => {
const path = new URL(req.url()).pathname;
if (path in counts) counts[path]++;
});
return counts;
}

test.describe("permission-gated background queries", () => {
test.beforeEach(async ({ page }) => {
await seedAuthedSession(page, TEST_USER);
await installShellMocks(page);
await mockJsonResponse(page, "**/api/v1/billing/usage**", []);
await mockJsonResponse(page, "**/api/v1/billing/subscriptions/me**", null);
});

test("without the permissions, none of the gated endpoints is called", async ({ page }) => {
await mockJsonResponse(page, "**/api/v1/identity/permissions", []);
const counts = countGatedRequests(page);

await page.goto("/");
// Only renders once permissions are hydrated, so every query the page
// mounts has had its chance to fire by now.
await expect(page.getByText("No access to audits")).toBeVisible();

expect(counts).toEqual(Object.fromEntries(GATED_ENDPOINTS.map((p) => [p, 0])));
await expect(page.getByText("No access to usage")).toBeVisible();
await expect(page.getByRole("button", { name: /^notifications/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: /^chat/i })).toHaveCount(0);
});

test("with the permissions, each gated endpoint is called and its UI renders", async ({ page }) => {
await mockJsonResponse(page, "**/api/v1/identity/permissions", GRANTS);
const counts = countGatedRequests(page);

await page.goto("/");
await expect(page.getByRole("button", { name: /^notifications/i })).toBeVisible();
await expect(page.getByRole("button", { name: /^chat/i })).toBeVisible();
await expect(page.getByText(/no usage captured yet/i)).toBeVisible();

for (const path of GATED_ENDPOINTS) {
await expect.poll(() => counts[path], { message: path }).toBeGreaterThan(0);
}
});
});
2 changes: 1 addition & 1 deletion clients/dashboard/tests/overview/overview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ test.describe("overview (/)", () => {
await expect(page.getByText(/no usage captured yet/i)).toBeVisible();
});

// GET /audits requires AuditTrails.View; installShellMocks grants no permissions.
// GET /audits requires AuditTrails.View; installShellMocks does not grant it.
test("recent audits: without Audit trail access, no /audits call and a no-access state", async ({ page }) => {
let auditCalls = 0;
page.on("request", (req) => {
Expand Down
Loading