diff --git a/clients/admin/src/components/layout/nav-items.ts b/clients/admin/src/components/layout/nav-items.ts index c31223454c..d2cc4626b4 100644 --- a/clients/admin/src/components/layout/nav-items.ts +++ b/clients/admin/src/components/layout/nav-items.ts @@ -16,6 +16,7 @@ import { BillingPermissions, IdentityPermissions, MultitenancyPermissions, + WebhooksPermissions, } from "@/lib/permissions"; /** A single nav destination — label, route, icon, optional perm guard. */ @@ -101,6 +102,7 @@ export const sections: NavSection[] = [ to: "/webhooks", label: "Webhooks", icon: Webhook, + perms: [WebhooksPermissions.Subscriptions.View], }, { to: "/audits", @@ -208,6 +210,7 @@ export const NAV_ITEMS: NavItem[] = [ label: "Webhooks", icon: Webhook, matchPrefix: "/webhooks", + perms: [WebhooksPermissions.Subscriptions.View], }, { to: "/health", label: "Health", icon: Activity, matchPrefix: "/health" }, ]; diff --git a/clients/admin/src/lib/permissions.ts b/clients/admin/src/lib/permissions.ts index 7fb8242e42..1f1524e5d8 100644 --- a/clients/admin/src/lib/permissions.ts +++ b/clients/admin/src/lib/permissions.ts @@ -68,6 +68,15 @@ export const AuditingPermissions = Object.freeze({ }, } as const); +export const WebhooksPermissions = Object.freeze({ + Subscriptions: { + View: "Permissions.Webhooks.View", + Create: "Permissions.Webhooks.Create", + Delete: "Permissions.Webhooks.Delete", + Test: "Permissions.Webhooks.Test", + }, +} as const); + // ─── Catalog (drives the Role editor) ─────────────────────────────────── export type PermissionEntry = { @@ -164,6 +173,16 @@ export const PERMISSION_CATALOG: readonly PermissionGroup[] = [ { name: IdentityPermissions.Impersonation.Revoke, description: "Revoke active impersonation grants" }, ], }, + { + category: "Webhooks", + blurb: "Manage outbound webhook subscriptions and inspect their deliveries.", + entries: [ + { name: WebhooksPermissions.Subscriptions.View, description: "View webhook subscriptions & deliveries", basic: true }, + { name: WebhooksPermissions.Subscriptions.Create, description: "Create webhook subscriptions" }, + { name: WebhooksPermissions.Subscriptions.Delete, description: "Delete webhook subscriptions" }, + { name: WebhooksPermissions.Subscriptions.Test, description: "Send test webhook deliveries" }, + ], + }, ]; export const ALL_PERMISSION_NAMES: readonly string[] = PERMISSION_CATALOG.flatMap((g) => diff --git a/clients/admin/src/routes.tsx b/clients/admin/src/routes.tsx index 8ea633ca3d..9e6840464b 100644 --- a/clients/admin/src/routes.tsx +++ b/clients/admin/src/routes.tsx @@ -12,6 +12,7 @@ import { BillingPermissions, IdentityPermissions, MultitenancyPermissions, + WebhooksPermissions, } from "@/lib/permissions"; // Lazy-loaded pages — each `import()` becomes its own bundle chunk so the @@ -192,9 +193,24 @@ export const router = createBrowserRouter([ element: , }, - // Webhooks — any signed-in user can manage their tenant's subscriptions - { path: "webhooks", element: }, - { path: "webhooks/:id", element: }, + // Webhooks — list/detail both read subscriptions, which the server + // gates on Webhooks.View (granted to Basic by default). + { + path: "webhooks", + element: ( + + + + ), + }, + { + path: "webhooks/:id", + element: ( + + + + ), + }, // Notifications inbox — available to every signed-in user { path: "notifications", element: }, diff --git a/clients/admin/tests/helpers/shell-mocks.ts b/clients/admin/tests/helpers/shell-mocks.ts index cd2e48f123..420b06e6b3 100644 --- a/clients/admin/tests/helpers/shell-mocks.ts +++ b/clients/admin/tests/helpers/shell-mocks.ts @@ -34,6 +34,10 @@ export const ADMIN_PERMS = [ "Permissions.Billing.Manage", "Permissions.AuditTrails.View", "Permissions.AuditTrails.ViewCrossTenant", + "Permissions.Webhooks.View", + "Permissions.Webhooks.Create", + "Permissions.Webhooks.Delete", + "Permissions.Webhooks.Test", ] as const; export const ADMIN_PROFILE = { diff --git a/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx b/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx index 80fb512538..f05a195946 100644 --- a/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx +++ b/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx @@ -37,6 +37,7 @@ import { import { useAuth } from "@/auth/use-auth"; import { useTheme } from "@/components/theme/theme-provider"; import { accents } from "@/components/theme/appearance-options"; +import { ALL_TRASH_PERMISSIONS } from "@/lib/trash-permissions"; import { cn } from "@/lib/cn"; /** @@ -56,6 +57,14 @@ type ActionItem = { keywords?: string[]; shortcut?: string; perform: () => void; + /** + * Permission gates — same semantics as NavSpec in layout/nav-data.ts: the item + * is hidden unless the user holds `perm` AND at least one of `anyPerm`. Each + * value mirrors what the destination page's API (or the create action's + * endpoint) enforces server-side, so the palette never offers a guaranteed 403. + */ + perm?: string; + anyPerm?: readonly string[]; }; type ActionGroup = { @@ -71,8 +80,9 @@ export function CommandPaletteDialog({ onOpenChange: (next: boolean) => void; }) { const navigate = useNavigate(); - const { logout } = useAuth(); + const { user, logout } = useAuth(); const { setMode, setAccent } = useTheme(); + const permissions = useMemo(() => user?.permissions ?? [], [user]); // Build the action set fresh each time the palette opens. The ones // that navigate close the palette; the ones that mutate appearance @@ -83,7 +93,13 @@ export function CommandPaletteDialog({ navigate(path); close(); }; - return [ + // Mirrors isNavItemVisible in layout/nav-data.ts. + const visible = (item: ActionItem) => { + if (item.perm && !permissions.includes(item.perm)) return false; + if (item.anyPerm && !item.anyPerm.some((p) => permissions.includes(p))) return false; + return true; + }; + const allGroups: ActionGroup[] = [ { heading: "Navigate", items: [ @@ -110,6 +126,7 @@ export function CommandPaletteDialog({ Icon: MessageSquare, keywords: ["messages", "dm", "channel", "conversation"], perform: go("/chat"), + perm: "Permissions.Chat.Channels.View", }, { id: "nav-files", @@ -118,6 +135,7 @@ export function CommandPaletteDialog({ Icon: Folder, keywords: ["storage", "uploads", "documents"], perform: go("/files"), + perm: "Permissions.Files.Upload", }, { id: "nav-users", @@ -126,6 +144,7 @@ export function CommandPaletteDialog({ Icon: Users, keywords: ["identity", "people", "members", "team"], perform: go("/identity/users"), + perm: "Permissions.Users.Update", }, { id: "nav-roles", @@ -134,6 +153,7 @@ export function CommandPaletteDialog({ Icon: ShieldCheck, keywords: ["identity", "permissions", "rbac"], perform: go("/identity/roles"), + perm: "Permissions.Roles.Update", }, { id: "nav-groups", @@ -142,6 +162,7 @@ export function CommandPaletteDialog({ Icon: Users, keywords: ["identity", "teams", "org"], perform: go("/identity/groups"), + perm: "Permissions.Groups.Update", }, { id: "nav-products", @@ -150,6 +171,7 @@ export function CommandPaletteDialog({ Icon: Package, keywords: ["catalog", "sku", "inventory", "stock"], perform: go("/catalog/products"), + perm: "Permissions.Catalog.Products.View", }, { id: "nav-brands", @@ -158,6 +180,7 @@ export function CommandPaletteDialog({ Icon: Tag, keywords: ["catalog"], perform: go("/catalog/brands"), + perm: "Permissions.Catalog.Brands.View", }, { id: "nav-categories", @@ -166,6 +189,7 @@ export function CommandPaletteDialog({ Icon: Boxes, keywords: ["catalog"], perform: go("/catalog/categories"), + perm: "Permissions.Catalog.Categories.View", }, { id: "nav-tickets", @@ -174,6 +198,7 @@ export function CommandPaletteDialog({ Icon: LifeBuoy, keywords: ["support", "issues", "helpdesk"], perform: go("/tickets"), + perm: "Permissions.Tickets.View", }, { id: "nav-invoices", @@ -182,6 +207,7 @@ export function CommandPaletteDialog({ Icon: Receipt, keywords: ["billing", "payment"], perform: go("/invoices"), + perm: "Permissions.Billing.View", }, { id: "nav-health", @@ -198,6 +224,7 @@ export function CommandPaletteDialog({ Icon: ScrollText, keywords: ["audit", "log", "compliance", "security", "trace", "correlation"], perform: go("/system/audits"), + perm: "Permissions.AuditTrails.View", }, { id: "nav-trash", @@ -206,6 +233,7 @@ export function CommandPaletteDialog({ Icon: ScrollText, keywords: ["recycle", "deleted", "restore"], perform: go("/system/trash"), + anyPerm: ALL_TRASH_PERMISSIONS, }, { id: "nav-sessions", @@ -214,6 +242,7 @@ export function CommandPaletteDialog({ Icon: Shield, keywords: ["devices", "logins"], perform: go("/system/sessions"), + perm: "Permissions.Sessions.ViewAll", }, { id: "nav-settings", @@ -234,6 +263,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "invite", "register", "identity"], perform: go("/identity/users?action=create"), + perm: "Permissions.Users.Create", }, { id: "create-role", @@ -242,6 +272,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "permissions", "rbac"], perform: go("/identity/roles?action=create"), + perm: "Permissions.Roles.Create", }, { id: "create-group", @@ -250,6 +281,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "team", "org"], perform: go("/identity/groups?action=create"), + perm: "Permissions.Groups.Create", }, { id: "create-product", @@ -258,6 +290,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "catalog", "sku"], perform: go("/catalog/products?action=create"), + perm: "Permissions.Catalog.Products.Create", }, { id: "create-brand", @@ -266,6 +299,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "catalog"], perform: go("/catalog/brands?action=create"), + perm: "Permissions.Catalog.Brands.Create", }, { id: "create-category", @@ -274,6 +308,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "catalog"], perform: go("/catalog/categories?action=create"), + perm: "Permissions.Catalog.Categories.Create", }, { id: "create-ticket", @@ -282,6 +317,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "support", "issue"], perform: go("/tickets?action=create"), + perm: "Permissions.Tickets.Create", }, { id: "create-channel", @@ -290,6 +326,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "chat", "channel"], perform: go("/chat?action=create-channel"), + perm: "Permissions.Chat.Channels.Create", }, { id: "create-file", @@ -298,6 +335,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "upload", "attach"], perform: go("/files?action=upload"), + perm: "Permissions.Files.Upload", }, ], }, @@ -398,7 +436,12 @@ export function CommandPaletteDialog({ ], }, ]; - }, [navigate, onOpenChange, setMode, setAccent, logout]); + // Drop items the user can't access, then drop any group left empty — + // same shape as visibleSections() in layout/nav-data.ts. + return allGroups + .map((g) => ({ ...g, items: g.items.filter(visible) })) + .filter((g) => g.items.length > 0); + }, [navigate, onOpenChange, setMode, setAccent, logout, permissions]); return ( diff --git a/clients/dashboard/src/components/layout/nav-data.ts b/clients/dashboard/src/components/layout/nav-data.ts index 37e2f837b4..5eda502fa3 100644 --- a/clients/dashboard/src/components/layout/nav-data.ts +++ b/clients/dashboard/src/components/layout/nav-data.ts @@ -53,8 +53,11 @@ export type NavSection = { // Settings is account-scoped and lives at the very bottom. export const topNavTop: NavSpec[] = [ { to: "/", label: "Overview", icon: LayoutDashboard }, - { to: "/chat", label: "Chat", icon: MessageCircle }, - { to: "/files", label: "My Files", icon: FolderOpen }, + // Each gate mirrors the permission the page's primary list endpoint enforces + // server-side (Chat → channels list, Files → /files/mine). Same convention + // as trash-permissions.ts: if the endpoint's permission changes, mirror it. + { to: "/chat", label: "Chat", icon: MessageCircle, perm: "Permissions.Chat.Channels.View" }, + { to: "/files", label: "My Files", icon: FolderOpen, perm: "Permissions.Files.Upload" }, ]; export const topNavBottom: NavSpec[] = [ @@ -68,9 +71,10 @@ export const sections: NavSection[] = [ caption: "Operations", icon: Activity, items: [ + // Live activity is SSE-backed; the stream is auth-only (no permission), so no gate. { to: "/activity", label: "Live activity", icon: Activity }, - { to: "/subscription", label: "Subscription", icon: CreditCard }, - { to: "/invoices", label: "Invoices", icon: Receipt }, + { to: "/subscription", label: "Subscription", icon: CreditCard, perm: "Permissions.Billing.View" }, + { to: "/invoices", label: "Invoices", icon: Receipt, perm: "Permissions.Billing.View" }, ], }, { @@ -78,9 +82,9 @@ export const sections: NavSection[] = [ caption: "Catalog", icon: Package, items: [ - { to: "/catalog/products", label: "Products", icon: Package }, - { to: "/catalog/brands", label: "Brands", icon: Tags }, - { to: "/catalog/categories", label: "Categories", icon: FolderTree }, + { to: "/catalog/products", label: "Products", icon: Package, perm: "Permissions.Catalog.Products.View" }, + { to: "/catalog/brands", label: "Brands", icon: Tags, perm: "Permissions.Catalog.Brands.View" }, + { to: "/catalog/categories", label: "Categories", icon: FolderTree, perm: "Permissions.Catalog.Categories.View" }, ], }, { @@ -88,7 +92,7 @@ export const sections: NavSection[] = [ caption: "Helpdesk", icon: Ticket, items: [ - { to: "/tickets", label: "Tickets", icon: Ticket }, + { to: "/tickets", label: "Tickets", icon: Ticket, perm: "Permissions.Tickets.View" }, ], }, { @@ -109,6 +113,7 @@ export const sections: NavSection[] = [ caption: "System", icon: HeartPulse, items: [ + // Health hits the anonymous /health/ready probe — visible to everyone. { to: "/system/health", label: "Health", icon: HeartPulse }, { to: "/system/audits", label: "Audit trail", icon: ScrollText, perm: "Permissions.AuditTrails.View" }, { to: "/system/sessions", label: "Sessions", icon: Wifi, perm: "Permissions.Sessions.ViewAll" }, diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 404daeb6f5..c4661c3054 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -120,4 +120,11 @@ + + + + \ No newline at end of file diff --git a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs index afcfe8ff0b..74ec5d9f43 100644 --- a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs +++ b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs @@ -7,15 +7,18 @@ using FSH.Modules.Billing.Contracts; using FSH.Modules.Billing.Data; using FSH.Modules.Billing.Domain; +using FSH.Modules.Catalog.Contracts.Authorization; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; +using FSH.Modules.Identity.Contracts.Authorization; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; using FSH.Modules.Multitenancy.Contracts; using FSH.Modules.Multitenancy.Data; using FSH.Modules.Multitenancy.Provisioning; +using FSH.Modules.Tickets.Contracts.Authorization; using FSH.Modules.Tickets.Contracts.Dtos; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; @@ -734,48 +737,52 @@ private static IReadOnlyList BuildGlobexUsers() => new("globex.dave", "dave@globex.com", "Dave", "Hartwell", [RoleConstants.Basic]), ]; + // Permission claims reference the module contracts constants — never raw strings. + // A hand-typed name that doesn't match a registry entry (e.g. the old + // "Permissions.Brands.View" vs the real "Permissions.Catalog.Brands.View") + // is a claim that grants nothing, silently. private static IReadOnlyList BuildAcmeCustomRoles() => [ new( "Manager", "Operations manager — full catalog + tickets + read-only users.", [ - "Permissions.Users.View", - "Permissions.Users.Update", - "Permissions.UserRoles.View", - "Permissions.Roles.View", - "Permissions.Sessions.View", - "Permissions.Sessions.Revoke", - "Permissions.Groups.View", - "Permissions.Brands.View", - "Permissions.Brands.Create", - "Permissions.Brands.Update", - "Permissions.Brands.Delete", - "Permissions.Categories.View", - "Permissions.Categories.Create", - "Permissions.Categories.Update", - "Permissions.Categories.Delete", - "Permissions.Products.View", - "Permissions.Products.Create", - "Permissions.Products.Update", - "Permissions.Products.Delete", - "Permissions.Tickets.View", - "Permissions.Tickets.Create", - "Permissions.Tickets.Update", - "Permissions.Tickets.Delete", + IdentityPermissions.Users.View, + IdentityPermissions.Users.Update, + IdentityPermissions.UserRoles.View, + IdentityPermissions.Roles.View, + IdentityPermissions.Sessions.View, + IdentityPermissions.Sessions.Revoke, + IdentityPermissions.Groups.View, + CatalogPermissions.Brands.View, + CatalogPermissions.Brands.Create, + CatalogPermissions.Brands.Update, + CatalogPermissions.Brands.Delete, + CatalogPermissions.Categories.View, + CatalogPermissions.Categories.Create, + CatalogPermissions.Categories.Update, + CatalogPermissions.Categories.Delete, + CatalogPermissions.Products.View, + CatalogPermissions.Products.Create, + CatalogPermissions.Products.Update, + CatalogPermissions.Products.Delete, + TicketsPermissions.Tickets.View, + TicketsPermissions.Tickets.Create, + TicketsPermissions.Tickets.Update, + TicketsPermissions.Tickets.Delete, ]), new( "Support", "Support agent — full tickets + read-only users.", [ - "Permissions.Users.View", - "Permissions.UserRoles.View", - "Permissions.Sessions.View", - "Permissions.Sessions.Revoke", - "Permissions.Tickets.View", - "Permissions.Tickets.Create", - "Permissions.Tickets.Update", + IdentityPermissions.Users.View, + IdentityPermissions.UserRoles.View, + IdentityPermissions.Sessions.View, + IdentityPermissions.Sessions.Revoke, + TicketsPermissions.Tickets.View, + TicketsPermissions.Tickets.Create, + TicketsPermissions.Tickets.Update, ]), ];