From 2cff5051ac6e4d19a55294deb19192c260b66a6a Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sun, 21 Jun 2026 05:58:56 +0330 Subject: [PATCH] feat(rbac): gate pages and action buttons in the UI by permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nav already hides pages a role can't view (NAV_REQUIRED_PERMISSION). This wraps the sensitive/CRUD action controls in so users only see what they can do (server still enforces): - POS/orders: void → VoidOrder, cancel → VoidOrder, transfer → EditOrder, pay/split → HandlePayments - menu/inventory/coupons/customers/reservations/expenses/taxes/branches: add/edit/delete buttons → the matching Create/Edit/Delete permission - reports CSV export → ExportReports; SMS send → SendSms, settings → ManageSmsSettings - home dashboard: revenue/orders KPI queries gated on ViewReports so non-report roles don't 403 on the landing page (Refund/discount/comp/cash-drawer have no UI control yet — no buttons to gate.) Co-Authored-By: Claude Opus 4.8 --- .../components/branches/branches-screen.tsx | 45 +++-- .../src/components/coupons/coupons-screen.tsx | 33 ++-- .../src/components/crm/crm-screen.tsx | 57 +++--- .../components/expenses/expenses-screen.tsx | 41 ++-- .../components/inventory/inventory-screen.tsx | 127 +++++++------ .../src/components/menu/menu-admin-screen.tsx | 175 ++++++++++-------- .../components/overview/overview-screen.tsx | 8 +- .../src/components/pos/pos-pay-panel.tsx | 55 +++--- .../src/components/pos/pos-screen.tsx | 61 +++--- .../src/components/pos2/pos2-prototype.tsx | 21 ++- .../src/components/pos2/pos2-screen.tsx | 21 ++- .../src/components/reports/reports-screen.tsx | 21 ++- .../reservations/reservations-screen.tsx | 75 ++++---- .../src/components/sms/sms-screen.tsx | 33 ++-- .../src/components/taxes/taxes-screen.tsx | 41 ++-- 15 files changed, 457 insertions(+), 357 deletions(-) diff --git a/web/dashboard/src/components/branches/branches-screen.tsx b/web/dashboard/src/components/branches/branches-screen.tsx index ea9f764..8907c39 100644 --- a/web/dashboard/src/components/branches/branches-screen.tsx +++ b/web/dashboard/src/components/branches/branches-screen.tsx @@ -24,6 +24,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { cn } from "@/lib/utils"; +import { Can } from "@/components/auth/can"; type Branch = { id: string; @@ -266,19 +267,21 @@ export function BranchesScreen() { {t("review")} - + + + ))} @@ -327,13 +330,15 @@ export function BranchesScreen() { /> - + + +

{t("masterPlanHint")}

{t("branchSelectHint")}

diff --git a/web/dashboard/src/components/coupons/coupons-screen.tsx b/web/dashboard/src/components/coupons/coupons-screen.tsx index 34584d8..0ab30ca 100644 --- a/web/dashboard/src/components/coupons/coupons-screen.tsx +++ b/web/dashboard/src/components/coupons/coupons-screen.tsx @@ -16,6 +16,7 @@ import { LabeledField } from "@/components/ui/labeled-field"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Can } from "@/components/auth/can"; export function CouponsScreen() { const t = useTranslations("coupons"); @@ -68,10 +69,12 @@ export function CouponsScreen() {

{t("title")}

- + + +
{showForm && ( @@ -148,16 +151,18 @@ export function CouponsScreen() { {c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}

- + + +
diff --git a/web/dashboard/src/components/crm/crm-screen.tsx b/web/dashboard/src/components/crm/crm-screen.tsx index 7b50e87..b2d3b82 100644 --- a/web/dashboard/src/components/crm/crm-screen.tsx +++ b/web/dashboard/src/components/crm/crm-screen.tsx @@ -17,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard"; +import { Can } from "@/components/auth/can"; export function CrmScreen() { const t = useTranslations("crm"); @@ -67,13 +68,15 @@ export function CrmScreen() {

{t("title")}

- + + +
@@ -120,24 +123,28 @@ export function CrmScreen() {

- - + + + + + +
diff --git a/web/dashboard/src/components/expenses/expenses-screen.tsx b/web/dashboard/src/components/expenses/expenses-screen.tsx index 81afb40..90026a0 100644 --- a/web/dashboard/src/components/expenses/expenses-screen.tsx +++ b/web/dashboard/src/components/expenses/expenses-screen.tsx @@ -15,6 +15,7 @@ import { MoneyInput } from "@/components/ui/money-input"; import { JalaliDateField } from "@/components/ui/jalali-date-field"; import { LabeledField } from "@/components/ui/labeled-field"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Can } from "@/components/auth/can"; type Branch = { id: string; name: string }; type ShiftDto = { @@ -146,14 +147,16 @@ export function ExpensesScreen() { title={t("title")} subtitle={t("subtitle")} action={ - + + + } /> @@ -236,16 +239,18 @@ export function ExpensesScreen() { {canDelete ? ( - + + + ) : null} diff --git a/web/dashboard/src/components/inventory/inventory-screen.tsx b/web/dashboard/src/components/inventory/inventory-screen.tsx index 3803c12..1e5fd24 100644 --- a/web/dashboard/src/components/inventory/inventory-screen.tsx +++ b/web/dashboard/src/components/inventory/inventory-screen.tsx @@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { notify } from "@/lib/notify"; import { useApiError } from "@/lib/use-api-error"; +import { Can } from "@/components/auth/can"; type Ingredient = { id: string; @@ -369,13 +370,15 @@ export function InventoryScreen() { setReorder(e.target.value)} dir="ltr" className="text-end" /> - + + + @@ -458,14 +461,16 @@ export function InventoryScreen() { {t("quantityEditHint")}

- + + + - + + + + + +

@@ -540,29 +549,31 @@ export function InventoryScreen() { /> ) : null} - + + +

{parseFloat(adjustQty[ing.id] ?? "0") > 0 ? (

{t("purchaseHint")}

diff --git a/web/dashboard/src/components/menu/menu-admin-screen.tsx b/web/dashboard/src/components/menu/menu-admin-screen.tsx index f1e2e50..38f54ef 100644 --- a/web/dashboard/src/components/menu/menu-admin-screen.tsx +++ b/web/dashboard/src/components/menu/menu-admin-screen.tsx @@ -41,6 +41,7 @@ import { MenuItemMedia } from "@/components/menu/menu-item-media"; import { buildCategoryNameMap, inferMenuItemKind } from "@/lib/menu-item-image"; import { menuItemMatchesSearch } from "@/lib/menu-display"; import { BranchMenuOverrides } from "@/components/menu/branch-menu-overrides"; +import { Can } from "@/components/auth/can"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -565,27 +566,31 @@ export function MenuAdminScreen() { {/* Edit category button */} - + + + ))} {/* Add category button */}
- + + +
@@ -632,14 +637,16 @@ export function MenuAdminScreen() { ))} - + + + {/* Search + Add bar */} @@ -665,14 +672,16 @@ export function MenuAdminScreen() { ) : null} - + + + {/* Items grid */} @@ -690,15 +699,17 @@ export function MenuAdminScreen() { {itemSearch ? t("noItemsMatchSearch") : t("noItemsInCategory")}

{!itemSearch ? ( - + + + ) : null} ) : ( @@ -730,14 +741,16 @@ export function MenuAdminScreen() { {/* Hover overlay — edit button */}
- + + +
{/* Discount badge */} @@ -933,21 +946,23 @@ export function MenuAdminScreen() { {/* Actions */}
{editingItem ? ( - + + + ) : ( )} @@ -999,21 +1014,23 @@ export function MenuAdminScreen() {
{editingCategory ? ( - + + + ) : ( )} diff --git a/web/dashboard/src/components/overview/overview-screen.tsx b/web/dashboard/src/components/overview/overview-screen.tsx index c0a2842..77540e4 100644 --- a/web/dashboard/src/components/overview/overview-screen.tsx +++ b/web/dashboard/src/components/overview/overview-screen.tsx @@ -19,6 +19,7 @@ import { import { Link } from "@/i18n/routing"; import { apiGet } from "@/lib/api/client"; import { useAuthStore } from "@/lib/stores/auth.store"; +import { useHasPermission } from "@/lib/permissions"; import { useLiveClock } from "@/lib/hooks/use-live-clock"; import { addDaysIso, @@ -100,6 +101,9 @@ export function OverviewScreen() { const cafeId = useAuthStore((s) => s.user?.cafeId); const branchId = useAuthStore((s) => s.user?.branchId ?? null); const role = useAuthStore((s) => s.user?.role); + // KPI cards surface revenue/orders/net income — report data. Don't fetch (and + // 403) for roles without ViewReports; the cards simply stay empty for them. + const canViewReports = useHasPermission("ViewReports"); const clock = useLiveClock(10_000); @@ -122,7 +126,7 @@ export function OverviewScreen() { apiGet( `/api/cafes/${cafeId}/reports/daily/range?from=${sevenDaysAgo}&to=${today}` ), - enabled: !!cafeId, + enabled: !!cafeId && canViewReports, staleTime: 60_000, }); @@ -132,7 +136,7 @@ export function OverviewScreen() { apiGet( `/api/cafes/${cafeId}/reports/daily/range?from=${yesterday}&to=${yesterday}` ), - enabled: !!cafeId, + enabled: !!cafeId && canViewReports, staleTime: 300_000, }); diff --git a/web/dashboard/src/components/pos/pos-pay-panel.tsx b/web/dashboard/src/components/pos/pos-pay-panel.tsx index 6a1a8e7..50e3420 100644 --- a/web/dashboard/src/components/pos/pos-pay-panel.tsx +++ b/web/dashboard/src/components/pos/pos-pay-panel.tsx @@ -13,6 +13,7 @@ import { formatCurrency, formatNumber } from "@/lib/format"; import { formatPosOrderLabel } from "@/lib/pos-order-label"; import { formatOrderNumber } from "@/lib/order-number"; import { PosTableBoard } from "@/components/pos/pos-table-board"; +import { Can } from "@/components/auth/can"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { LabeledField } from "@/components/ui/labeled-field"; @@ -613,24 +614,26 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan className="h-9" maxLength={500} /> - + + +
{ @@ -640,13 +643,15 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan } }} > - + + +
diff --git a/web/dashboard/src/components/pos/pos-screen.tsx b/web/dashboard/src/components/pos/pos-screen.tsx index 25ebe0e..a47169b 100644 --- a/web/dashboard/src/components/pos/pos-screen.tsx +++ b/web/dashboard/src/components/pos/pos-screen.tsx @@ -51,6 +51,7 @@ import { buildCategoryNameMap, inferMenuItemKind, } from "@/lib/menu-item-image"; +import { Can } from "@/components/auth/can"; import { PosPayPanel } from "@/components/pos/pos-pay-panel"; import { PosTableBoard } from "@/components/pos/pos-table-board"; import { PosCustomerPicker } from "@/components/pos/pos-customer-picker"; @@ -1174,15 +1175,17 @@ export function PosScreen() { {/* Transfer table (for table orders with active session) */} {activeOrderId && tableId ? ( - + + + ) : null} {/* Assign table button (for counter orders) */} @@ -1248,14 +1251,16 @@ export function PosScreen() { line.orderItemId && !line.isVoided && activeOrderId ? ( - + + + ) : null} {!line.isVoided ? ( <> @@ -1443,16 +1448,18 @@ export function PosScreen() { ) : null}
- + + +
- + + + - + + +
); diff --git a/web/dashboard/src/components/pos2/pos2-screen.tsx b/web/dashboard/src/components/pos2/pos2-screen.tsx index 105d9b8..dacad91 100644 --- a/web/dashboard/src/components/pos2/pos2-screen.tsx +++ b/web/dashboard/src/components/pos2/pos2-screen.tsx @@ -25,6 +25,7 @@ import { apiGet, apiPost, ApiClientError } from "@/lib/api/client"; import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos/submit-order"; import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; import { PosCustomerPicker } from "@/components/pos/pos-customer-picker"; +import { Can } from "@/components/auth/can"; import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types"; import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2"; @@ -729,17 +730,21 @@ function Ticket({ className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl bg-primary text-base font-bold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 active:scale-[0.98]"> ارسال{pendingCount > 0 ? ` (${fmt(pendingCount)})` : ""} - + + + - + + +
); diff --git a/web/dashboard/src/components/reports/reports-screen.tsx b/web/dashboard/src/components/reports/reports-screen.tsx index e16a8d5..64a9639 100644 --- a/web/dashboard/src/components/reports/reports-screen.tsx +++ b/web/dashboard/src/components/reports/reports-screen.tsx @@ -33,6 +33,7 @@ import { ReportsChartsFallback } from "@/components/reports/reports-charts-fallb import type { ReportsChartsProps } from "@/components/reports/reports-charts.types"; import { PaymentCorrectionsTab } from "@/components/reports/payment-corrections-tab"; import { AuditLogsTab } from "@/components/reports/audit-logs-tab"; +import { Can } from "@/components/auth/can"; const LazyReportsCharts = lazy(() => import("@/components/reports/reports-charts").then((m) => ({ @@ -218,15 +219,17 @@ export function ReportsScreen() { subtitle={t("subtitle")} action={ tab === "performance" ? ( - + + + ) : undefined } /> diff --git a/web/dashboard/src/components/reservations/reservations-screen.tsx b/web/dashboard/src/components/reservations/reservations-screen.tsx index d6113e2..36d86e8 100644 --- a/web/dashboard/src/components/reservations/reservations-screen.tsx +++ b/web/dashboard/src/components/reservations/reservations-screen.tsx @@ -19,6 +19,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import type { Table } from "@/lib/api/types"; +import { Can } from "@/components/auth/can"; type ReservationStatus = | "Pending" @@ -194,12 +195,14 @@ export function ReservationsScreen() { />
- + + +
@@ -228,19 +231,23 @@ export function ReservationsScreen() {
{r.status === "Pending" && ( <> - - + + + + + + )} {(r.status === "Confirmed" || r.status === "Seated") && ( @@ -249,23 +256,27 @@ export function ReservationsScreen() { )} {r.status === "Seated" && ( + + + + )} + - )} - +
diff --git a/web/dashboard/src/components/sms/sms-screen.tsx b/web/dashboard/src/components/sms/sms-screen.tsx index f52829d..51cf7ee 100644 --- a/web/dashboard/src/components/sms/sms-screen.tsx +++ b/web/dashboard/src/components/sms/sms-screen.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { LabeledField } from "@/components/ui/labeled-field"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { cn } from "@/lib/utils"; +import { Can } from "@/components/auth/can"; const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"]; const MANAGER_ROLES = new Set(["Owner", "Manager"]); @@ -195,13 +196,15 @@ export function SmsScreen() { {/* Send button */} - + + + {/* Result banner */} {result && ( @@ -314,13 +317,15 @@ function ProviderSettingsCard({

{settings?.isConfigured ? t("configured") : t("notConfigured")}

- + + + diff --git a/web/dashboard/src/components/taxes/taxes-screen.tsx b/web/dashboard/src/components/taxes/taxes-screen.tsx index e27d2e0..fe609d3 100644 --- a/web/dashboard/src/components/taxes/taxes-screen.tsx +++ b/web/dashboard/src/components/taxes/taxes-screen.tsx @@ -15,6 +15,7 @@ import { LabeledField } from "@/components/ui/labeled-field"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { useConfirm } from "@/components/providers/confirm-provider"; +import { Can } from "@/components/auth/can"; interface TaxRow { id: string; @@ -89,14 +90,16 @@ export function TaxesScreen() { subtitle={t("subtitle")} action={ canEdit ? ( - + + + ) : undefined } /> @@ -152,16 +155,18 @@ export function TaxesScreen() { ) : null} {canEdit ? ( - + + + ) : null}