diff --git a/.agents/rules/realtime.md b/.agents/rules/realtime.md index 49574c6660..a541fb284a 100644 --- a/.agents/rules/realtime.md +++ b/.agents/rules/realtime.md @@ -6,6 +6,7 @@ `[Authorize] AppHub` mapped at **`/api/v1/realtime/hub`**. Groups: `user:{userId}`, `tenant:{tenantId}`, `channel:{channelId}`. +- **Channel-group join is connect-time + on-demand.** `OnConnectedAsync` auto-joins `user:{id}`, `tenant:{id}`, and every `channel:{id}` the user is *already* a member of. A channel that becomes relevant **after** the socket is live (a new DM, or being added to a channel) is **not** auto-joined — the client must call the membership-gated **`JoinChannel(channelId)`** hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs `OnConnectedAsync`. New-DM creation pushes `ChatChannelAdded` to each other participant's `user:{id}` group so their channel list refreshes. - **⚠️ Read the user from `Context.User`, NOT `ICurrentUser`.** `ICurrentUser` flows through `IHttpContextAccessor`, but the negotiate `HttpContext` isn't pinned to subsequent hub invocations → `ICurrentUser` returns nulls inside the hub. Use `Context.User` (the hub's `GetUserId()`/`GetTenantId()` helpers). - Broadcasts are **scoped to groups** (`tenant:{id}`, `user:{id}`, `channel:{id}`), never `Clients.All`. `PresenceChanged` goes to the tenant group. - Redis backplane is added automatically when `CachingOptions:Redis` is set (channel prefix `fsh-signalr`) — required for multi-replica. diff --git a/clients/admin/index.html b/clients/admin/index.html index dd72e62969..f3ece6e87f 100644 --- a/clients/admin/index.html +++ b/clients/admin/index.html @@ -8,7 +8,7 @@ FullStackHero — Admin diff --git a/clients/admin/src/api/files.ts b/clients/admin/src/api/files.ts new file mode 100644 index 0000000000..4b063e6d78 --- /dev/null +++ b/clients/admin/src/api/files.ts @@ -0,0 +1,138 @@ +import { apiFetch } from "@/lib/api-client"; +import type { PagedResponse } from "@/lib/api-types"; + +// Mirrors FSH.Modules.Files.Domain.Visibility — Public/Private numeric codes +// match the server's int? Visibility shape on the FileAssetDto. +export const Visibility = { + Public: 0, + Private: 1, +} as const; +export type VisibilityValue = (typeof Visibility)[keyof typeof Visibility]; + +// Mirrors FSH.Modules.Files.Domain.FileAssetStatus. +export const FileAssetStatus = { + PendingUpload: 0, + Available: 1, + Quarantined: 2, +} as const; +export type FileAssetStatusValue = (typeof FileAssetStatus)[keyof typeof FileAssetStatus]; + +// Mirrors FSH.Modules.Files.Contracts.v1.DTOs.FileAssetDto. +export type FileAssetDto = { + id: string; + ownerType: string; + ownerId?: string | null; + originalFileName: string; + contentType: string; + sizeBytes: number; + visibility: VisibilityValue; + status: FileAssetStatusValue; + scanStatus: number; + createdAtUtc: string; + publicUrl?: string | null; + /** The user that uploaded the file. Use with useUserDisplay to resolve a name. + * Older server versions before the field was added send "" — guard against it + * when deciding whether to render an "uploaded by" attribution row. */ + createdByUserId: string; + deletedOnUtc?: string | null; + deletedBy?: string | null; +}; + +export type PresignedUploadResponse = { + fileAssetId: string; + uploadUrl: string; + requiredHeaders: Record; + expiresAt: string; +}; + +export type PresignedDownloadResponse = { + url: string; + expiresAt: string; +}; + +export type RequestUploadUrlInput = { + ownerType: string; + ownerId?: string | null; + fileName: string; + contentType: string; + sizeBytes: number; + visibility: VisibilityValue; + category: string; +}; + +export function requestUploadUrl(input: RequestUploadUrlInput): Promise { + return apiFetch("/api/v1/files/upload-url", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function finalizeUpload(fileAssetId: string): Promise { + return apiFetch( + `/api/v1/files/${encodeURIComponent(fileAssetId)}/finalize`, + { method: "POST" }, + ); +} + +export function getFileMetadata(fileAssetId: string): Promise { + return apiFetch(`/api/v1/files/${encodeURIComponent(fileAssetId)}`); +} + +export function getFileDownloadUrl( + fileAssetId: string, + options: { inline?: boolean } = {}, +): Promise { + const qs = options.inline ? "?inline=true" : ""; + return apiFetch( + `/api/v1/files/${encodeURIComponent(fileAssetId)}/url${qs}`, + ); +} + +export function listMyFiles(page = 1, pageSize = 20): Promise { + return apiFetch( + `/api/v1/files/mine?page=${page}&pageSize=${pageSize}`, + ); +} + +export function listSharedFiles(page = 1, pageSize = 20): Promise { + return apiFetch( + `/api/v1/files/shared?page=${page}&pageSize=${pageSize}`, + ); +} + +/** Flip a file's visibility. Server returns the refreshed DTO so the client can patch + * its preview/list without a follow-up GET. */ +export function changeFileVisibility( + fileAssetId: string, + visibility: VisibilityValue, +): Promise { + return apiFetch( + `/api/v1/files/${encodeURIComponent(fileAssetId)}/visibility`, + { + method: "PATCH", + body: JSON.stringify({ visibility }), + }, + ); +} + +export function deleteFile(fileAssetId: string): Promise { + return apiFetch(`/api/v1/files/${encodeURIComponent(fileAssetId)}`, { + method: "DELETE", + }); +} + +export function listTrashedFiles( + pageNumber = 1, + pageSize = 20, +): Promise> { + return apiFetch>( + `/api/v1/files/trash?pageNumber=${pageNumber}&pageSize=${pageSize}`, + ); +} + +export function restoreFile(fileAssetId: string): Promise { + return apiFetch( + `/api/v1/files/${encodeURIComponent(fileAssetId)}/restore`, + { method: "POST" }, + ); +} diff --git a/clients/admin/src/components/auth/demo-accounts-dialog.tsx b/clients/admin/src/components/auth/demo-accounts-dialog.tsx new file mode 100644 index 0000000000..e0f8b25c84 --- /dev/null +++ b/clients/admin/src/components/auth/demo-accounts-dialog.tsx @@ -0,0 +1,173 @@ +import { useEffect } from "react"; +import { ArrowUpRight, ShieldCheck } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { ADMIN_DEMO_ACCOUNTS, type DemoAccount } from "@/pages/login.demo-accounts"; + +// ──────────────────────────────────────────────────────────────────────── +// DemoAccountsDialog — dev-only demo account picker for the admin app. +// +// Admin surfaces a single root/superadmin account (vs. the dashboard's +// multi-tenant tenant-picker). The layout is a single-pane account list +// rather than the dashboard's two-pane tenant-rail, since there's only +// one operator tenant. Tapping an account signs in instantly and closes +// the dialog. Gating (import.meta.env.DEV) is the caller's responsibility. +// ──────────────────────────────────────────────────────────────────────── + +interface DemoAccountsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Fired when an account row is tapped — caller signs in with these creds. */ + onPick: (account: DemoAccount) => void; +} + +export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsDialogProps) { + // Nothing to reset on re-open (single account list, no tenant rail). + useEffect(() => {}, [open]); + + const handlePick = (account: DemoAccount) => { + onOpenChange(false); + onPick(account); + }; + + return ( + + + Demo accounts + + Pick a demo account to sign in to the admin console. + + + {/* Atmospheric gradient wash */} +
+
+ + {/* Header */} +
+
+ + + + + + Dev · demo + +
+

+ Sign in as operator. +

+

+ Tap an account below — we'll fill the credentials and sign you in instantly. +

+
+ + {/* Account list */} +
+
+ + Operator accounts + +
+ + tap to sign in + +
+ +
+ {ADMIN_DEMO_ACCOUNTS.map((account, i) => ( + + ))} +
+
+ + {/* Footer */} +
+

+ + + dev only + · + Not visible in production. + +

+ + esc + +
+ +
+ ); +} + +// ─── AccountRow ────────────────────────────────────────────────────────────── + +function AccountRow({ + account, + delay, + onPick, +}: { + account: DemoAccount; + delay: number; + onPick: (account: DemoAccount) => void; +}) { + return ( + + ); +} diff --git a/clients/admin/src/components/brand-mark.tsx b/clients/admin/src/components/brand-mark.tsx index f0bdd05403..cbfbcc14d6 100644 --- a/clients/admin/src/components/brand-mark.tsx +++ b/clients/admin/src/components/brand-mark.tsx @@ -1,32 +1,44 @@ import { cn } from "@/lib/cn"; /** - * BrandMark — the Console wordmark. Two glyphs side-by-side: - * • A small chartreuse square "punctuation" mark (the only place chrome - * uses the accent at full saturation). - * • A mono "FSH" lockup with tight letter-spacing. - * Designed to feel like a system header line rather than a logo. + * BrandMark — the compact inline lockup used in the sidebar brand row and + * any surface that needs a sub-header-sized reference to the product. + * + * Matches the dashboard's brand treatment: a small gradient square carrying + * the "F" initial, paired with the "fullstackhero" wordmark with a tinted + * accent on "hero", and a small "Admin" sub-label. + * + * The chartreuse signal colour from the old Console identity is retired here. + * Colour-identity is now driven purely by the shared `--color-primary` token. */ export function BrandMark({ className }: { className?: string }) { return ( -
+
- - FSH - /admin + className={cn( + "brand-mark grid size-8 shrink-0 place-items-center rounded-lg", + "font-display text-[12px] font-bold text-[var(--color-primary-foreground)]", + )} + > + F +
+ + fullstackhero + + + Admin + +
); } /** * BrandMarkXL — splash version for the Login page. Leads with the FSH logo - * mark + "fullstackhero" wordmark, then the "Console." display monogram and - * a one-line system blurb. The chartreuse signal carries through the wordmark - * accent and the monogram period. + * mark + "fullstackhero" wordmark, then a display monogram and a one-line + * system blurb. */ export function BrandMarkXL({ className }: { className?: string }) { return ( @@ -38,14 +50,16 @@ export function BrandMarkXL({ className }: { className?: string }) { className="size-7 object-contain" /> - fullstackhero + fullstackhero + + + · platform admin - · platform admin

- Console. + Admin.

-

+

Operate every tenant on this instance — identity, multitenancy, billing, and the rest of the system surface, from one place.

diff --git a/clients/admin/src/components/file/image-input.tsx b/clients/admin/src/components/file/image-input.tsx new file mode 100644 index 0000000000..7a9c9c1f74 --- /dev/null +++ b/clients/admin/src/components/file/image-input.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Image as ImageIcon, Loader2, Upload, X, Link as LinkIcon } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/cn"; +import { useFileUpload, formatBytes } from "@/hooks/use-file-upload"; +import { getFileMetadata, Visibility } from "@/api/files"; +import { ApiRequestError } from "@/lib/api-client"; + +type Props = { + /** Current image URL (or empty). The component is fully controlled. */ + value: string; + onChange: (next: string) => void; + /** + * Owner binding for the upload. The Files module's per-OwnerType IFileAccessPolicy + * decides who can attach what. For user avatars, ownerType="User" + the user id. + */ + ownerType: string; + ownerId?: string | null; + /** Allowed extensions (lower-case w/ leading dot). Server enforces too. */ + allowedExtensions?: string[]; + maxBytes?: number; + /** Visual treatment for the preview tile — "square" for general images, "circle" for avatars. */ + shape?: "square" | "circle"; + className?: string; +}; + +const IMAGE_EXTS = [".jpg", ".jpeg", ".png", ".webp", ".gif"]; + +/** + * ImageInput — composite control that lets a user either upload a new image + * (presigned PUT to S3/MinIO) OR paste an external URL. After a successful + * upload the component fetches the FileAsset metadata to retrieve the durable + * `publicUrl` and forwards it through `onChange`. + */ +export function ImageInput({ + value, + onChange, + ownerType, + ownerId, + allowedExtensions = IMAGE_EXTS, + maxBytes = 10 * 1024 * 1024, + shape = "square", + className, +}: Props) { + const [mode, setMode] = useState<"upload" | "url">("upload"); + const { upload, progress, isUploading, reset } = useFileUpload({ + ownerType, + ownerId, + category: "Image", + visibility: Visibility.Public, // public so we get a durable URL we can persist on the entity + allowedExtensions, + maxBytes, + }); + + // After upload+finalize, fetch metadata so we get the durable publicUrl. + const resolveUrl = useMutation({ + mutationFn: async (fileAssetId: string) => { + const dto = await getFileMetadata(fileAssetId); + if (!dto.publicUrl) { + throw new Error("Server returned no publicUrl for this file."); + } + return dto.publicUrl; + }, + }); + + const handlePick = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + try { + const asset = await upload(file); + const url = await resolveUrl.mutateAsync(asset.id); + onChange(url); + toast.success("Image uploaded"); + // Clear progress so the dropzone re-arms for another upload. + setTimeout(reset, 1500); + } catch (e) { + const message = + e instanceof ApiRequestError + ? (e.problem?.detail ?? e.problem?.title ?? e.message) + : e instanceof Error + ? e.message + : "Upload failed"; + toast.error(message); + } + }; + input.click(); + }; + + const hasImage = value.length > 0; + const isWorking = isUploading || resolveUrl.isPending; + const tileClass = shape === "circle" ? "rounded-full" : "rounded-xl"; + + return ( +
+ {/* Mode toggle */} +
+ setMode("upload")} icon={}> + Upload + + setMode("url")} icon={}> + Paste URL + +
+ + {/* Preview + controls row */} +
+
+ {hasImage ? ( + + ) : isWorking ? ( + + ) : ( + + )} +
+ +
+ {mode === "upload" ? ( +
+ + {hasImage && !isWorking && ( + + )} + {isUploading && progress && ( + + {progress.percent}% · {formatBytes(progress.loaded)} / {formatBytes(progress.totalBytes)} + + )} +
+ ) : ( + onChange(e.target.value)} + placeholder="https://…" + maxLength={512} + /> + )} + +

+ {mode === "upload" + ? `JPG/PNG/WebP/GIF · up to ${formatBytes(maxBytes)}` + : "Direct link to an image you host elsewhere."} +

+
+
+
+ ); +} + +function ModeChip({ + active, + onClick, + icon, + children, +}: { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/clients/admin/src/components/impersonation/active-grants-card.tsx b/clients/admin/src/components/impersonation/active-grants-card.tsx index 6694db1e4a..b938e09298 100644 --- a/clients/admin/src/components/impersonation/active-grants-card.tsx +++ b/clients/admin/src/components/impersonation/active-grants-card.tsx @@ -9,7 +9,7 @@ import type { UserDto } from "@/api/users"; import { useAuth } from "@/auth/use-auth"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { FormSection, FormShell } from "@/components/list"; +import { SettingsSection } from "@/components/list"; import { ImpersonateDialog } from "@/components/impersonation/impersonate-dialog"; import { RevokeGrantDialog } from "@/components/impersonation/revoke-grant-dialog"; import { IdentityPermissions } from "@/lib/permissions"; @@ -65,61 +65,28 @@ export function ActiveGrantsCard({ tenantId }: { tenantId: string }) { return ( <> - - -
    - {items.map((g) => ( -
  • - -
    -
    - - - {g.actorUserName ?? g.actorUserId} - - - - {g.impersonatedUserName ?? g.impersonatedUserId} - - - - Active - -
    -
    - started {new Date(g.startedAtUtc).toLocaleTimeString()} · expires{" "} - {new Date(g.expiresAtUtc).toLocaleTimeString()} - {g.reason && <> · {truncate(g.reason, 80)}} -
    -
    - setTargetGrant(g)} - onReopen={() => setReopenGrant(g)} - /> -
  • - ))} -
-
-
+ +
    + {items.map((g) => ( + setTargetGrant(g)} + onReopen={() => setReopenGrant(g)} + /> + ))} +
+
void; + onReopen: () => void; +}) { + return ( +
  • + {/* Live-session pulse dot */} + + + {/* Session detail */} +
    +
    + + + {g.actorUserName ?? g.actorUserId} + + + + {g.impersonatedUserName ?? g.impersonatedUserId} + + + + Active + +
    +
    + started {new Date(g.startedAtUtc).toLocaleTimeString()} · expires{" "} + {new Date(g.expiresAtUtc).toLocaleTimeString()} + {g.reason && <> · {truncate(g.reason, 80)}} +
    +
    + + {/* Row actions */} + +
  • + ); +} + function RowActions({ canRevoke, canReopen, diff --git a/clients/admin/src/components/layout/app-shell.tsx b/clients/admin/src/components/layout/app-shell.tsx index 044cf0f084..4777db39b8 100644 --- a/clients/admin/src/components/layout/app-shell.tsx +++ b/clients/admin/src/components/layout/app-shell.tsx @@ -2,65 +2,66 @@ import { Suspense } from "react"; import { Outlet } from "react-router-dom"; import { Sidebar } from "@/components/layout/sidebar"; import { Topbar } from "@/components/layout/topbar"; +import { + MobileNavProvider, + MobileNavRoot, +} from "@/components/layout/mobile-nav"; /** - * AppShell — three-area layout: sidebar / topbar / canvas. The canvas owns - * the 4px subgrid texture that telegraphs "this is a console." A subtle - * radial vignette at the top-right adds depth in dark mode without - * competing with content. + * AppShell — three-area layout: sidebar / topbar / main content. + * + * Structure mirrors the dashboard shell: + * - Desktop sidebar collapses to a 52px icon rail via localStorage state. + * - MobileNavRoot mounts the Sheet drawer; MobileNavTrigger in the Topbar + * opens it on screens below `md`. + * - Suspense boundary in main catches lazy-loaded route chunks. + * + * Note: admin does not yet have an ImpersonationBanner — the admin app is + * the operator surface so it doesn't impersonate itself. If that changes, + * add a banner component here that reads from admin's AuthContext. */ export function AppShell() { return ( -
    - {/* Skip link — first focusable element so keyboard/AT users can jump - past the sidebar + topbar straight to page content. */} + + {/* Skip-to-content link — first focusable element. */} Skip to content - -
    - -
    - {/* A soft corner vignette only — the canvas-grid texture used to - live here too but read as visual noise on dense list surfaces. - Login + Dashboard hero still apply canvas-mesh locally where - the editorial reading matters. */} -
    - {/* Container width is full-bleed by default. If a page needs a - narrower measure (settings, single-form surfaces), opt into a - child wrapper there — never widen here. - Suspense boundary catches lazy-loaded routes during chunk - fetch — fallback is a tiny mono-caps slug rather than a - full skeleton, since most chunks are 10–40 KB gzipped and - resolve in well under a frame. */} -
    - - Loading view - -
    - } +
    +
    + +
    + +
    - - + {/* Suspense boundary catches lazy-loaded route chunks. + Fallback is kept minimal — most chunks resolve quickly. */} + + Loading… +
    + } + > + + +
    - +
    - + + {/* Mobile drawer — mounted at root so it portals above the shell. */} + + ); } diff --git a/clients/admin/src/components/layout/mobile-nav.tsx b/clients/admin/src/components/layout/mobile-nav.tsx index 9c16c33044..c055286731 100644 --- a/clients/admin/src/components/layout/mobile-nav.tsx +++ b/clients/admin/src/components/layout/mobile-nav.tsx @@ -1,101 +1,169 @@ -import { useEffect, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { useLocation } from "react-router-dom"; -import { Menu, X } from "lucide-react"; -import { SidebarContent } from "@/components/layout/sidebar-content"; +import { Menu } from "lucide-react"; +import { + Dialog as Sheet, + SheetContent, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { SidebarNavBody } from "@/components/layout/sidebar"; +import { findSectionForPath, sections, filterNavSpec } from "@/components/layout/nav-items"; +import { useAuth } from "@/auth/use-auth"; import { cn } from "@/lib/cn"; +import type { NavSection } from "@/components/layout/nav-items"; /** - * MobileNav — hamburger trigger + slide-over drawer for screens below `md`. - * Closes on route change, on Escape, and on backdrop click. The drawer - * uses the same SidebarContent as the desktop rail so numbering and - * active-state styling stay identical. + * Mobile nav drawer. + * + * Below `md`, the desktop is hidden. mounts + * a left-edge Sheet that the Topbar's hamburger triggers. The drawer reuses + * so there is one source of truth for the nav. + * + * Composition: + * + * ← renders the Sheet (mount once at root) + * ← the hamburger (place in Topbar) + * */ -export function MobileNav() { + +type MobileNavContextValue = { + open: boolean; + setOpen: (next: boolean) => void; +}; + +const MobileNavContext = createContext(null); + +export function useMobileNav() { + const ctx = useContext(MobileNavContext); + if (!ctx) throw new Error("useMobileNav must be used within MobileNavProvider"); + return ctx; +} + +export function MobileNavProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); + const value = useMemo(() => ({ open, setOpen }), [open]); + return ( + {children} + ); +} + +/** + * The Sheet itself — mount once near the root (inside the provider). + * Auto-closes on route changes. + */ +export function MobileNavRoot() { + const { open, setOpen } = useMobileNav(); const location = useLocation(); - const buttonRef = useRef(null); + const { user, permissionsHydrated } = useAuth(); + + const granted = permissionsHydrated ? (user?.permissions ?? []) : []; + const visibleSections: NavSection[] = useMemo( + () => + sections + .map((s) => ({ ...s, items: filterNavSpec(s.items, granted) })) + .filter((s) => s.items.length > 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [granted.join(",")], + ); + + const [openSection, setOpenSection] = useState(() => + findSectionForPath(location.pathname), + ); - // Close on route change. We intentionally listen on pathname only — the - // search/hash changing shouldn't dismiss the drawer. useEffect(() => { - setOpen(false); + setOpenSection(findSectionForPath(location.pathname)); }, [location.pathname]); - // Close on Escape; lock body scroll while open. + // Close the drawer on route changes (e.g. browser back/forward). useEffect(() => { - if (!open) return; - // Capture the trigger node now (it's stable) so the cleanup focuses the - // right element without reading a possibly-changed ref at teardown. - const trigger = buttonRef.current; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpen(false); - }; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - document.addEventListener("keydown", onKey); - return () => { - document.body.style.overflow = previousOverflow; - document.removeEventListener("keydown", onKey); - // Return focus to the trigger so the next tap-target is the menu button. - trigger?.focus(); - }; - }, [open]); + setOpen(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname]); return ( - <> - - - {/* Drawer + backdrop */} -
    - {/* Backdrop — dim + slight blur. canvas-grid sits under it so the - console texture stays continuous through the dim. */} - - setOpen(false)} /> - -
    - + F + +
    + + fullstackhero + + + Admin + +
    + + + setOpen(false)} + /> + +
    +

    + v0.1 · admin +

    +
    + + ); } + +/** + * Hamburger trigger — `md:hidden`. Place in the Topbar. + */ +export function MobileNavTrigger({ className }: { className?: string }) { + const { setOpen } = useMobileNav(); + const onClick = useCallback(() => setOpen(true), [setOpen]); + return ( + + ); +} + +/** + * @deprecated Use MobileNavTrigger + MobileNavProvider + MobileNavRoot instead. + * Kept only for any lingering direct usages of the old . + */ +export { MobileNavTrigger as MobileNav }; diff --git a/clients/admin/src/components/layout/nav-items.ts b/clients/admin/src/components/layout/nav-items.ts index 0afda68aaf..c31223454c 100644 --- a/clients/admin/src/components/layout/nav-items.ts +++ b/clients/admin/src/components/layout/nav-items.ts @@ -4,6 +4,7 @@ import { LayoutDashboard, Receipt, ScrollText, + Settings, ShieldCheck, UserCog, UsersRound, @@ -17,28 +18,147 @@ import { MultitenancyPermissions, } from "@/lib/permissions"; -export type NavItem = { +/** A single nav destination — label, route, icon, optional perm guard. */ +export type NavSpec = { to: string; label: string; icon: LucideIcon; - /** Sub-path prefix that should also light this nav item. */ - matchPrefix?: string; - /** - * Permissions the user must hold to see this item in the sidebar. Mirrors - * the route's RouteGuard exactly — if the user can't reach the page they - * shouldn't see the link. Omit (or pass []) for surfaces every signed-in - * user can hit (Overview, Health). - */ + /** One or more permissions the user must hold to see this item. */ perms?: readonly string[]; }; -/** - * Primary navigation. Shared between the desktop sidebar and the mobile - * drawer so the magazine-table-of-contents numbering stays consistent. - * - * Per-entry `perms` are kept in lockstep with routes.tsx — see useVisibleNavItems - * for the filtering. If a route's gating changes, update both places. - */ +/** A collapsible section that groups related NavSpecs. */ +export type NavSection = { + id: string; + caption: string; + icon: LucideIcon; + items: NavSpec[]; +}; + +// ─── Top-level singletons ──────────────────────────────────────────────────── + +export const topNavTop: NavSpec[] = [ + { to: "/", label: "Overview", icon: LayoutDashboard }, +]; + +export const topNavBottom: NavSpec[] = [ + { to: "/settings", label: "Settings", icon: Settings }, +]; + +// ─── Section accordions ────────────────────────────────────────────────────── + +export const sections: NavSection[] = [ + { + id: "multitenancy", + caption: "Tenants", + icon: Building2, + items: [ + { + to: "/tenants", + label: "Tenants", + icon: Building2, + perms: [MultitenancyPermissions.Tenants.View], + }, + ], + }, + { + id: "identity", + caption: "Identity", + icon: UsersRound, + items: [ + { + to: "/users", + label: "Users", + icon: UsersRound, + perms: [IdentityPermissions.Users.View], + }, + { + to: "/roles", + label: "Roles", + icon: ShieldCheck, + perms: [IdentityPermissions.Roles.View], + }, + { + to: "/impersonation", + label: "Impersonation", + icon: UserCog, + perms: [IdentityPermissions.Impersonation.View], + }, + ], + }, + { + id: "operations", + caption: "Operations", + icon: Activity, + items: [ + { + to: "/billing", + label: "Billing", + icon: Receipt, + perms: [BillingPermissions.View], + }, + { + to: "/webhooks", + label: "Webhooks", + icon: Webhook, + }, + { + to: "/audits", + label: "Audits", + icon: ScrollText, + perms: [AuditingPermissions.AuditTrails.View], + }, + { + to: "/health", + label: "Health", + icon: Activity, + }, + ], + }, +]; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Find the section id whose items contain the given path (best prefix match). */ +export function findSectionForPath(pathname: string): string | null { + let bestId: string | null = null; + let bestLen = 0; + for (const s of sections) { + for (const item of s.items) { + if ( + (item.to === "/" && pathname === "/") || + (item.to !== "/" && pathname.startsWith(item.to)) + ) { + if (item.to.length > bestLen) { + bestLen = item.to.length; + bestId = s.id; + } + } + } + } + return bestId; +} + +/** Returns true when the given NavSpec is the active route. */ +export function isNavItemActive(item: NavSpec, pathname: string): boolean { + if (item.to === "/") return pathname === "/"; + return pathname === item.to || pathname.startsWith(`${item.to}/`); +} + +/** Filter nav items (and sections) based on granted permissions. */ +export function filterNavSpec(items: NavSpec[], granted: readonly string[]): NavSpec[] { + return items.filter((item) => { + if (!item.perms || item.perms.length === 0) return true; + return item.perms.every((p) => granted.includes(p)); + }); +} + +// ── Legacy flat export (used by sidebar-content & permission gating elsewhere) ── + +/** @deprecated Use sections / topNavTop / topNavBottom instead. */ +export type NavItem = NavSpec & { matchPrefix?: string }; + +/** @deprecated Flat list kept only for call-sites still importing NAV_ITEMS. */ export const NAV_ITEMS: NavItem[] = [ { to: "/", label: "Overview", icon: LayoutDashboard }, { @@ -92,13 +212,7 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/health", label: "Health", icon: Activity, matchPrefix: "/health" }, ]; -export function isNavItemActive(item: NavItem, pathname: string): boolean { - if (item.matchPrefix) { - return pathname === item.matchPrefix || pathname.startsWith(`${item.matchPrefix}/`); - } - return pathname === item.to; -} - +/** @deprecated Use filterNavSpec instead. */ export function filterNavItems(items: NavItem[], grantedPermissions: readonly string[]): NavItem[] { return items.filter((item) => { if (!item.perms || item.perms.length === 0) return true; diff --git a/clients/admin/src/components/layout/sidebar-content.tsx b/clients/admin/src/components/layout/sidebar-content.tsx index 819e3c6a88..54af66d4cc 100644 --- a/clients/admin/src/components/layout/sidebar-content.tsx +++ b/clients/admin/src/components/layout/sidebar-content.tsx @@ -1,96 +1,8 @@ -import { useMemo } from "react"; -import { NavLink, useLocation } from "react-router-dom"; -import { BrandMark } from "@/components/brand-mark"; -import { NAV_ITEMS, filterNavItems, isNavItemActive } from "@/components/layout/nav-items"; -import { useAuth } from "@/auth/use-auth"; -import { cn } from "@/lib/cn"; - -type SidebarContentProps = { - /** Optional click hook — used by the mobile drawer to close on navigate. */ - onNavigate?: () => void; -}; - /** - * SidebarContent — the inner nav layout shared between the desktop Sidebar - * and the mobile drawer. Magazine TOC numbering (01, 02, …), chartreuse - * active-rail on the left of the selected entry, mono footer kicker. + * sidebar-content.tsx — compatibility shim. + * + * The sidebar's nav body now lives in sidebar.tsx as . + * This file is kept so any lingering imports (e.g. tests) don't break. + * Mobile nav uses directly. */ -export function SidebarContent({ onNavigate }: SidebarContentProps) { - const location = useLocation(); - const { user, permissionsHydrated } = useAuth(); - // Render the unfiltered list while permissions are still loading so the - // sidebar doesn't flash empty on cold-start. Filter once they're known. - const items = useMemo(() => { - if (!permissionsHydrated) return NAV_ITEMS; - return filterNavItems(NAV_ITEMS, user?.permissions ?? []); - }, [permissionsHydrated, user?.permissions]); - return ( -
    - {/* Brand block */} -
    - -
    - - {/* Section marker */} -
    -
    // Navigation
    -
    - - {/* Nav */} - - - {/* Footer credit */} -
    -
    v0.1 · console
    -
    - platform · administration · interface -
    -
    -
    - ); -} +export { SidebarNavBody as SidebarContent } from "@/components/layout/sidebar"; diff --git a/clients/admin/src/components/layout/sidebar.tsx b/clients/admin/src/components/layout/sidebar.tsx index e5e8259fe6..ce3e5aba82 100644 --- a/clients/admin/src/components/layout/sidebar.tsx +++ b/clients/admin/src/components/layout/sidebar.tsx @@ -1,14 +1,423 @@ -import { SidebarContent } from "@/components/layout/sidebar-content"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { NavLink, useLocation } from "react-router-dom"; +import { ChevronDown, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useAuth } from "@/auth/use-auth"; +import { + findSectionForPath, + filterNavSpec, + sections, + topNavBottom, + topNavTop, + type NavSection, + type NavSpec, +} from "@/components/layout/nav-items"; + +const COLLAPSED_KEY = "fsh.admin.sidebar.collapsed"; + +/** Persisted collapsed state, reads localStorage on mount. */ +function useCollapsedSidebar() { + const [collapsed, setRaw] = useState(() => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(COLLAPSED_KEY) === "true"; + } catch { + return false; + } + }); + const setCollapsed = useCallback((next: boolean) => { + setRaw(next); + try { + window.localStorage.setItem(COLLAPSED_KEY, String(next)); + } catch { + /* storage unavailable */ + } + }, []); + return { collapsed, toggle: () => setCollapsed(!collapsed) }; +} -/** - * Sidebar — desktop-only fixed-width rail. Below `md` the AppShell mounts - * instead, which uses the same in a - * slide-over drawer. - */ export function Sidebar() { + const { collapsed, toggle } = useCollapsedSidebar(); + const location = useLocation(); + const { user, permissionsHydrated } = useAuth(); + + // Permission-filtered sections + const granted = permissionsHydrated ? (user?.permissions ?? []) : []; + const visibleSections: NavSection[] = useMemo( + () => + sections + .map((s) => ({ ...s, items: filterNavSpec(s.items, granted) })) + .filter((s) => s.items.length > 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [granted.join(",")], + ); + + // Single-select accordion: which section is currently open. + const [openSection, setOpenSection] = useState(() => + findSectionForPath(location.pathname), + ); + + // Re-sync the open section on every route change. + useEffect(() => { + const next = findSectionForPath(location.pathname); + setOpenSection(next); + }, [location.pathname]); + return ( -