diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index ecf6e3ba..e68dc5c7 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -1,4 +1,4 @@ -import type { MouseEvent } from "react"; +import { useContext, type MouseEvent } from "react"; import { MapPin } from "lucide-react"; import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; @@ -13,6 +13,8 @@ import { WizardStepLocationAccountSelect } from "@/app/(protected)/workorders/_c import { SiteDialogPocSection } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section"; import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types"; import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state"; +import { isOrgWideScope } from "@/lib/auth/org-scope"; +import { AuthContext } from "@/providers/auth-context"; export type { SitePatch }; @@ -47,6 +49,7 @@ export function SiteDialog({ viewOnly = false, onSave, }: SiteDialogProps) { + const requireAccount = isOrgWideScope(useContext(AuthContext)?.user); const s = useSiteDialogState({ open, onOpenChange, @@ -59,6 +62,7 @@ export function SiteDialog({ additionalContacts, sites, createMode, + requireAccount, onSave, }); @@ -85,7 +89,7 @@ export function SiteDialog({
- {createMode && ( + {Boolean(createMode && requireAccount) && ( void; }; @@ -12,6 +13,7 @@ export function useSiteDialogAccountField({ open, customer, createMode, + requireAccount = true, dirty, onDirty, }: UseSiteDialogAccountFieldArgs) { @@ -26,8 +28,8 @@ export function useSiteDialogAccountField({ return { accountName, - accountMissing: createMode && !accountName.trim(), - customerPatch: createMode ? { customer: accountName } : {}, + accountMissing: createMode && requireAccount && !accountName.trim(), + customerPatch: createMode && requireAccount ? { customer: accountName } : {}, setAccountName: (next: string) => { onDirty(); setAccountNameState(next); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts index 5259e328..ca321241 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts @@ -22,6 +22,7 @@ type UseSiteDialogStateArgs = { additionalContacts: WorkOrderAdditionalContact[]; sites: LocationOption[]; createMode: boolean; + requireAccount?: boolean; onSave: (patch: SitePatch) => void; }; @@ -37,6 +38,7 @@ export function useSiteDialogState({ additionalContacts = [], sites, createMode, + requireAccount = true, onSave, }: UseSiteDialogStateArgs) { const fields = useSiteDialogFormFields({ @@ -54,6 +56,7 @@ export function useSiteDialogState({ open, customer, createMode, + requireAccount, dirty: fields.dirty, onDirty: fields.markDirty, }); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index 94431d21..9a0d3a8a 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type RefObject } from "react"; +import { useContext, useRef, useState, type RefObject } from "react"; import { Flag } from "lucide-react"; import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog"; @@ -21,6 +21,8 @@ import { inlineCreateSaveHint, inlineCreateSiteError, } from "@/domain/work-orders/utils/get-inline-create-missing"; +import { isOrgWideScope } from "@/lib/auth/org-scope"; +import { AuthContext } from "@/providers/auth-context"; import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols"; import { WoTableInlineRowCells } from "./wo-table-inline-row-cells"; @@ -71,7 +73,9 @@ export function WoTableInlineRow({ onDuplicateFound, }: WoTableInlineRowProps) { const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY; - const missing = getInlineCreateMissing(draft); + const missing = getInlineCreateMissing(draft, { + requireAccount: isOrgWideScope(useContext(AuthContext)?.user), + }); const canSave = !saving && missing.length === 0; const [showErrors, setShowErrors] = useState(false); const [confirmComplete, setConfirmComplete] = useState(false); diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx index 36a4e4ba..9d6a2207 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx @@ -1,3 +1,4 @@ +import { useContext } from "react"; import { Check, ChevronLeft, ChevronRight, Lightbulb } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -7,8 +8,11 @@ import { isWizardStepValid, isWizardWoNumberValid, wizardStep2ContinueHint, + type WizardAccountGate, type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; +import { isOrgWideScope } from "@/lib/auth/org-scope"; +import { AuthContext } from "@/providers/auth-context"; type WizardFooterProps = { step: number; @@ -21,7 +25,11 @@ type WizardFooterProps = { isCreating: boolean; }; -function stepHint(step: number, draft: WorkOrderWizardDraft): string | null { +function stepHint( + step: number, + draft: WorkOrderWizardDraft, + gate: WizardAccountGate, +): string | null { if (step === 1 && !canContinueWizardStep1(draft)) { if (!isWizardWoNumberValid(draft.woNumber)) { return "Enter an alphanumeric WO number, or leave blank for SH generation"; @@ -29,7 +37,7 @@ function stepHint(step: number, draft: WorkOrderWizardDraft): string | null { return "Select severity to continue"; } if (step === 2) { - return wizardStep2ContinueHint(draft); + return wizardStep2ContinueHint(draft, gate); } return null; } @@ -44,9 +52,10 @@ export function WizardFooter({ onAttemptContinue, isCreating, }: WizardFooterProps) { - const stepValid = isWizardStepValid(step, draft); - const canCreate = canCreateWorkOrderFromWizard(draft); - const hint = stepHint(step, draft); + const accountGate = { requireAccount: isOrgWideScope(useContext(AuthContext)?.user) }; + const stepValid = isWizardStepValid(step, draft, accountGate); + const canCreate = canCreateWorkOrderFromWizard(draft, accountGate); + const hint = stepHint(step, draft, accountGate); return (
account.name.trim()).filter(Boolean); const selected = customer.trim(); + if (!orgWide) { + return null; + } + return (
Account diff --git a/src/domain/accounts/use-cases/use-accounts-list.ts b/src/domain/accounts/use-cases/use-accounts-list.ts index 9dcb347a..2a031a0c 100644 --- a/src/domain/accounts/use-cases/use-accounts-list.ts +++ b/src/domain/accounts/use-cases/use-accounts-list.ts @@ -5,10 +5,12 @@ import { queryKeys } from "@/infra/query-key/query-key"; export function useAccountsList( params: AccountsListParams, + enabled = true, ): UseQueryResult { return useQuery({ queryKey: queryKeys.accounts.list(params as Record), queryFn: () => accountsApi.getList(params), placeholderData: keepPreviousData, + enabled, }); } diff --git a/src/domain/auth/types/auth-user.ts b/src/domain/auth/types/auth-user.ts index 6c5faf2a..f16f38b0 100644 --- a/src/domain/auth/types/auth-user.ts +++ b/src/domain/auth/types/auth-user.ts @@ -7,6 +7,9 @@ export interface AuthUser { fullname: string; id: string | number; name?: string; + /** JWT / login `org_scope`. Only `all` may see org-wide account lists. */ + orgScope?: string; + accountId?: string | number; } export interface LoginCredentials { diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index d9573867..20a53a92 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -197,34 +197,56 @@ export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean { return true; } -export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean { - const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim())); - return Boolean(hasSite && draft.customer.trim() && draft.pocName.trim() && draft.pocPhone.trim()); +export type WizardAccountGate = { + requireAccount?: boolean; +}; + +function requiresAccount(options?: WizardAccountGate): boolean { + return options?.requireAccount !== false; } -export function wizardStep2ContinueHint(draft: WorkOrderWizardDraft): string | null { - if (canContinueWizardStep2(draft)) { +export function canContinueWizardStep2( + draft: WorkOrderWizardDraft, + options?: WizardAccountGate, +): boolean { + const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim())); + const hasAccount = !requiresAccount(options) || Boolean(draft.customer.trim()); + return Boolean(hasSite && hasAccount && draft.pocName.trim() && draft.pocPhone.trim()); +} + +export function wizardStep2ContinueHint( + draft: WorkOrderWizardDraft, + options?: WizardAccountGate, +): string | null { + if (canContinueWizardStep2(draft, options)) { return null; } if (!draft.locationId) { return "Pick a site to continue"; } - if (!draft.customer.trim()) { + if (requiresAccount(options) && !draft.customer.trim()) { return "Select an account to continue"; } return "Enter POC name and phone to continue"; } -export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean { - return canContinueWizardStep1(draft) && canContinueWizardStep2(draft); +export function canCreateWorkOrderFromWizard( + draft: WorkOrderWizardDraft, + options?: WizardAccountGate, +): boolean { + return canContinueWizardStep1(draft) && canContinueWizardStep2(draft, options); } -export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean { +export function isWizardStepValid( + step: number, + draft: WorkOrderWizardDraft, + options?: WizardAccountGate, +): boolean { if (step === 1) { return canContinueWizardStep1(draft); } if (step === 2) { - return canContinueWizardStep2(draft); + return canContinueWizardStep2(draft, options); } return true; } diff --git a/src/domain/work-orders/utils/get-inline-create-missing.ts b/src/domain/work-orders/utils/get-inline-create-missing.ts index 5c2378f2..6c2ea6e2 100644 --- a/src/domain/work-orders/utils/get-inline-create-missing.ts +++ b/src/domain/work-orders/utils/get-inline-create-missing.ts @@ -18,9 +18,16 @@ export type InlineCreateDraftFields = { pocPhone?: string; }; -export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] { +export type InlineCreateAccountGate = { + requireAccount?: boolean; +}; + +export function getInlineCreateMissing( + d: InlineCreateDraftFields, + options?: InlineCreateAccountGate, +): InlineCreateReqField[] { const miss: InlineCreateReqField[] = []; - if (!d.customer?.trim()) { + if (options?.requireAccount !== false && !d.customer?.trim()) { miss.push("customer"); } const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim()); diff --git a/src/lib/auth/org-scope.ts b/src/lib/auth/org-scope.ts new file mode 100644 index 00000000..95afebd1 --- /dev/null +++ b/src/lib/auth/org-scope.ts @@ -0,0 +1,45 @@ +import type { AuthUser } from "@/domain/auth/types/auth-user"; + +const ORG_SCOPE_ALL = "all"; + +function readJwtClaims(token: string): Record | null { + const parts = token.split("."); + if (parts.length < 2) { + return null; + } + try { + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const parsed: unknown = JSON.parse(globalThis.atob(padded)); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + +function readStringClaim(source: Record | null, keys: string[]): string { + if (!source) { + return ""; + } + for (const key of keys) { + const value = source[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +/** Org-wide only when the authenticated scope is explicitly `all`. Fail-closed otherwise. */ +export function isOrgWideScope(user: AuthUser | null | undefined): boolean { + if (!user) { + return false; + } + const claims = typeof user.token === "string" ? readJwtClaims(user.token) : null; + const fromJwt = readStringClaim(claims, ["org_scope", "orgScope", "OrgScope"]); + const fromUser = typeof user.orgScope === "string" ? user.orgScope.trim() : ""; + return (fromJwt || fromUser).toLowerCase() === ORG_SCOPE_ALL; +} diff --git a/src/test/app/(protected)/workorders/wizard-step-location-account-select.test.tsx b/src/test/app/(protected)/workorders/wizard-step-location-account-select.test.tsx index 4276fb3d..c6ccb517 100644 --- a/src/test/app/(protected)/workorders/wizard-step-location-account-select.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-step-location-account-select.test.tsx @@ -1,6 +1,8 @@ import { fireEvent, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { WizardStepLocationAccountSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select"; +import type { AuthUser } from "@/domain/auth/types/auth-user"; +import { AuthContext, type AuthContextValue } from "@/providers/auth-context"; import { renderWithProviders } from "@/test/test-utils"; const { useAccountsListMock } = vi.hoisted(() => ({ @@ -12,9 +14,11 @@ vi.mock("@/hooks/use-debounce", () => ({ })); vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ - useAccountsList: (params: unknown) => useAccountsListMock(params), + useAccountsList: (params: unknown, enabled?: boolean) => useAccountsListMock(params, enabled), })); +const FOREIGN_ACCOUNT = "Contoso Other Tenant"; + const ACCOUNTS = { items: [ { @@ -28,8 +32,8 @@ const ACCOUNTS = { owner: "", }, { - id: 2, - name: "Sea Haven", + id: 7, + name: FOREIGN_ACCOUNT, number: "", industry: "", website: "", @@ -40,15 +44,60 @@ const ACCOUNTS = { ], }; +function encodeJwt(payload: Record): string { + const json = JSON.stringify(payload); + const b64 = btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + return `eyJhbGciOiJub25lIn0.${b64}.sig`; +} + +function authUser(token: string): AuthUser { + return { + token, + expiration: new Date(Date.now() + 60_000).toISOString(), + email: "user@seahavenind.com", + userRoles: "User", + phoneNumber: "", + fullname: "Test User", + id: 1, + }; +} + +function authValue(user: AuthUser): AuthContextValue { + return { + user, + isAuthenticated: true, + isLoading: false, + error: null, + login: vi.fn(), + logout: vi.fn(), + changePassword: vi.fn(), + isLoggingIn: false, + isLoggingOut: false, + isChangingPassword: false, + loginError: null, + }; +} + +function renderSelect(user: AuthUser) { + return renderWithProviders( + + + , + { withAuth: false }, + ); +} + describe("WizardStepLocationAccountSelect", () => { beforeEach(() => { useAccountsListMock.mockReturnValue({ data: ACCOUNTS }); }); - it("sets draft.customer to the exact account name on pick", () => { + it("sets draft.customer to the exact account name on pick for org-wide scope", () => { const onCustomerChange = vi.fn(); renderWithProviders( - , + + + , { withAuth: false }, ); @@ -56,21 +105,31 @@ describe("WizardStepLocationAccountSelect", () => { fireEvent.click(screen.getByRole("button", { name: "Amazon" })); expect(onCustomerChange).toHaveBeenCalledWith("Amazon"); + expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" }, true); }); - it("passes typed search to the accounts list query", () => { - renderWithProviders( - , - { withAuth: false }, - ); + it("passes typed search to the accounts list query for org-wide scope", () => { + renderSelect(authUser(encodeJwt({ org_scope: "all" }))); - expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" }); + expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" }, true); fireEvent.click(screen.getByRole("button", { name: /select account/i })); fireEvent.change(screen.getByPlaceholderText("Search account name…"), { target: { value: "Sea" }, }); - expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "Sea" }); + expect(useAccountsListMock).toHaveBeenCalledWith( + { page: 1, pageSize: 50, search: "Sea" }, + true, + ); + }); + + it("does not fetch or expose another account name for a scoped caller", () => { + renderSelect(authUser(encodeJwt({ org_scope: "account", account_id: 7 }))); + + expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" }, false); + expect(screen.queryByRole("button", { name: /select account/i })).not.toBeInTheDocument(); + expect(screen.queryByText(FOREIGN_ACCOUNT)).not.toBeInTheDocument(); + expect(screen.queryByText("Amazon")).not.toBeInTheDocument(); }); }); diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx index 9597efee..7e2ccec8 100644 --- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx @@ -3,9 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ useWorkOrderBoardDetail: () => ({ diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index d62baec0..5b1e50c8 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -22,9 +22,13 @@ vi.mock("react-router", async () => { }; }); -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ useWorkOrdersListFilters: () => ({ diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index e8f8ce8e..2dbdc94e 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -16,9 +16,13 @@ vi.mock("react-router", async () => { }; }); -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ useWorkOrdersListFilters: () => ({ diff --git a/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx index 9458b1cd..246da779 100644 --- a/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx @@ -12,9 +12,13 @@ vi.mock("react-router", async () => { }; }); -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ useWorkOrdersListFilters: () => ({ diff --git a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts index 17c488f0..7079dcb0 100644 --- a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts @@ -124,6 +124,21 @@ describe("wizard step validation", () => { expect(isWizardStepValid(2, draft)).toBe(false); }); + it("allows step 2 without customer when account is not required", () => { + const draft = { + ...EMPTY_WIZARD_DRAFT, + type: "PM" as const, + woNumber: "12345", + locationId: "99", + siteCode: "BK5", + pocName: "Jordan", + pocPhone: "555-0100", + }; + expect(canContinueWizardStep2(draft, { requireAccount: false })).toBe(true); + expect(isWizardStepValid(2, draft, { requireAccount: false })).toBe(true); + expect(wizardStep2ContinueHint(draft, { requireAccount: false })).toBeNull(); + }); + it("blocks step 2 without customer", () => { const draft = { ...EMPTY_WIZARD_DRAFT, diff --git a/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts index 6d2204df..250b4b1d 100644 --- a/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts +++ b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts @@ -45,6 +45,21 @@ describe("getInlineCreateMissing", () => { ).toEqual(["pocName", "pocPhone"]); }); + it("skips account when the caller does not require it", () => { + expect( + getInlineCreateMissing( + { + site: "BK5", + locationId: "12", + woNumber: "25001", + pocName: "Jane", + pocPhone: "555-0100", + }, + { requireAccount: false }, + ), + ).toEqual([]); + }); + it("lists account, site, and woNumber when empty", () => { expect(getInlineCreateMissing({})).toEqual([ "customer", diff --git a/src/test/lib/auth/org-scope.test.ts b/src/test/lib/auth/org-scope.test.ts new file mode 100644 index 00000000..0782fd13 --- /dev/null +++ b/src/test/lib/auth/org-scope.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { AuthUser } from "@/domain/auth/types/auth-user"; +import { isOrgWideScope } from "@/lib/auth/org-scope"; + +function encodeJwt(payload: Record): string { + const json = JSON.stringify(payload); + const b64 = btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + return `eyJhbGciOiJub25lIn0.${b64}.sig`; +} + +function user(token: string, orgScope?: string): AuthUser { + return { + token, + expiration: new Date(Date.now() + 60_000).toISOString(), + email: "user@seahavenind.com", + userRoles: "User", + phoneNumber: "", + fullname: "Test User", + id: 1, + orgScope, + }; +} + +describe("isOrgWideScope", () => { + it("is false without a session", () => { + expect(isOrgWideScope(null)).toBe(false); + expect(isOrgWideScope(undefined)).toBe(false); + }); + + it("is true only when the JWT org_scope is all", () => { + expect(isOrgWideScope(user(encodeJwt({ org_scope: "all" })))).toBe(true); + expect(isOrgWideScope(user(encodeJwt({ org_scope: "ALL" })))).toBe(true); + }); + + it("is false for a claim-scoped account", () => { + expect(isOrgWideScope(user(encodeJwt({ org_scope: "account", account_id: 7 })))).toBe(false); + }); + + it("uses the login orgScope when the token is not a JWT", () => { + expect(isOrgWideScope(user("session-token", "all"))).toBe(true); + expect(isOrgWideScope(user("session-token", "account"))).toBe(false); + }); + + it("prefers a scoped JWT over a stale org-wide login field", () => { + expect(isOrgWideScope(user(encodeJwt({ org_scope: "account" }), "all"))).toBe(false); + }); + + it("is false when neither the JWT nor the user declares all", () => { + expect(isOrgWideScope(user("wo-visual-token"))).toBe(false); + }); +});