fix(work-orders): gate account picker to org-wide scope

Hide GetAccountList and skip client customer on create unless
org_scope is all, so scoped users cannot read another tenant name.
This commit is contained in:
Arthur Bassi 2026-08-25 21:10:12 -03:00
parent 800671030f
commit b240cc8c79
19 changed files with 313 additions and 49 deletions

View file

@ -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({
</Text>
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
{createMode && (
{Boolean(createMode && requireAccount) && (
<WizardStepLocationAccountSelect
customer={s.accountName}
showError={s.showErrors && s.accountMissing}

View file

@ -4,6 +4,7 @@ type UseSiteDialogAccountFieldArgs = {
open: boolean;
customer: string;
createMode: boolean;
requireAccount?: boolean;
dirty: boolean;
onDirty: () => 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);

View file

@ -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,
});

View file

@ -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);

View file

@ -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 (
<div

View file

@ -1,10 +1,12 @@
import { useState } from "react";
import { useContext, useState } from "react";
import { Building2, Info } from "lucide-react";
import { Text } from "@/components/ui/text";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { useDebounce } from "@/hooks/use-debounce";
import { isOrgWideScope } from "@/lib/auth/org-scope";
import { AuthContext } from "@/providers/auth-context";
type WizardStepLocationAccountSelectProps = {
customer: string;
@ -22,12 +24,17 @@ export function WizardStepLocationAccountSelect({
onCustomerChange,
showError = false,
}: WizardStepLocationAccountSelectProps) {
const orgWide = isOrgWideScope(useContext(AuthContext)?.user);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const { data } = useAccountsList({ page: 1, pageSize: 50, search: debouncedSearch });
const { data } = useAccountsList({ page: 1, pageSize: 50, search: debouncedSearch }, orgWide);
const names = (data?.items ?? []).map((account) => account.name.trim()).filter(Boolean);
const selected = customer.trim();
if (!orgWide) {
return null;
}
return (
<div>
<WizLabel required>Account</WizLabel>

View file

@ -5,10 +5,12 @@ import { queryKeys } from "@/infra/query-key/query-key";
export function useAccountsList(
params: AccountsListParams,
enabled = true,
): UseQueryResult<AccountsListResult, Error> {
return useQuery({
queryKey: queryKeys.accounts.list(params as Record<string, unknown>),
queryFn: () => accountsApi.getList(params),
placeholderData: keepPreviousData,
enabled,
});
}

View file

@ -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 {

View file

@ -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;
}

View file

@ -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());

45
src/lib/auth/org-scope.ts Normal file
View file

@ -0,0 +1,45 @@
import type { AuthUser } from "@/domain/auth/types/auth-user";
const ORG_SCOPE_ALL = "all";
function readJwtClaims(token: string): Record<string, unknown> | 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<string, unknown>;
} catch {
return null;
}
}
function readStringClaim(source: Record<string, unknown> | 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;
}

View file

@ -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, unknown>): 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(
<AuthContext.Provider value={authValue(user)}>
<WizardStepLocationAccountSelect customer="" onCustomerChange={vi.fn()} />
</AuthContext.Provider>,
{ 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(
<WizardStepLocationAccountSelect customer="" onCustomerChange={onCustomerChange} />,
<AuthContext.Provider value={authValue(authUser(encodeJwt({ org_scope: "all" })))}>
<WizardStepLocationAccountSelect customer="" onCustomerChange={onCustomerChange} />
</AuthContext.Provider>,
{ 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(
<WizardStepLocationAccountSelect customer="" onCustomerChange={vi.fn()} />,
{ 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();
});
});

View file

@ -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<typeof import("@/providers/auth-context")>();
return {
...actual,
useAuthContext: () => ({ user: { id: "u1" } }),
};
});
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({

View file

@ -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<typeof import("@/providers/auth-context")>();
return {
...actual,
useAuthContext: () => ({ user: { id: "u1" } }),
};
});
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
useWorkOrdersListFilters: () => ({

View file

@ -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<typeof import("@/providers/auth-context")>();
return {
...actual,
useAuthContext: () => ({ user: { id: "u1" } }),
};
});
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
useWorkOrdersListFilters: () => ({

View file

@ -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<typeof import("@/providers/auth-context")>();
return {
...actual,
useAuthContext: () => ({ user: { id: "u1" } }),
};
});
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
useWorkOrdersListFilters: () => ({

View file

@ -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,

View file

@ -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",

View file

@ -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, unknown>): 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);
});
});