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
3 changes: 3 additions & 0 deletions clients/admin/src/components/layout/nav-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
BillingPermissions,
IdentityPermissions,
MultitenancyPermissions,
WebhooksPermissions,
} from "@/lib/permissions";

/** A single nav destination — label, route, icon, optional perm guard. */
Expand Down Expand Up @@ -101,6 +102,7 @@ export const sections: NavSection[] = [
to: "/webhooks",
label: "Webhooks",
icon: Webhook,
perms: [WebhooksPermissions.Subscriptions.View],
},
{
to: "/audits",
Expand Down Expand Up @@ -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" },
];
Expand Down
19 changes: 19 additions & 0 deletions clients/admin/src/lib/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) =>
Expand Down
22 changes: 19 additions & 3 deletions clients/admin/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
BillingPermissions,
IdentityPermissions,
MultitenancyPermissions,
WebhooksPermissions,
} from "@/lib/permissions";

// Lazy-loaded pages — each `import()` becomes its own bundle chunk so the
Expand Down Expand Up @@ -192,9 +193,24 @@ export const router = createBrowserRouter([
element: <Navigate to="/audits" replace />,
},

// Webhooks — any signed-in user can manage their tenant's subscriptions
{ path: "webhooks", element: <WebhooksListPage /> },
{ path: "webhooks/:id", element: <WebhookDetailPage /> },
// Webhooks — list/detail both read subscriptions, which the server
// gates on Webhooks.View (granted to Basic by default).
{
path: "webhooks",
element: (
<RouteGuard perms={[WebhooksPermissions.Subscriptions.View]}>
<WebhooksListPage />
</RouteGuard>
),
},
{
path: "webhooks/:id",
element: (
<RouteGuard perms={[WebhooksPermissions.Subscriptions.View]}>
<WebhookDetailPage />
</RouteGuard>
),
},

// Notifications inbox — available to every signed-in user
{ path: "notifications", element: <NotificationsInboxPage /> },
Expand Down
4 changes: 4 additions & 0 deletions clients/admin/tests/helpers/shell-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -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: [
Expand All @@ -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",
Expand All @@ -118,6 +135,7 @@ export function CommandPaletteDialog({
Icon: Folder,
keywords: ["storage", "uploads", "documents"],
perform: go("/files"),
perm: "Permissions.Files.Upload",
},
{
id: "nav-users",
Expand All @@ -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",
Expand All @@ -134,6 +153,7 @@ export function CommandPaletteDialog({
Icon: ShieldCheck,
keywords: ["identity", "permissions", "rbac"],
perform: go("/identity/roles"),
perm: "Permissions.Roles.Update",
},
{
id: "nav-groups",
Expand All @@ -142,6 +162,7 @@ export function CommandPaletteDialog({
Icon: Users,
keywords: ["identity", "teams", "org"],
perform: go("/identity/groups"),
perm: "Permissions.Groups.Update",
},
{
id: "nav-products",
Expand All @@ -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",
Expand All @@ -158,6 +180,7 @@ export function CommandPaletteDialog({
Icon: Tag,
keywords: ["catalog"],
perform: go("/catalog/brands"),
perm: "Permissions.Catalog.Brands.View",
},
{
id: "nav-categories",
Expand All @@ -166,6 +189,7 @@ export function CommandPaletteDialog({
Icon: Boxes,
keywords: ["catalog"],
perform: go("/catalog/categories"),
perm: "Permissions.Catalog.Categories.View",
},
{
id: "nav-tickets",
Expand All @@ -174,6 +198,7 @@ export function CommandPaletteDialog({
Icon: LifeBuoy,
keywords: ["support", "issues", "helpdesk"],
perform: go("/tickets"),
perm: "Permissions.Tickets.View",
},
{
id: "nav-invoices",
Expand All @@ -182,6 +207,7 @@ export function CommandPaletteDialog({
Icon: Receipt,
keywords: ["billing", "payment"],
perform: go("/invoices"),
perm: "Permissions.Billing.View",
},
{
id: "nav-health",
Expand All @@ -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",
Expand All @@ -206,6 +233,7 @@ export function CommandPaletteDialog({
Icon: ScrollText,
keywords: ["recycle", "deleted", "restore"],
perform: go("/system/trash"),
anyPerm: ALL_TRASH_PERMISSIONS,
},
{
id: "nav-sessions",
Expand All @@ -214,6 +242,7 @@ export function CommandPaletteDialog({
Icon: Shield,
keywords: ["devices", "logins"],
perform: go("/system/sessions"),
perm: "Permissions.Sessions.ViewAll",
},
{
id: "nav-settings",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -298,6 +335,7 @@ export function CommandPaletteDialog({
Icon: Plus,
keywords: ["new", "upload", "attach"],
perform: go("/files?action=upload"),
perm: "Permissions.Files.Upload",
},
],
},
Expand Down Expand Up @@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
Expand Down
21 changes: 13 additions & 8 deletions clients/dashboard/src/components/layout/nav-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand All @@ -68,27 +71,28 @@ 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" },
],
},
{
id: "catalog",
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" },
],
},
{
id: "helpdesk",
caption: "Helpdesk",
icon: Ticket,
items: [
{ to: "/tickets", label: "Tickets", icon: Ticket },
{ to: "/tickets", label: "Tickets", icon: Ticket, perm: "Permissions.Tickets.View" },
],
},
{
Expand All @@ -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" },
Expand Down
Loading
Loading