feat(rbac): gate pages and action buttons in the UI by permission
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m45s

Nav already hides pages a role can't view (NAV_REQUIRED_PERMISSION). This wraps
the sensitive/CRUD action controls in <Can permission> 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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 05:58:56 +03:30
parent 53d90fa357
commit 2cff5051ac
15 changed files with 457 additions and 357 deletions
@@ -24,6 +24,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Can } from "@/components/auth/can";
type Branch = { type Branch = {
id: string; id: string;
@@ -266,6 +267,7 @@ export function BranchesScreen() {
<Eye className="h-3.5 w-3.5 me-1.5" /> <Eye className="h-3.5 w-3.5 me-1.5" />
{t("review")} {t("review")}
</Button> </Button>
<Can permission="DeleteBranch">
<Button <Button
type="button" type="button"
size="sm" size="sm"
@@ -279,6 +281,7 @@ export function BranchesScreen() {
<Trash2 className="h-3.5 w-3.5 me-1.5" /> <Trash2 className="h-3.5 w-3.5 me-1.5" />
{t("delete")} {t("delete")}
</Button> </Button>
</Can>
</div> </div>
</li> </li>
))} ))}
@@ -327,6 +330,7 @@ export function BranchesScreen() {
/> />
</LabeledField> </LabeledField>
</div> </div>
<Can permission="CreateBranch">
<Button <Button
type="submit" type="submit"
className="bg-[#0F6E56] hover:bg-[#0c5a46]" className="bg-[#0F6E56] hover:bg-[#0c5a46]"
@@ -334,6 +338,7 @@ export function BranchesScreen() {
> >
{createBranch.isPending ? "..." : t("add")} {createBranch.isPending ? "..." : t("add")}
</Button> </Button>
</Can>
<p className="text-xs text-muted-foreground">{t("masterPlanHint")}</p> <p className="text-xs text-muted-foreground">{t("masterPlanHint")}</p>
</form> </form>
<p className="text-xs text-muted-foreground">{t("branchSelectHint")}</p> <p className="text-xs text-muted-foreground">{t("branchSelectHint")}</p>
@@ -16,6 +16,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Can } from "@/components/auth/can";
export function CouponsScreen() { export function CouponsScreen() {
const t = useTranslations("coupons"); const t = useTranslations("coupons");
@@ -68,10 +69,12 @@ export function CouponsScreen() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-bold">{t("title")}</h2> <h2 className="text-xl font-bold">{t("title")}</h2>
<Can permission="CreateCoupon">
<Button onClick={() => setShowForm(!showForm)}> <Button onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{t("addCoupon")} {t("addCoupon")}
</Button> </Button>
</Can>
</div> </div>
{showForm && ( {showForm && (
@@ -148,6 +151,7 @@ export function CouponsScreen() {
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""} {c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
</p> </p>
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">
<Can permission="DeleteCoupon">
<Button <Button
type="button" type="button"
size="sm" size="sm"
@@ -158,6 +162,7 @@ export function CouponsScreen() {
<Trash2 className="me-1.5 size-4" /> <Trash2 className="me-1.5 size-4" />
{tCommon("delete")} {tCommon("delete")}
</Button> </Button>
</Can>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -17,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard"; import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
import { Can } from "@/components/auth/can";
export function CrmScreen() { export function CrmScreen() {
const t = useTranslations("crm"); const t = useTranslations("crm");
@@ -67,6 +68,7 @@ export function CrmScreen() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-xl font-bold">{t("title")}</h2> <h2 className="text-xl font-bold">{t("title")}</h2>
<Can permission="CreateCustomer">
<Button <Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]" className="bg-[#0F6E56] hover:bg-[#0c5a46]"
onClick={() => openWizard("create")} onClick={() => openWizard("create")}
@@ -74,6 +76,7 @@ export function CrmScreen() {
<Plus className="me-2 h-4 w-4" /> <Plus className="me-2 h-4 w-4" />
{t("addCustomer")} {t("addCustomer")}
</Button> </Button>
</Can>
</div> </div>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
@@ -120,6 +123,7 @@ export function CrmScreen() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Can permission="EditCustomer">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -129,6 +133,8 @@ export function CrmScreen() {
<Pencil className="me-1 h-3.5 w-3.5" /> <Pencil className="me-1 h-3.5 w-3.5" />
{tCommon("edit")} {tCommon("edit")}
</Button> </Button>
</Can>
<Can permission="DeleteCustomer">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -138,6 +144,7 @@ export function CrmScreen() {
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</Can>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -15,6 +15,7 @@ import { MoneyInput } from "@/components/ui/money-input";
import { JalaliDateField } from "@/components/ui/jalali-date-field"; import { JalaliDateField } from "@/components/ui/jalali-date-field";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Can } from "@/components/auth/can";
type Branch = { id: string; name: string }; type Branch = { id: string; name: string };
type ShiftDto = { type ShiftDto = {
@@ -146,6 +147,7 @@ export function ExpensesScreen() {
title={t("title")} title={t("title")}
subtitle={t("subtitle")} subtitle={t("subtitle")}
action={ action={
<Can permission="CreateExpense">
<Button <Button
className="bg-[#0F6E56] hover:bg-[#0d5e49]" className="bg-[#0F6E56] hover:bg-[#0d5e49]"
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
@@ -154,6 +156,7 @@ export function ExpensesScreen() {
<Plus className="ms-2 h-4 w-4" /> <Plus className="ms-2 h-4 w-4" />
{t("addExpense")} {t("addExpense")}
</Button> </Button>
</Can>
} }
/> />
@@ -236,6 +239,7 @@ export function ExpensesScreen() {
</td> </td>
{canDelete ? ( {canDelete ? (
<td className="py-2.5 text-end"> <td className="py-2.5 text-end">
<Can permission="DeleteExpense">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -246,6 +250,7 @@ export function ExpensesScreen() {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</Can>
</td> </td>
) : null} ) : null}
</tr> </tr>
@@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error"; import { useApiError } from "@/lib/use-api-error";
import { Can } from "@/components/auth/can";
type Ingredient = { type Ingredient = {
id: string; id: string;
@@ -369,6 +370,7 @@ export function InventoryScreen() {
<LabeledField label={t("reorderLevel")}> <LabeledField label={t("reorderLevel")}>
<Input value={reorder} onChange={(e) => setReorder(e.target.value)} dir="ltr" className="text-end" /> <Input value={reorder} onChange={(e) => setReorder(e.target.value)} dir="ltr" className="text-end" />
</LabeledField> </LabeledField>
<Can permission="CreateInventory">
<Button <Button
className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end" className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end"
disabled={!name.trim()} disabled={!name.trim()}
@@ -376,6 +378,7 @@ export function InventoryScreen() {
> >
{tCommon("save")} {tCommon("save")}
</Button> </Button>
</Can>
</CardContent> </CardContent>
</Card> </Card>
@@ -458,6 +461,7 @@ export function InventoryScreen() {
{t("quantityEditHint")} {t("quantityEditHint")}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Can permission="EditInventory">
<Button <Button
size="sm" size="sm"
className="bg-[#0F6E56] hover:bg-[#0c5a46]" className="bg-[#0F6E56] hover:bg-[#0c5a46]"
@@ -466,6 +470,7 @@ export function InventoryScreen() {
> >
{tCommon("save")} {tCommon("save")}
</Button> </Button>
</Can>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -483,6 +488,7 @@ export function InventoryScreen() {
{ing.isLowStock ? ( {ing.isLowStock ? (
<Badge variant="outline">{t("lowStock")}</Badge> <Badge variant="outline">{t("lowStock")}</Badge>
) : null} ) : null}
<Can permission="EditInventory">
<Button <Button
type="button" type="button"
size="icon" size="icon"
@@ -493,6 +499,8 @@ export function InventoryScreen() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
</Can>
<Can permission="DeleteInventory">
<Button <Button
type="button" type="button"
size="icon" size="icon"
@@ -503,6 +511,7 @@ export function InventoryScreen() {
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
</Can>
</div> </div>
</div> </div>
<p className="text-sm font-medium text-[#0F6E56]"> <p className="text-sm font-medium text-[#0F6E56]">
@@ -540,6 +549,7 @@ export function InventoryScreen() {
/> />
</LabeledField> </LabeledField>
) : null} ) : null}
<Can permission="EditInventory">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -563,6 +573,7 @@ export function InventoryScreen() {
> >
{t("adjust")} {t("adjust")}
</Button> </Button>
</Can>
</div> </div>
{parseFloat(adjustQty[ing.id] ?? "0") > 0 ? ( {parseFloat(adjustQty[ing.id] ?? "0") > 0 ? (
<p className="text-[11px] text-muted-foreground">{t("purchaseHint")}</p> <p className="text-[11px] text-muted-foreground">{t("purchaseHint")}</p>
@@ -41,6 +41,7 @@ import { MenuItemMedia } from "@/components/menu/menu-item-media";
import { buildCategoryNameMap, inferMenuItemKind } from "@/lib/menu-item-image"; import { buildCategoryNameMap, inferMenuItemKind } from "@/lib/menu-item-image";
import { menuItemMatchesSearch } from "@/lib/menu-display"; import { menuItemMatchesSearch } from "@/lib/menu-display";
import { BranchMenuOverrides } from "@/components/menu/branch-menu-overrides"; import { BranchMenuOverrides } from "@/components/menu/branch-menu-overrides";
import { Can } from "@/components/auth/can";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -565,6 +566,7 @@ export function MenuAdminScreen() {
</span> </span>
</button> </button>
{/* Edit category button */} {/* Edit category button */}
<Can permission="EditMenuItem">
<button <button
type="button" type="button"
aria-label={t("editCategory")} aria-label={t("editCategory")}
@@ -573,11 +575,13 @@ export function MenuAdminScreen() {
> >
<Pencil className="size-3" /> <Pencil className="size-3" />
</button> </button>
</Can>
</div> </div>
))} ))}
{/* Add category button */} {/* Add category button */}
<div className="mt-1 border-t border-border/60 pt-1"> <div className="mt-1 border-t border-border/60 pt-1">
<Can permission="CreateMenuItem">
<button <button
type="button" type="button"
onClick={openAddCategory} onClick={openAddCategory}
@@ -586,6 +590,7 @@ export function MenuAdminScreen() {
<Plus className="size-4 shrink-0" /> <Plus className="size-4 shrink-0" />
{t("addCategory")} {t("addCategory")}
</button> </button>
</Can>
</div> </div>
</div> </div>
</aside> </aside>
@@ -632,6 +637,7 @@ export function MenuAdminScreen() {
</span> </span>
</button> </button>
))} ))}
<Can permission="CreateMenuItem">
<button <button
type="button" type="button"
onClick={openAddCategory} onClick={openAddCategory}
@@ -640,6 +646,7 @@ export function MenuAdminScreen() {
<Plus className="size-3" /> <Plus className="size-3" />
{t("addCategory")} {t("addCategory")}
</button> </button>
</Can>
</div> </div>
{/* Search + Add bar */} {/* Search + Add bar */}
@@ -665,6 +672,7 @@ export function MenuAdminScreen() {
</Button> </Button>
) : null} ) : null}
</div> </div>
<Can permission="CreateMenuItem">
<Button <Button
onClick={openAddItem} onClick={openAddItem}
className="shrink-0 bg-[#0F6E56] hover:bg-[#0c5a46]" className="shrink-0 bg-[#0F6E56] hover:bg-[#0c5a46]"
@@ -673,6 +681,7 @@ export function MenuAdminScreen() {
<Plus className="me-1.5 size-4" /> <Plus className="me-1.5 size-4" />
{t("newItem")} {t("newItem")}
</Button> </Button>
</Can>
</div> </div>
{/* Items grid */} {/* Items grid */}
@@ -690,6 +699,7 @@ export function MenuAdminScreen() {
{itemSearch ? t("noItemsMatchSearch") : t("noItemsInCategory")} {itemSearch ? t("noItemsMatchSearch") : t("noItemsInCategory")}
</p> </p>
{!itemSearch ? ( {!itemSearch ? (
<Can permission="CreateMenuItem">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -699,6 +709,7 @@ export function MenuAdminScreen() {
<Plus className="me-1.5 size-4" /> <Plus className="me-1.5 size-4" />
{t("addItem")} {t("addItem")}
</Button> </Button>
</Can>
) : null} ) : null}
</div> </div>
) : ( ) : (
@@ -730,6 +741,7 @@ export function MenuAdminScreen() {
{/* Hover overlay — edit button */} {/* Hover overlay — edit button */}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-all group-hover:bg-black/25"> <div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-all group-hover:bg-black/25">
<Can permission="EditMenuItem">
<Button <Button
size="sm" size="sm"
className="translate-y-2 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100" className="translate-y-2 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
@@ -738,6 +750,7 @@ export function MenuAdminScreen() {
<Pencil className="me-1.5 size-3.5" /> <Pencil className="me-1.5 size-3.5" />
{t("editItem")} {t("editItem")}
</Button> </Button>
</Can>
</div> </div>
{/* Discount badge */} {/* Discount badge */}
@@ -933,6 +946,7 @@ export function MenuAdminScreen() {
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-between gap-2 border-t border-border pt-4"> <div className="flex items-center justify-between gap-2 border-t border-border pt-4">
{editingItem ? ( {editingItem ? (
<Can permission="DeleteMenuItem">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -948,6 +962,7 @@ export function MenuAdminScreen() {
<Trash2 className="me-1.5 size-4" /> <Trash2 className="me-1.5 size-4" />
{tCommon("delete")} {tCommon("delete")}
</Button> </Button>
</Can>
) : ( ) : (
<span /> <span />
)} )}
@@ -999,6 +1014,7 @@ export function MenuAdminScreen() {
<div className="flex items-center justify-between gap-2 border-t border-border pt-4"> <div className="flex items-center justify-between gap-2 border-t border-border pt-4">
{editingCategory ? ( {editingCategory ? (
<Can permission="DeleteMenuItem">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -1014,6 +1030,7 @@ export function MenuAdminScreen() {
<Trash2 className="me-1.5 size-4" /> <Trash2 className="me-1.5 size-4" />
{tCommon("delete")} {tCommon("delete")}
</Button> </Button>
</Can>
) : ( ) : (
<span /> <span />
)} )}
@@ -19,6 +19,7 @@ import {
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { apiGet } from "@/lib/api/client"; import { apiGet } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useHasPermission } from "@/lib/permissions";
import { useLiveClock } from "@/lib/hooks/use-live-clock"; import { useLiveClock } from "@/lib/hooks/use-live-clock";
import { import {
addDaysIso, addDaysIso,
@@ -100,6 +101,9 @@ export function OverviewScreen() {
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useAuthStore((s) => s.user?.branchId ?? null); const branchId = useAuthStore((s) => s.user?.branchId ?? null);
const role = useAuthStore((s) => s.user?.role); 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); const clock = useLiveClock(10_000);
@@ -122,7 +126,7 @@ export function OverviewScreen() {
apiGet<DailyReportSnapshot[]>( apiGet<DailyReportSnapshot[]>(
`/api/cafes/${cafeId}/reports/daily/range?from=${sevenDaysAgo}&to=${today}` `/api/cafes/${cafeId}/reports/daily/range?from=${sevenDaysAgo}&to=${today}`
), ),
enabled: !!cafeId, enabled: !!cafeId && canViewReports,
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -132,7 +136,7 @@ export function OverviewScreen() {
apiGet<DailyReportSnapshot[]>( apiGet<DailyReportSnapshot[]>(
`/api/cafes/${cafeId}/reports/daily/range?from=${yesterday}&to=${yesterday}` `/api/cafes/${cafeId}/reports/daily/range?from=${yesterday}&to=${yesterday}`
), ),
enabled: !!cafeId, enabled: !!cafeId && canViewReports,
staleTime: 300_000, staleTime: 300_000,
}); });
@@ -13,6 +13,7 @@ import { formatCurrency, formatNumber } from "@/lib/format";
import { formatPosOrderLabel } from "@/lib/pos-order-label"; import { formatPosOrderLabel } from "@/lib/pos-order-label";
import { formatOrderNumber } from "@/lib/order-number"; import { formatOrderNumber } from "@/lib/order-number";
import { PosTableBoard } from "@/components/pos/pos-table-board"; import { PosTableBoard } from "@/components/pos/pos-table-board";
import { Can } from "@/components/auth/can";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
@@ -613,6 +614,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
className="h-9" className="h-9"
maxLength={500} maxLength={500}
/> />
<Can permission="VoidOrder">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -631,6 +633,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
> >
{cancelOrder.isPending ? "..." : t("cancelOrder")} {cancelOrder.isPending ? "..." : t("cancelOrder")}
</Button> </Button>
</Can>
<form <form
className="w-full" className="w-full"
onSubmit={(e) => { onSubmit={(e) => {
@@ -640,6 +643,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
} }
}} }}
> >
<Can permission="HandlePayments">
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
@@ -647,6 +651,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
> >
{payOrder.isPending ? "..." : payButtonLabel} {payOrder.isPending ? "..." : payButtonLabel}
</Button> </Button>
</Can>
</form> </form>
</div> </div>
</> </>
@@ -51,6 +51,7 @@ import {
buildCategoryNameMap, buildCategoryNameMap,
inferMenuItemKind, inferMenuItemKind,
} from "@/lib/menu-item-image"; } from "@/lib/menu-item-image";
import { Can } from "@/components/auth/can";
import { PosPayPanel } from "@/components/pos/pos-pay-panel"; import { PosPayPanel } from "@/components/pos/pos-pay-panel";
import { PosTableBoard } from "@/components/pos/pos-table-board"; import { PosTableBoard } from "@/components/pos/pos-table-board";
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker"; import { PosCustomerPicker } from "@/components/pos/pos-customer-picker";
@@ -1174,6 +1175,7 @@ export function PosScreen() {
{/* Transfer table (for table orders with active session) */} {/* Transfer table (for table orders with active session) */}
{activeOrderId && tableId ? ( {activeOrderId && tableId ? (
<Can permission="EditOrder">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -1183,6 +1185,7 @@ export function PosScreen() {
> >
{t("transferTable")} {t("transferTable")}
</Button> </Button>
</Can>
) : null} ) : null}
{/* Assign table button (for counter orders) */} {/* Assign table button (for counter orders) */}
@@ -1248,6 +1251,7 @@ export function PosScreen() {
line.orderItemId && line.orderItemId &&
!line.isVoided && !line.isVoided &&
activeOrderId ? ( activeOrderId ? (
<Can permission="VoidOrder">
<button <button
type="button" type="button"
className="cursor-pointer text-[10px] text-destructive hover:underline" className="cursor-pointer text-[10px] text-destructive hover:underline"
@@ -1256,6 +1260,7 @@ export function PosScreen() {
> >
{t("void")} {t("void")}
</button> </button>
</Can>
) : null} ) : null}
{!line.isVoided ? ( {!line.isVoided ? (
<> <>
@@ -1443,6 +1448,7 @@ export function PosScreen() {
) : null} ) : null}
<div className="flex flex-col gap-2 pt-0.5"> <div className="flex flex-col gap-2 pt-0.5">
<Can permission="HandlePayments">
<Button <Button
size="sm" size="sm"
className="w-full" className="w-full"
@@ -1453,6 +1459,7 @@ export function PosScreen() {
? "..." ? "..."
: t("submitOrderAndPay")} : t("submitOrderAndPay")}
</Button> </Button>
</Can>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
size="sm" size="sm"
@@ -15,6 +15,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { Can } from "@/components/auth/can";
type Item = { id: string; name: string; price: number; cat: string }; type Item = { id: string; name: string; price: number; cat: string };
type Line = { item: Item; qty: number }; type Line = { item: Item; qty: number };
@@ -402,18 +403,22 @@ 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]"> 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]">
<Send className="size-5" /> ارسال <Send className="size-5" /> ارسال
</button> </button>
<Can permission="HandlePayments">
<button type="button" disabled={!count} onClick={onPay} <button type="button" disabled={!count} onClick={onPay}
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]"> className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]">
<CreditCard className="size-5" /> پرداخت <CreditCard className="size-5" /> پرداخت
</button> </button>
</Can>
<button type="button" disabled={!count} onClick={onHold} <button type="button" disabled={!count} onClick={onHold}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40"> className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
<Pause className="size-4" /> نگهداشتن <Pause className="size-4" /> نگهداشتن
</button> </button>
<Can permission="HandlePayments">
<button type="button" disabled={!count} onClick={onSplit} <button type="button" disabled={!count} onClick={onSplit}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40"> className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
<SplitSquareHorizontal className="size-4" /> تقسیم <SplitSquareHorizontal className="size-4" /> تقسیم
</button> </button>
</Can>
</div> </div>
</div> </div>
); );
@@ -25,6 +25,7 @@ import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos/submit-order"; import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos/submit-order";
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker"; 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 type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2"; 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]"> 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]">
<Send className="size-5" /> ارسال{pendingCount > 0 ? ` (${fmt(pendingCount)})` : ""} <Send className="size-5" /> ارسال{pendingCount > 0 ? ` (${fmt(pendingCount)})` : ""}
</button> </button>
<Can permission="HandlePayments">
<button type="button" disabled={count === 0} onClick={onPay} <button type="button" disabled={count === 0} onClick={onPay}
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]"> className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]">
<CreditCard className="size-5" /> پرداخت <CreditCard className="size-5" /> پرداخت
</button> </button>
</Can>
<button type="button" disabled className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground opacity-50"> <button type="button" disabled className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground opacity-50">
<Pause className="size-4" /> نگهداشتن <Pause className="size-4" /> نگهداشتن
</button> </button>
<Can permission="HandlePayments">
<button type="button" disabled={count === 0} onClick={onSplit} <button type="button" disabled={count === 0} onClick={onSplit}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40"> className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
<SplitSquareHorizontal className="size-4" /> تقسیم <SplitSquareHorizontal className="size-4" /> تقسیم
</button> </button>
</Can>
</div> </div>
</div> </div>
); );
@@ -33,6 +33,7 @@ import { ReportsChartsFallback } from "@/components/reports/reports-charts-fallb
import type { ReportsChartsProps } from "@/components/reports/reports-charts.types"; import type { ReportsChartsProps } from "@/components/reports/reports-charts.types";
import { PaymentCorrectionsTab } from "@/components/reports/payment-corrections-tab"; import { PaymentCorrectionsTab } from "@/components/reports/payment-corrections-tab";
import { AuditLogsTab } from "@/components/reports/audit-logs-tab"; import { AuditLogsTab } from "@/components/reports/audit-logs-tab";
import { Can } from "@/components/auth/can";
const LazyReportsCharts = lazy(() => const LazyReportsCharts = lazy(() =>
import("@/components/reports/reports-charts").then((m) => ({ import("@/components/reports/reports-charts").then((m) => ({
@@ -218,6 +219,7 @@ export function ReportsScreen() {
subtitle={t("subtitle")} subtitle={t("subtitle")}
action={ action={
tab === "performance" ? ( tab === "performance" ? (
<Can permission="ExportReports">
<Button <Button
variant="outline" variant="outline"
className="border-[#0F6E56]/40" className="border-[#0F6E56]/40"
@@ -227,6 +229,7 @@ export function ReportsScreen() {
<Download className="ms-2 h-4 w-4" /> <Download className="ms-2 h-4 w-4" />
{t("exportCsv")} {t("exportCsv")}
</Button> </Button>
</Can>
) : undefined ) : undefined
} }
/> />
@@ -19,6 +19,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Table } from "@/lib/api/types"; import type { Table } from "@/lib/api/types";
import { Can } from "@/components/auth/can";
type ReservationStatus = type ReservationStatus =
| "Pending" | "Pending"
@@ -194,12 +195,14 @@ export function ReservationsScreen() {
/> />
</LabeledField> </LabeledField>
<div className="sm:col-span-2 lg:col-span-3"> <div className="sm:col-span-2 lg:col-span-3">
<Can permission="CreateReservation">
<Button <Button
onClick={() => createReservation.mutate()} onClick={() => createReservation.mutate()}
disabled={!guestName.trim() || createReservation.isPending} disabled={!guestName.trim() || createReservation.isPending}
> >
{createReservation.isPending ? "..." : t("create")} {createReservation.isPending ? "..." : t("create")}
</Button> </Button>
</Can>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -228,12 +231,15 @@ export function ReservationsScreen() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{r.status === "Pending" && ( {r.status === "Pending" && (
<> <>
<Can permission="EditReservation">
<Button <Button
size="sm" size="sm"
onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })} onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })}
> >
{t("confirm")} {t("confirm")}
</Button> </Button>
</Can>
<Can permission="EditReservation">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -241,6 +247,7 @@ export function ReservationsScreen() {
> >
{t("cancel")} {t("cancel")}
</Button> </Button>
</Can>
</> </>
)} )}
{(r.status === "Confirmed" || r.status === "Seated") && ( {(r.status === "Confirmed" || r.status === "Seated") && (
@@ -249,6 +256,7 @@ export function ReservationsScreen() {
</Button> </Button>
)} )}
{r.status === "Seated" && ( {r.status === "Seated" && (
<Can permission="EditReservation">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -256,7 +264,9 @@ export function ReservationsScreen() {
> >
{t("markCompleted")} {t("markCompleted")}
</Button> </Button>
</Can>
)} )}
<Can permission="DeleteReservation">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -266,6 +276,7 @@ export function ReservationsScreen() {
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
</Can>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Can } from "@/components/auth/can";
const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"]; const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"];
const MANAGER_ROLES = new Set(["Owner", "Manager"]); const MANAGER_ROLES = new Set(["Owner", "Manager"]);
@@ -195,6 +196,7 @@ export function SmsScreen() {
</LabeledField> </LabeledField>
{/* Send button */} {/* Send button */}
<Can permission="SendSms">
<Button <Button
className="w-full" className="w-full"
disabled={!message.trim() || sendCampaign.isPending || !isConfigured} disabled={!message.trim() || sendCampaign.isPending || !isConfigured}
@@ -202,6 +204,7 @@ export function SmsScreen() {
> >
{sendCampaign.isPending ? t("sending") : t("send")} {sendCampaign.isPending ? t("sending") : t("send")}
</Button> </Button>
</Can>
{/* Result banner */} {/* Result banner */}
{result && ( {result && (
@@ -314,6 +317,7 @@ function ProviderSettingsCard({
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{settings?.isConfigured ? t("configured") : t("notConfigured")} {settings?.isConfigured ? t("configured") : t("notConfigured")}
</p> </p>
<Can permission="ManageSmsSettings">
<Button <Button
size="sm" size="sm"
disabled={!canSave || save.isPending} disabled={!canSave || save.isPending}
@@ -321,6 +325,7 @@ function ProviderSettingsCard({
> >
{save.isPending ? t("saving") : t("save")} {save.isPending ? t("saving") : t("save")}
</Button> </Button>
</Can>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -15,6 +15,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useConfirm } from "@/components/providers/confirm-provider"; import { useConfirm } from "@/components/providers/confirm-provider";
import { Can } from "@/components/auth/can";
interface TaxRow { interface TaxRow {
id: string; id: string;
@@ -89,6 +90,7 @@ export function TaxesScreen() {
subtitle={t("subtitle")} subtitle={t("subtitle")}
action={ action={
canEdit ? ( canEdit ? (
<Can permission="CreateTax">
<Button <Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]" className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!name.trim() || !rate} disabled={!name.trim() || !rate}
@@ -97,6 +99,7 @@ export function TaxesScreen() {
<Plus className="me-2 h-4 w-4" /> <Plus className="me-2 h-4 w-4" />
{t("addTax")} {t("addTax")}
</Button> </Button>
</Can>
) : undefined ) : undefined
} }
/> />
@@ -152,6 +155,7 @@ export function TaxesScreen() {
</Button> </Button>
) : null} ) : null}
{canEdit ? ( {canEdit ? (
<Can permission="DeleteTax">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -162,6 +166,7 @@ export function TaxesScreen() {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</Can>
) : null} ) : null}
</div> </div>
</li> </li>