|
|
@@ -1,35 +1,102 @@
|
|
|
"use client";
|
|
|
|
|
|
import React from "react";
|
|
|
+import { Loader2 } from "lucide-react";
|
|
|
|
|
|
import { useAuth } from "@/components/auth/authContext";
|
|
|
-import ForbiddenView from "@/components/system/ForbiddenView";
|
|
|
+import { getBranches } from "@/lib/frontend/apiClient";
|
|
|
import {
|
|
|
- getBranchAccess,
|
|
|
- BRANCH_ACCESS,
|
|
|
-} from "@/lib/frontend/rbac/branchAccess";
|
|
|
-
|
|
|
-/**
|
|
|
- * BranchGuard (RHL-021)
|
|
|
- *
|
|
|
- * UI-side RBAC guard for branch-based routes.
|
|
|
- *
|
|
|
- * This guard assumes that AuthProvider already handled session checks and redirects.
|
|
|
- * Therefore:
|
|
|
- * - If the auth state is not authenticated yet, we render children (AuthProvider gating).
|
|
|
- * - If authenticated, we enforce branch-level RBAC for role="branch".
|
|
|
- */
|
|
|
+ decideBranchUi,
|
|
|
+ BRANCH_UI_DECISION,
|
|
|
+} from "@/lib/frontend/rbac/branchUiDecision";
|
|
|
+
|
|
|
+import ForbiddenView from "@/components/system/ForbiddenView";
|
|
|
+import NotFoundView from "@/components/system/NotFoundView";
|
|
|
+
|
|
|
+const BRANCH_LIST_STATE = Object.freeze({
|
|
|
+ IDLE: "idle",
|
|
|
+ LOADING: "loading",
|
|
|
+ READY: "ready",
|
|
|
+ ERROR: "error",
|
|
|
+});
|
|
|
+
|
|
|
+function BranchValidationLoading() {
|
|
|
+ return (
|
|
|
+ <div className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
|
+ <Loader2 className="h-4 w-4 animate-spin" />
|
|
|
+ <span>Validating branch...</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
export default function BranchGuard({ branch, children }) {
|
|
|
const { status, user } = useAuth();
|
|
|
|
|
|
- const access = React.useMemo(() => {
|
|
|
- if (status !== "authenticated") return BRANCH_ACCESS.ALLOWED;
|
|
|
- return getBranchAccess(user, branch);
|
|
|
- }, [status, user, branch]);
|
|
|
+ const isAuthenticated = status === "authenticated" && user;
|
|
|
+ const needsExistenceCheck =
|
|
|
+ isAuthenticated && (user.role === "admin" || user.role === "dev");
|
|
|
+
|
|
|
+ const [branchList, setBranchList] = React.useState({
|
|
|
+ status: BRANCH_LIST_STATE.IDLE,
|
|
|
+ branches: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ React.useEffect(() => {
|
|
|
+ if (!needsExistenceCheck) return;
|
|
|
+
|
|
|
+ let cancelled = false;
|
|
|
+
|
|
|
+ setBranchList({ status: BRANCH_LIST_STATE.LOADING, branches: null });
|
|
|
+
|
|
|
+ (async () => {
|
|
|
+ try {
|
|
|
+ const res = await getBranches();
|
|
|
+ if (cancelled) return;
|
|
|
|
|
|
- if (access === BRANCH_ACCESS.FORBIDDEN) {
|
|
|
+ const branches = Array.isArray(res?.branches) ? res.branches : [];
|
|
|
+ setBranchList({ status: BRANCH_LIST_STATE.READY, branches });
|
|
|
+ } catch (err) {
|
|
|
+ if (cancelled) return;
|
|
|
+
|
|
|
+ // Fail open: do not block navigation if validation fails.
|
|
|
+ console.error("[BranchGuard] getBranches failed:", err);
|
|
|
+ setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null });
|
|
|
+ }
|
|
|
+ })();
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ cancelled = true;
|
|
|
+ };
|
|
|
+
|
|
|
+ // IMPORTANT:
|
|
|
+ // - depend only on user identity + needsExistenceCheck
|
|
|
+ // - do NOT depend on branchList.status (would cancel itself when setting LOADING)
|
|
|
+ }, [needsExistenceCheck, user?.userId]);
|
|
|
+
|
|
|
+ if (!isAuthenticated) {
|
|
|
+ return children;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (needsExistenceCheck && branchList.status === BRANCH_LIST_STATE.LOADING) {
|
|
|
+ return <BranchValidationLoading />;
|
|
|
+ }
|
|
|
+
|
|
|
+ const allowedBranches =
|
|
|
+ branchList.status === BRANCH_LIST_STATE.READY ? branchList.branches : null;
|
|
|
+
|
|
|
+ const decision = decideBranchUi({
|
|
|
+ user,
|
|
|
+ branch,
|
|
|
+ allowedBranches,
|
|
|
+ });
|
|
|
+
|
|
|
+ if (decision === BRANCH_UI_DECISION.FORBIDDEN) {
|
|
|
return <ForbiddenView attemptedBranch={branch} />;
|
|
|
}
|
|
|
|
|
|
+ if (decision === BRANCH_UI_DECISION.NOT_FOUND) {
|
|
|
+ return <NotFoundView />;
|
|
|
+ }
|
|
|
+
|
|
|
return children;
|
|
|
}
|