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
@@ -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() {
/>
</LabeledField>
<div className="sm:col-span-2 lg:col-span-3">
<Button
onClick={() => createReservation.mutate()}
disabled={!guestName.trim() || createReservation.isPending}
>
{createReservation.isPending ? "..." : t("create")}
</Button>
<Can permission="CreateReservation">
<Button
onClick={() => createReservation.mutate()}
disabled={!guestName.trim() || createReservation.isPending}
>
{createReservation.isPending ? "..." : t("create")}
</Button>
</Can>
</div>
</CardContent>
</Card>
@@ -228,19 +231,23 @@ export function ReservationsScreen() {
<div className="flex flex-wrap gap-2">
{r.status === "Pending" && (
<>
<Button
size="sm"
onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })}
>
{t("confirm")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Cancelled" })}
>
{t("cancel")}
</Button>
<Can permission="EditReservation">
<Button
size="sm"
onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })}
>
{t("confirm")}
</Button>
</Can>
<Can permission="EditReservation">
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Cancelled" })}
>
{t("cancel")}
</Button>
</Can>
</>
)}
{(r.status === "Confirmed" || r.status === "Seated") && (
@@ -249,23 +256,27 @@ export function ReservationsScreen() {
</Button>
)}
{r.status === "Seated" && (
<Can permission="EditReservation">
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Completed" })}
>
{t("markCompleted")}
</Button>
</Can>
)}
<Can permission="DeleteReservation">
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Completed" })}
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(r)}
>
{t("markCompleted")}
<Trash2 className="size-4" />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(r)}
>
<Trash2 className="size-4" />
</Button>
</Can>
</div>
</CardContent>
</Card>