diff --git a/.agents/rules/frontend/dashboard.md b/.agents/rules/frontend/dashboard.md index a680ed67c4..97f419b762 100644 --- a/.agents/rules/frontend/dashboard.md +++ b/.agents/rules/frontend/dashboard.md @@ -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()` 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. diff --git a/clients/dashboard/src/components/notifications/chat-unread-badge.tsx b/clients/dashboard/src/components/notifications/chat-unread-badge.tsx index 2094f449a5..f6d4a3aec0 100644 --- a/clients/dashboard/src/components/notifications/chat-unread-badge.tsx +++ b/clients/dashboard/src/components/notifications/chat-unread-badge.tsx @@ -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"; /** @@ -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, }); @@ -30,6 +38,8 @@ export function ChatUnreadBadge() { [channels], ); + if (!canViewChannels) return null; + return (