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
20 changes: 17 additions & 3 deletions clients/dashboard/src/components/layout/nav-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
UsersRound,
Wifi,
} from "lucide-react";
import { ALL_TRASH_PERMISSIONS } from "@/lib/trash-permissions";

export type NavSpec = {
to: string;
Expand All @@ -30,6 +31,13 @@ export type NavSpec = {
* page the API will reject with 403.
*/
perm?: string;
/**
* Visible only if the user holds *at least one* of these permissions. Use for
* an item that fronts several independently-gated sub-views (e.g. Trash, whose
* tabs each require a different permission) — the entry should show as long as
* the user can reach any one of them. Combined with `perm` via AND.
*/
anyPerm?: readonly string[];
};

export type NavSection = {
Expand Down Expand Up @@ -104,14 +112,20 @@ export const sections: NavSection[] = [
{ 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" },
{ to: "/system/trash", label: "Trash", icon: Trash2 },
// Trash fronts five tabs, each gated on a different resource's restore /
// view-trash permission. Show the entry if the user can reach any tab; the
// page hides the individual tabs they can't (see trash-permissions.ts).
{ to: "/system/trash", label: "Trash", icon: Trash2, anyPerm: ALL_TRASH_PERMISSIONS },
],
},
];

/** True when the item is ungated, or the user holds its required permission. */
/** True when the user satisfies the item's gates: the single `perm` (if any)
* AND at least one of `anyPerm` (if any). Ungated items are always visible. */
function isNavItemVisible(item: NavSpec, permissions: readonly string[]): boolean {
return !item.perm || permissions.includes(item.perm);
if (item.perm && !permissions.includes(item.perm)) return false;
if (item.anyPerm && !item.anyPerm.some((p) => permissions.includes(p))) return false;
return true;
}

/** Drop items the user can't access, then drop any section left empty. */
Expand Down
24 changes: 24 additions & 0 deletions clients/dashboard/src/lib/trash-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Permission required to view each Recycle bin tab. Each value mirrors the
* permission its matching trash endpoint enforces server-side (the catalog
* trash lists require the resource's `Restore` permission; Files trash requires
* `Files.ViewTrash`). Mirrored here so the dashboard can hide tabs — and the
* Trash nav entry itself — that a user can't access, instead of letting them
* click into a guaranteed 403. The server keeps enforcing as defence-in-depth.
*
* Convention follows the server registries: `Permissions.{Resource}.{Action}`.
* If a trash endpoint's permission changes, mirror it here.
*/
export const TRASH_TAB_PERMISSIONS = {
products: "Permissions.Catalog.Products.Restore",
brands: "Permissions.Catalog.Brands.Restore",
categories: "Permissions.Catalog.Categories.Restore",
tickets: "Permissions.Tickets.Restore",
files: "Permissions.Files.ViewTrash",
} as const;

export type TrashTabKey = keyof typeof TRASH_TAB_PERMISSIONS;

/** Flat list of every trash permission — used to gate the Trash nav entry
* (visible if the user holds *any* of them). */
export const ALL_TRASH_PERMISSIONS: readonly string[] = Object.values(TRASH_TAB_PERMISSIONS);
62 changes: 48 additions & 14 deletions clients/dashboard/src/pages/system/trash.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Expand Down Expand Up @@ -33,6 +33,11 @@ import {
restoreFile,
type FileAssetDto,
} from "@/api/files";
import { useAuth } from "@/auth/use-auth";
import {
TRASH_TAB_PERMISSIONS,
type TrashTabKey,
} from "@/lib/trash-permissions";
import { Button } from "@/components/ui/button";
import {
Dialog,
Expand Down Expand Up @@ -63,28 +68,47 @@ import {
const PAGE_SIZE = 20;
const DESKTOP_COLS = "grid-cols-[1.5fr_140px_140px_100px]";

type TabKey = "products" | "brands" | "categories" | "tickets" | "files";
type TabKey = TrashTabKey;

const TABS: ReadonlyArray<{
key: TabKey;
label: string;
icon: React.ComponentType<{ className?: string }>;
/** Permission gating this tab — mirrors what its trash endpoint enforces. */
perm: string;
}> = [
{ key: "products", label: "Products", icon: Package },
{ key: "brands", label: "Brands", icon: Tags },
{ key: "categories", label: "Categories", icon: FolderTree },
{ key: "tickets", label: "Tickets", icon: Ticket },
{ key: "files", label: "Files", icon: FileText },
{ key: "products", label: "Products", icon: Package, perm: TRASH_TAB_PERMISSIONS.products },
{ key: "brands", label: "Brands", icon: Tags, perm: TRASH_TAB_PERMISSIONS.brands },
{ key: "categories", label: "Categories", icon: FolderTree, perm: TRASH_TAB_PERMISSIONS.categories },
{ key: "tickets", label: "Tickets", icon: Ticket, perm: TRASH_TAB_PERMISSIONS.tickets },
{ key: "files", label: "Files", icon: FileText, perm: TRASH_TAB_PERMISSIONS.files },
];

// ───────────────────────────────────────────────────────────────────────
// Page
// ───────────────────────────────────────────────────────────────────────

export function TrashPage() {
const { user } = useAuth();
const [tab, setTab] = useState<TabKey>("products");
const [pageNumber, setPageNumber] = useState(1);

// Show only the tabs whose trash endpoint the user can actually reach, so they
// never click into a guaranteed 403. The server still enforces (defence in
// depth) — this is the UX layer. The nav already hides the Trash entry when
// the user has none of these, but the route is still directly reachable, so
// we handle the empty case here too.
const perms = user?.permissions;
const visibleTabs = useMemo(
() => TABS.filter((t) => perms?.includes(t.perm) ?? false),
[perms],
);
// The selected tab may be one the user can't see (initial default, or a
// permission they lost) — fall back to the first visible tab.
const activeTab = visibleTabs.some((t) => t.key === tab)
? tab
: visibleTabs[0]?.key;

// Reset paging when switching tabs.
const onTab = (next: TabKey) => {
setTab(next);
Expand All @@ -99,13 +123,21 @@ export function TrashPage() {
description="Soft-deleted records, kept indefinitely until you restore them. Restoring a row brings it back to its parent list with the same ID and history intact."
/>

{visibleTabs.length === 0 ? (
<EntityEmpty
icon={Trash2}
title="No recycle bins available"
body="You don't have permission to restore deleted records in this tenant. Ask an administrator if you think you should."
/>
) : (
<>
{/* Tab pills */}
<nav
aria-label="Trash sections"
className="flex flex-wrap items-center gap-2"
>
{TABS.map(({ key, label, icon: Icon }) => {
const active = tab === key;
{visibleTabs.map(({ key, label, icon: Icon }) => {
const active = activeTab === key;
return (
<button
key={key}
Expand All @@ -127,21 +159,23 @@ export function TrashPage() {
</nav>

{/* Active panel */}
{tab === "products" && (
{activeTab === "products" && (
<ProductsTab pageNumber={pageNumber} setPageNumber={setPageNumber} />
)}
{tab === "brands" && (
{activeTab === "brands" && (
<BrandsTab pageNumber={pageNumber} setPageNumber={setPageNumber} />
)}
{tab === "categories" && (
{activeTab === "categories" && (
<CategoriesTab pageNumber={pageNumber} setPageNumber={setPageNumber} />
)}
{tab === "tickets" && (
{activeTab === "tickets" && (
<TicketsTab pageNumber={pageNumber} setPageNumber={setPageNumber} />
)}
{tab === "files" && (
{activeTab === "files" && (
<FilesTab pageNumber={pageNumber} setPageNumber={setPageNumber} />
)}
</>
)}
</div>
);
}
Expand Down
64 changes: 63 additions & 1 deletion clients/dashboard/tests/system/trash.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { mockJsonResponse } from "../helpers/api-mocks";
import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";
import { installShellMocks, paged } from "../helpers/shell-mocks";
Expand Down Expand Up @@ -37,9 +37,29 @@ function trashedBrand(over: Record<string, unknown> = {}) {
};
}

// Trash tabs are permission-gated (mirrors src/lib/trash-permissions.ts). The
// dashboard reads the user's permission set from GET /identity/permissions, so
// tests grant tabs by re-mocking that endpoint AFTER installShellMocks (which
// defaults it to []); Playwright matches the most-recently-registered route.
const TRASH_PERMS = {
products: "Permissions.Catalog.Products.Restore",
brands: "Permissions.Catalog.Brands.Restore",
categories: "Permissions.Catalog.Categories.Restore",
tickets: "Permissions.Tickets.Restore",
files: "Permissions.Files.ViewTrash",
} as const;

const ALL_TRASH_PERMS = Object.values(TRASH_PERMS);

async function grantPermissions(page: Page, perms: readonly string[]): Promise<void> {
await mockJsonResponse(page, "**/api/v1/identity/permissions", perms);
}

test.beforeEach(async ({ page }) => {
await seedAuthedSession(page, TEST_USER);
await installShellMocks(page);
// Default: the user can reach every trash tab. Gating-specific tests override.
await grantPermissions(page, ALL_TRASH_PERMS);
});

test.describe("system/trash", () => {
Expand Down Expand Up @@ -112,4 +132,46 @@ test.describe("system/trash", () => {
await expect(page.getByText("Acme Tools").last()).toBeVisible();
await expect(page.getByText(/1 brands in trash/i)).toBeVisible();
});

test("hides tabs the user lacks permission for, defaulting to the first visible one", async ({
page,
}) => {
// Only Brands + Files are reachable — Products (the hard-coded default tab)
// is gated away, so the page must fall back to the first visible tab (Brands).
await grantPermissions(page, [TRASH_PERMS.brands, TRASH_PERMS.files]);
await mockJsonResponse(
page,
"**/api/v1/catalog/brands/trash**",
paged([trashedBrand()], { totalCount: 1 }),
);

await page.goto("/system/trash");

const tabs = page.getByRole("navigation", { name: /trash sections/i });
await expect(tabs.getByRole("button", { name: "Brands" })).toBeVisible();
await expect(tabs.getByRole("button", { name: "Files" })).toBeVisible();
// Gated tabs are absent entirely — not just disabled.
await expect(tabs.getByRole("button", { name: "Products" })).toHaveCount(0);
await expect(tabs.getByRole("button", { name: "Categories" })).toHaveCount(0);
await expect(tabs.getByRole("button", { name: "Tickets" })).toHaveCount(0);

// Defaulted to Brands (first visible) and loaded its data, not a 403.
await expect(page.getByText(/1 brands in trash/i)).toBeVisible();
});

test("shows a no-access empty state when the user has no trash permissions", async ({
page,
}) => {
await grantPermissions(page, []);

await page.goto("/system/trash");

await expect(
page.getByRole("heading", { name: /no recycle bins available/i }),
).toBeVisible();
// No tab rail at all.
await expect(
page.getByRole("navigation", { name: /trash sections/i }),
).toHaveCount(0);
});
});
Loading