feat: add vendor optional fields and configuration hooks
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation. - Created useCustomerConfig hook to load optional customer field settings from Sales configuration. - Developed useProcurementConfig hook to manage Procurement configuration flags. - Introduced accessConfigApi for managing access configuration settings. - Added openingBalancesApi for handling opening balances operations. - Created procurementConfigApi for managing procurement configuration settings. - Implemented nav-routes for permission-based routing. - Developed PDF generation for purchase orders with dynamic vendor and item details. - Added purchase-order tax helpers for tax calculations and formatting. - Defined types for opening balances and related operations.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Lock, RefreshCw, ShieldOff } from "lucide-react"
|
||||
|
||||
import { requiredCodesFor } from "@/lib/nav-routes"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
/**
|
||||
* Two doors. First: the page is shown only to people who hold a role. Someone who signs in with a valid login but has no usable role
|
||||
* (none assigned and no default role configured, or their role was deactivated) — or whose account is switched
|
||||
* off — gets a plain explanation instead of a menu full of "Forbidden" screens. "Check again" re-reads their role
|
||||
* from the server, so access an administrator just granted appears without signing out.
|
||||
*
|
||||
* Second: a person with a role still only opens the pages that role grants, even by typing the address
|
||||
* (lib/nav-routes.ts maps each page to its permission). The API enforces the same permissions on every call.
|
||||
*/
|
||||
export function AccessGate({ children }: { children: React.ReactNode }) {
|
||||
const { loading, hasRole, accountBlocked, refresh, can, roleName } = useAuth()
|
||||
const pathname = usePathname() || "/"
|
||||
const [checking, setChecking] = useState(false)
|
||||
|
||||
if (loading) return <>{children}</>
|
||||
|
||||
if (hasRole) {
|
||||
const required = requiredCodesFor(pathname)
|
||||
if (required === null || required.some((code) => can(code))) return <>{children}</>
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<ShieldOff className="size-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">You don't have access to this page</h1>
|
||||
<p className="max-w-md text-base text-muted-foreground">
|
||||
{roleName ? `Your role (${roleName}) doesn't include this area.` : "Your role doesn't include this area."} If you need it,
|
||||
ask an administrator to add it under Settings → Roles.
|
||||
</p>
|
||||
<Link href="/dashboard" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Back to the dashboard
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function checkAgain() {
|
||||
setChecking(true)
|
||||
try {
|
||||
await refresh()
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-warning/10 text-warning">
|
||||
<Lock className="size-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{accountBlocked ? "Your account is switched off" : "Waiting for access"}
|
||||
</h1>
|
||||
<p className="max-w-md text-base text-muted-foreground">
|
||||
{accountBlocked
|
||||
? "An administrator has deactivated this account. Please contact them if you think this is a mistake."
|
||||
: "You're signed in, but no role has been assigned to your account yet. Ask an administrator to give you one (Settings → Users), then check again."}
|
||||
</p>
|
||||
{!accountBlocked && (
|
||||
<Button variant="outline" size="lg" onClick={checkAgain} disabled={checking}>
|
||||
<RefreshCw className={checking ? "size-5 animate-spin" : "size-5"} />
|
||||
{checking ? "Checking…" : "Check again"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client"
|
||||
|
||||
// Authoritative session context: fetches GET /auth/me once per mount and exposes
|
||||
// the current role + permitted sidebar nav codes. Replaces trusting the stale,
|
||||
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
|
||||
// (that cache remains display-only, e.g. for the header's user name).
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
// Authoritative session context: fetches GET /auth/me and exposes the current role and what it may do.
|
||||
// The role lives in the ERP (users.RoleId) and is read on every request, so this is refetched whenever the
|
||||
// window regains focus (and on demand via `refresh`) — a role or permission change made by an administrator
|
||||
// shows up without signing out. Replaces trusting the stale, client-only `roleId` cached by
|
||||
// lib/auth-session.ts for anything access-related (that cache remains display-only, e.g. the header's name).
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { MeResponse } from "@/types/rbac"
|
||||
import { ApiError, FORBIDDEN_EVENT } from "@/lib/api-client"
|
||||
import { MeResponse, RolePermissionAction } from "@/types/rbac"
|
||||
|
||||
interface ActiveBranch {
|
||||
id: number | null
|
||||
@@ -19,6 +21,9 @@ interface ActiveBranch {
|
||||
* display/filter convenience — never trusted server-side. */
|
||||
const ACTIVE_BRANCH_KEY = "erpcore.activeBranch"
|
||||
|
||||
/** Don't re-ask the server more often than this when the window keeps regaining focus. */
|
||||
const FOCUS_REFRESH_MIN_MS = 15_000
|
||||
|
||||
function readStoredActiveBranch(): ActiveBranch | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const raw = window.localStorage.getItem(ACTIVE_BRANCH_KEY)
|
||||
@@ -31,12 +36,28 @@ function readStoredActiveBranch(): ActiveBranch | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** The names of the actions a permission can grant (Approve, Delete, …). */
|
||||
export type PermissionActionName = keyof typeof RolePermissionAction
|
||||
|
||||
interface AuthContextValue {
|
||||
roleId: number | null
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
navCodes: string[]
|
||||
/** True for the Admin role — the only role that may pick any branch (others are locked to their own). */
|
||||
/** True when the person has a usable role. False means "waiting for access": no role assigned and no default, or an inactive role. */
|
||||
hasRole: boolean
|
||||
/** True for the administrator role — the only role that may pick any branch (others are locked to their own). */
|
||||
isAdmin: boolean
|
||||
/**
|
||||
* Whether the role may reach `navCode` (a module, or a screen within one) and — when `action` is given — holds that
|
||||
* action on exactly that code. Administrators may do everything. Hide or disable UI with this; the server
|
||||
* enforces the same rules, so this is a convenience, never the protection.
|
||||
*/
|
||||
can: (navCode: string, action?: PermissionActionName) => boolean
|
||||
/** Re-reads the role and permissions from the server (after changing your own role, say). */
|
||||
refresh: () => Promise<void>
|
||||
/** True when the account itself is switched off (the server answered 403 to /auth/me). */
|
||||
accountBlocked: boolean
|
||||
/** The caller's own branch, via their linked Employee record — null if unlinked. */
|
||||
branchId: number | null
|
||||
branchName: string | null
|
||||
@@ -67,10 +88,15 @@ interface AuthContextValue {
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
roleId: null,
|
||||
roleCode: null,
|
||||
roleName: null,
|
||||
navCodes: [],
|
||||
hasRole: false,
|
||||
isAdmin: false,
|
||||
can: () => false,
|
||||
refresh: async () => {},
|
||||
accountBlocked: false,
|
||||
branchId: null,
|
||||
branchName: null,
|
||||
activeBranchId: null,
|
||||
@@ -81,27 +107,50 @@ const AuthContext = createContext<AuthContextValue>({
|
||||
loading: true,
|
||||
})
|
||||
|
||||
const NO_ACCESS: MeResponse = {
|
||||
roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null,
|
||||
branchingEnabled: true, warehouseBranchingEnabled: true, roleId: null, hasRole: false, permissions: {},
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [accountBlocked, setAccountBlocked] = useState(false)
|
||||
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null)
|
||||
const lastFetch = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
authApi
|
||||
.me()
|
||||
.then((res) => {
|
||||
if (!cancelled) setMe(res)
|
||||
})
|
||||
.catch(() => {
|
||||
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
||||
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
const load = useCallback(async () => {
|
||||
lastFetch.current = Date.now()
|
||||
try {
|
||||
const res = await authApi.me()
|
||||
setMe(res)
|
||||
setAccountBlocked(false)
|
||||
} catch (err) {
|
||||
// 403 = the ERP account is switched off; anything else (unauthenticated/unreachable) falls back to
|
||||
// "no permissions" rather than crashing the shell — proxy.ts already redirects unauthenticated users to /login.
|
||||
setAccountBlocked(err instanceof ApiError && err.status === 403)
|
||||
setMe((prev) => prev ?? NO_ACCESS)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// The role is read from the ERP on every request, so keep the UI in step with it: refetch when the person comes
|
||||
// back to the tab (rate-limited), which picks up a role or permission change an administrator just made.
|
||||
useEffect(() => {
|
||||
function onFocus() {
|
||||
if (Date.now() - lastFetch.current >= FOCUS_REFRESH_MIN_MS) void load()
|
||||
}
|
||||
// A 403 from any call means what this tab believes the role can do may be stale: re-read it (same rate limit).
|
||||
window.addEventListener("focus", onFocus)
|
||||
window.addEventListener(FORBIDDEN_EVENT, onFocus)
|
||||
return () => {
|
||||
window.removeEventListener("focus", onFocus)
|
||||
window.removeEventListener(FORBIDDEN_EVENT, onFocus)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (me === null) return
|
||||
// Non-admins are always pinned to their own branch — never their own past picks.
|
||||
@@ -115,13 +164,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(ACTIVE_BRANCH_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
const can = useCallback(
|
||||
(navCode: string, action?: PermissionActionName): boolean => {
|
||||
if (!me) return false
|
||||
if (me.isAdmin) return true
|
||||
const permissions = me.permissions ?? {}
|
||||
if (action) {
|
||||
const granted = permissions[navCode]
|
||||
return granted !== undefined && (granted & RolePermissionAction[action]) !== 0
|
||||
}
|
||||
// Reaching a module: the code itself, or any screen inside it ("procurement" matches "procurement.vendors").
|
||||
return navCode in permissions || Object.keys(permissions).some((c) => c.startsWith(navCode + "."))
|
||||
},
|
||||
[me],
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
roleId: me?.roleId ?? null,
|
||||
roleCode: me?.roleCode ?? null,
|
||||
roleName: me?.roleName ?? null,
|
||||
navCodes: me?.navCodes ?? [],
|
||||
hasRole: me?.hasRole ?? false,
|
||||
isAdmin: me?.isAdmin ?? false,
|
||||
can,
|
||||
refresh: load,
|
||||
accountBlocked,
|
||||
branchId: me?.branchId ?? null,
|
||||
branchName: me?.branchName ?? null,
|
||||
activeBranchId: activeBranch?.id ?? null,
|
||||
|
||||
Reference in New Issue
Block a user