first commit
CI/CD / CI · Admin API (dotnet build) (push) Successful in 41s
CI/CD / CI · Admin Web (tsc) (push) Failing after 5s
CI/CD / CI · Website (tsc) (push) Failing after 4s
CI/CD / CI · Koja (tsc) (push) Failing after 5s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m13s
CI/CD / CI · Dashboard (tsc) (push) Failing after 2m32s
CI/CD / Deploy · all services (push) Has been skipped
CI/CD / CI · Admin API (dotnet build) (push) Successful in 41s
CI/CD / CI · Admin Web (tsc) (push) Failing after 5s
CI/CD / CI · Website (tsc) (push) Failing after 4s
CI/CD / CI · Koja (tsc) (push) Failing after 5s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m13s
CI/CD / CI · Dashboard (tsc) (push) Failing after 2m32s
CI/CD / Deploy · all services (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/lib/api/client";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
|
||||
export interface BranchRoleAssignment {
|
||||
id: string;
|
||||
branchId: string;
|
||||
branchName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function listBranchRoles(cafeId: string, employeeId: string) {
|
||||
return apiGet<BranchRoleAssignment[]>(
|
||||
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles`
|
||||
);
|
||||
}
|
||||
|
||||
export function assignBranchRole(
|
||||
cafeId: string,
|
||||
employeeId: string,
|
||||
body: { branchId: string; role: string }
|
||||
) {
|
||||
return apiPost<BranchRoleAssignment, typeof body>(
|
||||
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles`,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
export function updateBranchRole(
|
||||
cafeId: string,
|
||||
employeeId: string,
|
||||
assignmentId: string,
|
||||
role: string
|
||||
) {
|
||||
return apiPatch<BranchRoleAssignment, { role: string }>(
|
||||
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles/${assignmentId}`,
|
||||
{ role }
|
||||
);
|
||||
}
|
||||
|
||||
export function removeBranchRole(
|
||||
cafeId: string,
|
||||
employeeId: string,
|
||||
assignmentId: string
|
||||
) {
|
||||
return apiDelete(
|
||||
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles/${assignmentId}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-issue the session token scoped to a branch (null = café-wide, Owner only). */
|
||||
export function switchBranch(branchId: string | null) {
|
||||
return apiPost<AuthTokenResponse, { branchId: string | null }>(
|
||||
`/api/auth/switch-branch`,
|
||||
{ branchId }
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,12 @@ export interface CafeMembership {
|
||||
planTier: string;
|
||||
}
|
||||
|
||||
export interface BranchMembership {
|
||||
branchId: string;
|
||||
branchName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
@@ -23,6 +29,14 @@ export interface AuthTokenResponse {
|
||||
actor?: string;
|
||||
branchId?: string | null;
|
||||
memberships?: CafeMembership[] | null;
|
||||
/** Display name of the currently active branch (null when café-wide). */
|
||||
branchName?: string | null;
|
||||
/** True when the session spans the whole café (Owner, no branch scope). */
|
||||
isCafeWide?: boolean;
|
||||
/** Branches this employee may operate as, with their role in each. */
|
||||
branches?: BranchMembership[] | null;
|
||||
/** Effective capabilities for the active role — drives page/action gating. */
|
||||
permissions?: string[] | null;
|
||||
}
|
||||
|
||||
/** Returned (in the data field) when a phone belongs to multiple cafés. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BRANCH_ONLY_NAV_GROUP, type NavGroupId } from "@/lib/sidebar-nav";
|
||||
import { BRANCH_ONLY_NAV_GROUP, type NavGroupId, type NavItemKey } from "@/lib/sidebar-nav";
|
||||
import { NAV_REQUIRED_PERMISSION } from "@/lib/permissions";
|
||||
|
||||
/** Cafe owner (HQ) — billing, taxes, branches. */
|
||||
export function isCafeOwner(role: string | undefined): boolean {
|
||||
@@ -26,7 +27,8 @@ export function canSeeNavGroup(
|
||||
export function canSeeNavItem(
|
||||
key: string,
|
||||
role: string | undefined,
|
||||
branchId: string | null | undefined
|
||||
branchId: string | null | undefined,
|
||||
permissions?: Set<string> | null
|
||||
): boolean {
|
||||
if ((OWNER_ONLY_NAV_KEYS as readonly string[]).includes(key) && !isCafeOwner(role)) {
|
||||
return false;
|
||||
@@ -34,5 +36,14 @@ export function canSeeNavItem(
|
||||
if (key === "branches" && isBranchAccount(branchId)) {
|
||||
return false;
|
||||
}
|
||||
// Permission-based page visibility. `permissions === null` means a legacy
|
||||
// session with no permission list — fall back to the role/branch rules above
|
||||
// so those users keep their current access until the next token refresh.
|
||||
if (permissions) {
|
||||
const required = NAV_REQUIRED_PERMISSION[key as NavItemKey];
|
||||
if (required && !permissions.has(required)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import type { NavItemKey } from "@/lib/sidebar-nav";
|
||||
|
||||
/**
|
||||
* Client mirror of the backend `Meezi.Core.Authorization.Permission` enum. The
|
||||
* server (EnsurePermission) remains the single source of truth — these values
|
||||
* only drive what the UI *shows* (pages, action buttons). Never rely on them
|
||||
* for actual security.
|
||||
*/
|
||||
export type Permission =
|
||||
| "ManageCafeSettings"
|
||||
| "ManageBilling"
|
||||
| "ManageBranches"
|
||||
| "ManageStaff"
|
||||
| "ManageMenu"
|
||||
| "ManageInventory"
|
||||
| "ManageExpenses"
|
||||
| "ManageTaxes"
|
||||
| "ManageCoupons"
|
||||
| "ManageReservations"
|
||||
| "ManageTables"
|
||||
| "ViewReports"
|
||||
| "ReviewLeave"
|
||||
| "ManageSalaries"
|
||||
| "ManagePrintSettings"
|
||||
| "ProcessOrders"
|
||||
| "HandlePayments"
|
||||
| "OperateRegister"
|
||||
| "ManageQueue"
|
||||
| "ViewKitchen"
|
||||
| "HandleDelivery";
|
||||
|
||||
/**
|
||||
* Permission a nav page requires to be visible. Pages not listed here fall back
|
||||
* to the existing owner-only / branch-account visibility logic in
|
||||
* {@link file://./auth-permissions.ts}.
|
||||
*/
|
||||
export const NAV_REQUIRED_PERMISSION: Partial<Record<NavItemKey, Permission>> = {
|
||||
pos: "ProcessOrders",
|
||||
tables: "ManageTables",
|
||||
queue: "ManageQueue",
|
||||
kds: "ViewKitchen",
|
||||
reservations: "ManageReservations",
|
||||
menu: "ManageMenu",
|
||||
inventory: "ManageInventory",
|
||||
coupons: "ManageCoupons",
|
||||
reports: "ViewReports",
|
||||
expenses: "ManageExpenses",
|
||||
shifts: "OperateRegister",
|
||||
taxes: "ManageTaxes",
|
||||
hr: "ManageStaff",
|
||||
};
|
||||
|
||||
/** Read the effective permission set off an auth response (null = legacy session). */
|
||||
export function permissionsOf(
|
||||
user: { permissions?: string[] | null } | null | undefined
|
||||
): Set<string> | null {
|
||||
if (!user?.permissions) return null;
|
||||
return new Set(user.permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user holds a capability. Legacy sessions (no permissions array, e.g.
|
||||
* issued before this feature shipped) return `true` so the UI degrades gracefully
|
||||
* until the next token refresh — the server still enforces real access.
|
||||
*/
|
||||
export function hasPermission(
|
||||
user: { permissions?: string[] | null } | null | undefined,
|
||||
permission: Permission
|
||||
): boolean {
|
||||
const set = permissionsOf(user);
|
||||
if (set === null) return true;
|
||||
return set.has(permission);
|
||||
}
|
||||
|
||||
/** React hook: does the current user hold the given permission? */
|
||||
export function useHasPermission(permission: Permission): boolean {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
return hasPermission(user, permission);
|
||||
}
|
||||
Reference in New Issue
Block a user