feat: sales opportunity detail, procurement filters, permission resilience
- Add sales opportunity detail page with tabs (overview, notes, contacts, products, forecasts, activity) - Add sales note CRUD endpoints (create, update, delete) with server routes - Add opportunity types, contacts, product sequencing, and refresh API methods - Add AddProductModal component for catalog browsing - Update procurement.fetchMany to accept CatalogItemFilters object - Add procurement.fetchCategories and procurement.fetchFilters endpoints - Add resilient permission check (no-token returns all-true with __checkFailed) - Parallelize company detail data fetches for performance - Remove stale console.log statements across modules - Add comprehensive unit tests for all new API methods and permission edge cases
This commit is contained in:
@@ -174,7 +174,12 @@ describe("optima api modules", () => {
|
||||
.mockResolvedValueOnce({ data: { data: [] } })
|
||||
.mockResolvedValueOnce({ data: { data: { count: 4 } } });
|
||||
|
||||
await procurement.fetchMany("token", 3, "switch", 10, true);
|
||||
await procurement.fetchMany(
|
||||
"token",
|
||||
3,
|
||||
{ search: "switch", includeInactive: true },
|
||||
10,
|
||||
);
|
||||
const count = await procurement.count("token", true);
|
||||
|
||||
expect(mockApi.get).toHaveBeenNthCalledWith(1, "/v1/procurement/items", {
|
||||
@@ -208,6 +213,106 @@ describe("optima api modules", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("procurement.fetchMany passes all CatalogItemFilters as params", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||
|
||||
await procurement.fetchMany("token", 1, {
|
||||
search: "cable",
|
||||
category: "Networking",
|
||||
subcategory: "Ethernet",
|
||||
group: "Cat6",
|
||||
manufacturer: "Ubiquiti",
|
||||
ecosystem: "unifi",
|
||||
inStock: true,
|
||||
minPrice: 10,
|
||||
maxPrice: 500,
|
||||
includeInactive: true,
|
||||
});
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/items", {
|
||||
params: {
|
||||
page: 1,
|
||||
rpp: 30,
|
||||
search: "cable",
|
||||
category: "Networking",
|
||||
subcategory: "Ethernet",
|
||||
group: "Cat6",
|
||||
manufacturer: "Ubiquiti",
|
||||
ecosystem: "unifi",
|
||||
inStock: true,
|
||||
minPrice: 10,
|
||||
maxPrice: 500,
|
||||
includeInactive: true,
|
||||
},
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("procurement.fetchMany omits filters that are not set", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||
|
||||
await procurement.fetchMany("token", 2, { category: "Audio" }, 15);
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/items", {
|
||||
params: { page: 2, rpp: 15, category: "Audio" },
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("procurement.fetchCategories returns category tree", async () => {
|
||||
const tree = { categories: [{ name: "Net" }], ecosystems: [] };
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: tree } });
|
||||
|
||||
const result = await procurement.fetchCategories("token");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/categories", {
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
expect(result).toEqual(tree);
|
||||
});
|
||||
|
||||
it("procurement.fetchFilters passes optional params", async () => {
|
||||
const filterValues = {
|
||||
categories: ["A"],
|
||||
subcategories: ["B"],
|
||||
manufacturers: ["C"],
|
||||
};
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: filterValues } });
|
||||
|
||||
const result = await procurement.fetchFilters("token", {
|
||||
category: "Networking",
|
||||
subcategory: "Switches",
|
||||
includeInactive: true,
|
||||
});
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/filters", {
|
||||
params: {
|
||||
category: "Networking",
|
||||
subcategory: "Switches",
|
||||
includeInactive: "true",
|
||||
},
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
expect(result).toEqual(filterValues);
|
||||
});
|
||||
|
||||
it("procurement.fetchFilters works with no options", async () => {
|
||||
const filterValues = {
|
||||
categories: [],
|
||||
subcategories: [],
|
||||
manufacturers: [],
|
||||
};
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: filterValues } });
|
||||
|
||||
const result = await procurement.fetchFilters("token");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/filters", {
|
||||
params: {},
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
expect(result).toEqual(filterValues);
|
||||
});
|
||||
|
||||
it("role add and remove permissions include payload", async () => {
|
||||
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
|
||||
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
||||
@@ -237,6 +342,144 @@ describe("optima api modules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sales.fetchOpportunityTypes calls expected endpoint", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||
|
||||
await sales.fetchOpportunityTypes("token");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunity-types", {
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sales.fetchOne encodes identifier in URL", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "opp-1" } } });
|
||||
|
||||
await sales.fetchOne("token", "opp-1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1",
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.fetchForecasts calls forecasts endpoint", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||
|
||||
await sales.fetchForecasts("token", "opp-1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1/forecasts",
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.fetchProducts calls products endpoint", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||
|
||||
await sales.fetchProducts("token", "opp-1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1/products",
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.fetchNotes calls notes endpoint", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||
|
||||
await sales.fetchNotes("token", "opp-1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1/notes",
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.createNote posts note payload", async () => {
|
||||
mockApi.post.mockResolvedValueOnce({ data: { id: 1, text: "Hello" } });
|
||||
|
||||
await sales.createNote("token", "opp-1", {
|
||||
text: "Hello",
|
||||
flagged: true,
|
||||
});
|
||||
|
||||
expect(mockApi.post).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1/notes",
|
||||
{ text: "Hello", flagged: true },
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.updateNote patches with noteId in URL", async () => {
|
||||
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
|
||||
|
||||
await sales.updateNote("token", "opp-1", 42, { text: "Updated" });
|
||||
|
||||
expect(mockApi.patch).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1/notes/42",
|
||||
{ text: "Updated" },
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.deleteNote calls delete with noteId in URL", async () => {
|
||||
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
||||
|
||||
await sales.deleteNote("token", "opp-1", 42);
|
||||
|
||||
expect(mockApi.delete).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1/notes/42",
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.fetchContacts calls contacts endpoint", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||
|
||||
await sales.fetchContacts("token", "opp-1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1/contacts",
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.sequenceProducts patches with ordered IDs", async () => {
|
||||
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
|
||||
|
||||
await sales.sequenceProducts("token", "opp-1", [3, 1, 2]);
|
||||
|
||||
expect(mockApi.patch).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1/products/sequence",
|
||||
{ orderedIds: [3, 1, 2] },
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.refreshOpportunity posts to refresh endpoint", async () => {
|
||||
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
|
||||
|
||||
await sales.refreshOpportunity("token", "opp-1");
|
||||
|
||||
expect(mockApi.post).toHaveBeenCalledWith(
|
||||
"/v1/sales/opportunities/opp-1/refresh",
|
||||
{},
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("sales.fetchOne encodes special characters in identifier", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { data: {} } });
|
||||
|
||||
await sales.fetchOne("token", "opp/special#1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
`/v1/sales/opportunities/${encodeURIComponent("opp/special#1")}`,
|
||||
{ headers: { Authorization: "Bearer token" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("users module uses expected endpoints", async () => {
|
||||
mockApi.get.mockResolvedValue({ data: { data: [] } });
|
||||
mockApi.post.mockResolvedValueOnce({ data: { data: { results: [] } } });
|
||||
|
||||
@@ -58,8 +58,6 @@ export const credential = {
|
||||
accessToken: string,
|
||||
data: Omit<Credential, "id" | "createdAt" | "updatedAt">,
|
||||
) {
|
||||
console.log(data);
|
||||
|
||||
const response = await api.post("/v1/credential/credentials", data, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
|
||||
@@ -1,16 +1,86 @@
|
||||
import api from "../axios";
|
||||
|
||||
export interface CatalogItemFilters {
|
||||
search?: string;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
group?: string;
|
||||
manufacturer?: string;
|
||||
ecosystem?: string;
|
||||
inStock?: boolean;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
includeInactive?: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryTreeEntry {
|
||||
name: string;
|
||||
type?: string;
|
||||
cwId?: number;
|
||||
subcategories?: CategoryTreeEntry[];
|
||||
entries?: CategoryTreeEntry[];
|
||||
}
|
||||
|
||||
export interface EcosystemManufacturer {
|
||||
name: string;
|
||||
cwId?: number;
|
||||
category?: string;
|
||||
subcategoryPrefix?: string;
|
||||
}
|
||||
|
||||
export interface EcosystemEntry {
|
||||
name: string;
|
||||
manufacturers: EcosystemManufacturer[];
|
||||
}
|
||||
|
||||
export interface CategoryTreeResponse {
|
||||
categories: CategoryTreeEntry[];
|
||||
ecosystems: EcosystemEntry[];
|
||||
}
|
||||
|
||||
export interface FilterValues {
|
||||
categories: string[];
|
||||
subcategories: string[];
|
||||
manufacturers: string[];
|
||||
}
|
||||
|
||||
export interface CatalogItem {
|
||||
id: string;
|
||||
identifier?: string;
|
||||
cwCatalogId?: number;
|
||||
description?: string;
|
||||
partNumber?: string;
|
||||
vendorSku?: string;
|
||||
manufacturer?: string;
|
||||
price?: number;
|
||||
cost?: number;
|
||||
unitOfMeasure?: string;
|
||||
onHand?: number;
|
||||
inactive?: boolean;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const procurement = {
|
||||
async fetchMany(
|
||||
accessToken: string,
|
||||
page: number = 1,
|
||||
search?: string,
|
||||
filters: CatalogItemFilters = {},
|
||||
rpp: number = 30,
|
||||
includeInactive: boolean = false,
|
||||
) {
|
||||
const params: Record<string, unknown> = { page, rpp };
|
||||
if (search && search.length > 0) params.search = search;
|
||||
if (includeInactive) params.includeInactive = true;
|
||||
if (filters.search && filters.search.length > 0)
|
||||
params.search = filters.search;
|
||||
if (filters.includeInactive) params.includeInactive = true;
|
||||
if (filters.category) params.category = filters.category;
|
||||
if (filters.subcategory) params.subcategory = filters.subcategory;
|
||||
if (filters.group) params.group = filters.group;
|
||||
if (filters.manufacturer) params.manufacturer = filters.manufacturer;
|
||||
if (filters.ecosystem) params.ecosystem = filters.ecosystem;
|
||||
if (filters.inStock) params.inStock = true;
|
||||
if (filters.minPrice != null) params.minPrice = filters.minPrice;
|
||||
if (filters.maxPrice != null) params.maxPrice = filters.maxPrice;
|
||||
|
||||
const response = await api.get("/v1/procurement/items", {
|
||||
params,
|
||||
@@ -101,4 +171,35 @@ export const procurement = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchCategories(accessToken: string): Promise<CategoryTreeResponse> {
|
||||
const response = await api.get("/v1/procurement/categories", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async fetchFilters(
|
||||
accessToken: string,
|
||||
options?: {
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
includeInactive?: boolean;
|
||||
},
|
||||
): Promise<FilterValues> {
|
||||
const params: Record<string, string> = {};
|
||||
if (options?.category) params.category = options.category;
|
||||
if (options?.subcategory) params.subcategory = options.subcategory;
|
||||
if (options?.includeInactive) params.includeInactive = "true";
|
||||
|
||||
const response = await api.get("/v1/procurement/filters", {
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,11 +22,54 @@ export interface SalesOpportunity {
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
} | null;
|
||||
company?: { id?: number | string; name?: string } | null;
|
||||
company?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
cw_Identifier?: string;
|
||||
cw_CompanyId?: number;
|
||||
cw_Data?: {
|
||||
address?: {
|
||||
line1?: string;
|
||||
line2?: string | null;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
country?: string;
|
||||
};
|
||||
allContacts?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
cwId?: number;
|
||||
inactive?: boolean;
|
||||
title?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
}[];
|
||||
};
|
||||
} | null;
|
||||
contact?: { id?: number | string; name?: string } | null;
|
||||
site?: { id?: number | string; name?: string } | null;
|
||||
site?: {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
address?: {
|
||||
line1?: string;
|
||||
line2?: string | null;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
country?: string;
|
||||
};
|
||||
phoneNumber?: string | null;
|
||||
faxNumber?: string | null;
|
||||
primaryAddressFlag?: boolean;
|
||||
defaultShippingFlag?: boolean;
|
||||
defaultBillingFlag?: boolean;
|
||||
defaultMailingFlag?: boolean;
|
||||
} | null;
|
||||
customerPO?: string | null;
|
||||
totalSalesTax?: number | null;
|
||||
location?: { id?: number; name?: string } | null;
|
||||
department?: { id?: number; name?: string } | null;
|
||||
expectedCloseDate?: string | null;
|
||||
pipelineChangeDate?: string | null;
|
||||
dateBecameLead?: string | null;
|
||||
@@ -39,6 +82,21 @@ export interface SalesOpportunity {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface OpportunityType {
|
||||
id: number;
|
||||
name: string;
|
||||
wonFlag?: boolean;
|
||||
lostFlag?: boolean;
|
||||
closedFlag?: boolean;
|
||||
inactiveFlag?: boolean;
|
||||
defaultFlag?: boolean;
|
||||
enteredBy?: string;
|
||||
dateEntered?: string;
|
||||
_info?: { lastUpdated?: string; updatedBy?: string };
|
||||
connectWiseId?: string;
|
||||
optimaEquivalency?: number[];
|
||||
}
|
||||
|
||||
export const sales = {
|
||||
async fetchMany(
|
||||
accessToken: string,
|
||||
@@ -59,4 +117,150 @@ export const sales = {
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchOpportunityTypes(accessToken: string) {
|
||||
const response = await api.get("/v1/sales/opportunity-types", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchOne(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchForecasts(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/forecasts`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchProducts(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/products`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchNotes(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/notes`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createNote(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
data: { text: string; flagged?: boolean },
|
||||
) {
|
||||
const response = await api.post(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/notes`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateNote(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
noteId: number,
|
||||
data: { text?: string; flagged?: boolean },
|
||||
) {
|
||||
const response = await api.patch(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/notes/${noteId}`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteNote(accessToken: string, identifier: string, noteId: number) {
|
||||
const response = await api.delete(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/notes/${noteId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fetchContacts(accessToken: string, identifier: string) {
|
||||
const response = await api.get(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/contacts`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async sequenceProducts(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
orderedIds: number[],
|
||||
) {
|
||||
const response = await api.patch(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/products/sequence`,
|
||||
{ orderedIds },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async refreshOpportunity(accessToken: string, identifier: string) {
|
||||
const response = await api.post(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/refresh`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@ export const user = {
|
||||
)
|
||||
).data.data;
|
||||
|
||||
console.log("Refreshed tokens:", refreshedTokens);
|
||||
return refreshedTokens;
|
||||
},
|
||||
|
||||
|
||||
@@ -61,6 +61,26 @@ describe("permissions helpers", () => {
|
||||
expect(result.__checkFailed).toBe(true);
|
||||
});
|
||||
|
||||
it("returns all-true with __checkFailed when accessToken is empty", async () => {
|
||||
const result = await checkPermissions("", ["x", "y"]);
|
||||
|
||||
expect(result.x).toBe(true);
|
||||
expect(result.y).toBe(true);
|
||||
expect(result.__checkFailed).toBe(true);
|
||||
expect(mockCheckPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns all-true with __checkFailed when accessToken is falsy", async () => {
|
||||
const result = await checkPermissions(
|
||||
undefined as unknown as string,
|
||||
["perm.a"],
|
||||
);
|
||||
|
||||
expect(result["perm.a"]).toBe(true);
|
||||
expect(result.__checkFailed).toBe(true);
|
||||
expect(mockCheckPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hasPermission returns true only for explicit true values", () => {
|
||||
expect(hasPermission({ "company.read": true }, "company.read")).toBe(true);
|
||||
expect(hasPermission({ "company.read": false }, "company.read")).toBe(
|
||||
|
||||
+20
-5
@@ -25,18 +25,33 @@ export async function checkPermissions(
|
||||
): Promise<PermissionMap> {
|
||||
if (!permissions.length) return {};
|
||||
|
||||
if (!accessToken) {
|
||||
// Return all-true so UI doesn't hide features
|
||||
const map = permissions.reduce<PermissionMap>((m, p) => {
|
||||
m[p] = true;
|
||||
return m;
|
||||
}, {} as PermissionMap);
|
||||
map.__checkFailed = true;
|
||||
return map;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await optima.user.checkPermissions(accessToken, permissions);
|
||||
|
||||
const results: Array<{ permission: string; hasPermission: boolean }> =
|
||||
result?.data?.results ?? [];
|
||||
|
||||
return results.reduce<PermissionMap>((map, entry) => {
|
||||
map[entry.permission] = entry.hasPermission === true;
|
||||
return map;
|
||||
const map = results.reduce<PermissionMap>((m, entry) => {
|
||||
m[entry.permission] = entry.hasPermission === true;
|
||||
return m;
|
||||
}, {});
|
||||
} catch (err) {
|
||||
console.error("Permission check failed:", err);
|
||||
|
||||
return map;
|
||||
} catch (err: unknown) {
|
||||
console.error(
|
||||
"Permission check failed:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
// Default every requested permission to true on failure so the UI
|
||||
// doesn't hide features that the user may actually be allowed to use.
|
||||
// The API will still enforce access if the user truly lacks permission.
|
||||
|
||||
Reference in New Issue
Block a user