fix: remove nested .git folders, re-add as normal directories

This commit is contained in:
2026-03-22 17:50:47 -05:00
parent f55c7e47c9
commit 6b7eec67b8
1870 changed files with 4170168 additions and 3 deletions
+78
View File
@@ -0,0 +1,78 @@
import { PUBLIC_API_URL } from "$env/static/public";
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import axios, { AxiosInstance, type InternalAxiosRequestConfig } from "axios";
const api: AxiosInstance = axios.create({
baseURL: PUBLIC_API_URL || "",
});
// ── Client-side 401 refresh interceptor ──────────────────────────────────────
// Only runs in the browser. On a 401 from the external API, we ask our own
// SvelteKit server for a fresh token (the server hook handles the actual
// refresh), then retry the original request once. Multiple concurrent 401s
// are queued so only one refresh call is made.
if (browser) {
let isRefreshing = false;
let queue: Array<(token: string) => void> = [];
function drainQueue(token: string) {
queue.forEach((cb) => cb(token));
queue = [];
}
async function fetchFreshToken(): Promise<string> {
const res = await fetch("/api/auth/refresh");
if (!res.ok) {
// Truly unauthenticated — send to login
goto("/login");
throw new Error("Session expired");
}
const data = await res.json();
return data.accessToken as string;
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const original: InternalAxiosRequestConfig & { _retry?: boolean } =
error.config;
const isExpired =
error.response?.data?.error === "ExpiredAccessTokenError";
if (!isExpired || original._retry) {
return Promise.reject(error);
}
// Mark so we don't retry more than once
original._retry = true;
if (isRefreshing) {
// Another request is already refreshing — wait for it
return new Promise((resolve, reject) => {
queue.push((token) => {
original.headers["Authorization"] = `Bearer ${token}`;
resolve(api(original));
});
});
}
isRefreshing = true;
try {
const token = await fetchFreshToken();
original.headers["Authorization"] = `Bearer ${token}`;
drainQueue(token);
return api(original);
} catch (refreshError) {
queue = [];
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
}
export { api };
export default api;
+154
View File
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockError, mockRedirect } = vi.hoisted(() => ({
mockError: vi.fn(),
mockRedirect: vi.fn(),
}));
vi.mock("@sveltejs/kit", () => ({
error: mockError,
redirect: mockRedirect,
}));
import {
ApiError,
handleApiError,
isInvalidSignatureError,
isNetworkError,
isUnauthorizedError,
isForbiddenError,
isNotFoundError,
} from "./errorHandler";
describe("errorHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
// ── ApiError ──────────────────────────────────────────────────────────
it("ApiError stores statusCode, message, and details", () => {
const err = new ApiError(422, "Validation failed", { field: "name" });
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("ApiError");
expect(err.statusCode).toBe(422);
expect(err.message).toBe("Validation failed");
expect(err.details).toEqual({ field: "name" });
});
// ── isInvalidSignatureError ───────────────────────────────────────────
it("detects 'invalid signature' in response data message", () => {
const err = {
response: { data: { message: "Token has invalid signature" } },
};
expect(isInvalidSignatureError(err)).toBe(true);
});
it("detects 'jwt malformed' in response data error field", () => {
const err = {
response: { data: { error: "jwt malformed" } },
};
expect(isInvalidSignatureError(err)).toBe(true);
});
it("detects 'invalid token' in Error message", () => {
const err = new Error("Invalid token received");
expect(isInvalidSignatureError(err)).toBe(true);
});
it("returns false for unrelated errors", () => {
expect(isInvalidSignatureError(new Error("network down"))).toBe(false);
expect(isInvalidSignatureError(null)).toBe(false);
expect(isInvalidSignatureError("string error")).toBe(false);
});
// ── handleApiError ────────────────────────────────────────────────────
it("redirects to /logout on invalid signature error", () => {
mockRedirect.mockImplementation(() => {
throw new Error("REDIRECT");
});
const err = {
response: { data: { message: "invalid signature" } },
};
expect(() => handleApiError(err)).toThrow("REDIRECT");
expect(mockRedirect).toHaveBeenCalledWith(303, "/logout");
});
it("throws SvelteKit error for ApiError", () => {
mockError.mockImplementation(() => {
throw new Error("HTTP_ERROR");
});
const err = new ApiError(404, "Not found", "extra");
expect(() => handleApiError(err)).toThrow("HTTP_ERROR");
expect(mockError).toHaveBeenCalledWith(404, {
message: "Not found",
details: "extra",
});
});
it("throws 500 error for generic Error", () => {
mockError.mockImplementation(() => {
throw new Error("HTTP_ERROR");
});
const err = new Error("Something broke");
expect(() => handleApiError(err)).toThrow("HTTP_ERROR");
expect(mockError).toHaveBeenCalledWith(500, {
message: "Something broke",
details: expect.any(String),
});
});
it("throws 500 error for non-Error values", () => {
mockError.mockImplementation(() => {
throw new Error("HTTP_ERROR");
});
expect(() => handleApiError("string error")).toThrow("HTTP_ERROR");
expect(mockError).toHaveBeenCalledWith(500, {
message: "An unexpected error occurred",
details: "string error",
});
});
// ── isNetworkError ────────────────────────────────────────────────────
it("identifies Network errors", () => {
expect(isNetworkError(new Error("Network request failed"))).toBe(true);
expect(isNetworkError(new Error("fetch failed"))).toBe(true);
expect(isNetworkError(new Error("ECONNREFUSED"))).toBe(true);
});
it("returns false for non-network errors", () => {
expect(isNetworkError(new Error("validation error"))).toBe(false);
expect(isNetworkError("not an error")).toBe(false);
});
// ── status code helpers ───────────────────────────────────────────────
it("isUnauthorizedError returns true for 401 ApiError", () => {
expect(isUnauthorizedError(new ApiError(401, "Unauthorized"))).toBe(true);
expect(isUnauthorizedError(new ApiError(403, "Forbidden"))).toBe(false);
expect(isUnauthorizedError(new Error("nope"))).toBe(false);
});
it("isForbiddenError returns true for 403 ApiError", () => {
expect(isForbiddenError(new ApiError(403, "Forbidden"))).toBe(true);
expect(isForbiddenError(new ApiError(401, "Unauthorized"))).toBe(false);
});
it("isNotFoundError returns true for 404 ApiError", () => {
expect(isNotFoundError(new ApiError(404, "Not Found"))).toBe(true);
expect(isNotFoundError(new ApiError(500, "Server Error"))).toBe(false);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { error, redirect } from "@sveltejs/kit";
export class ApiError extends Error {
constructor(
public statusCode: number,
public message: string,
public details?: unknown,
) {
super(message);
this.name = "ApiError";
}
}
/**
* Detects "invalid signature" or malformed-token errors from the API,
* which indicate the access or refresh token has been tampered with or
* the server signing key has changed.
*/
export function isInvalidSignatureError(err: unknown): boolean {
if (err && typeof err === "object") {
const axiosErr = err as Record<string, unknown>;
const responseData = (axiosErr?.response as Record<string, unknown>)
?.data as Record<string, unknown> | undefined;
const candidates = [
responseData?.message,
responseData?.error,
(err as Error)?.message,
];
return candidates.some((val) => {
if (typeof val !== "string") return false;
const lower = val.toLowerCase();
return (
lower.includes("invalid signature") ||
lower.includes("jwt malformed") ||
lower.includes("invalid token")
);
});
}
return false;
}
export function handleApiError(err: unknown): never {
console.error("API Error:", err);
// Treat invalid-signature errors as a forced logout
if (isInvalidSignatureError(err)) {
console.warn("Invalid token signature detected — forcing logout.");
throw redirect(303, "/logout");
}
if (err instanceof ApiError) {
throw error(err.statusCode, {
message: err.message,
details: err.details,
});
}
if (err instanceof Error) {
const message = err.message || "An unexpected error occurred";
throw error(500, {
message,
details: err.stack,
});
}
throw error(500, {
message: "An unexpected error occurred",
details: String(err),
});
}
export function isNetworkError(err: unknown): boolean {
if (err instanceof Error) {
return (
err.message.includes("Network") ||
err.message.includes("fetch") ||
err.message.includes("ECONNREFUSED")
);
}
return false;
}
export function isUnauthorizedError(err: unknown): boolean {
return err instanceof ApiError && err.statusCode === 401;
}
export function isForbiddenError(err: unknown): boolean {
return err instanceof ApiError && err.statusCode === 403;
}
export function isNotFoundError(err: unknown): boolean {
return err instanceof ApiError && err.statusCode === 404;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockGet, mockCreate } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockCreate: vi.fn(),
}));
vi.mock("axios", () => ({
default: { create: mockCreate },
create: mockCreate,
}));
import { auth } from "./auth";
describe("auth module", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreate.mockReturnValue({ get: mockGet });
});
it("fetches auth redirect uri and callback key", async () => {
mockGet.mockResolvedValueOnce({
data: {
data: {
uri: "https://auth.example.com",
callbackKey: "cb-123",
},
},
});
const result = await auth.fetchAuthRedirectUri("https://api.example.com");
expect(mockCreate).toHaveBeenCalledWith({
baseURL: "https://api.example.com",
timeout: 5000,
});
expect(mockGet).toHaveBeenCalledWith("/v1/auth/uri");
expect(result).toEqual({
uri: "https://auth.example.com",
callbackKey: "cb-123",
});
});
it("throws wrapped error when response is missing uri", async () => {
mockGet.mockResolvedValueOnce({ data: { data: {} } });
await expect(
auth.fetchAuthRedirectUri("https://api.example.com"),
).rejects.toThrow(
"Failed to fetch auth redirect uri: redirect uri missing from response",
);
});
it("throws wrapped axios error message", async () => {
mockGet.mockRejectedValueOnce(new Error("network down"));
await expect(
auth.fetchAuthRedirectUri("https://api.example.com"),
).rejects.toThrow("Failed to fetch auth redirect uri: network down");
});
});
+29
View File
@@ -0,0 +1,29 @@
import axios, { AxiosInstance } from "axios";
export const auth = {
async fetchAuthRedirectUri(api_url: string): Promise<{
uri: string;
callbackKey: string;
}> {
const client: AxiosInstance = axios.create({
baseURL: api_url || "",
timeout: 5000,
});
try {
const res = await client.get("/v1/auth/uri");
const d = res.data ?? {};
const uri = d.data.uri;
const callbackKey = d.data.callbackKey;
if (typeof uri !== "string" || !uri)
throw new Error("redirect uri missing from response");
return {
uri,
callbackKey,
};
} catch (e) {
throw new Error(
`Failed to fetch auth redirect uri: ${(e as Error).message}`,
);
}
},
};
@@ -0,0 +1,71 @@
import api from "../axios";
export const company = {
async fetch(
accessToken: string,
id: string,
options?: {
includeAddress?: boolean;
includePrimaryContact?: boolean;
includeAllContacts?: boolean;
},
) {
const params: Record<string, string> = {};
if (options?.includeAddress) params.includeAddress = "true";
if (options?.includePrimaryContact) params.includePrimaryContact = "true";
if (options?.includeAllContacts) params.includeAllContacts = "true";
const company = await api.get(`/v1/company/companies/${id}`, {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return company.data;
},
async fetchMany(
accessToken: string,
page: number = 1,
search?: string,
rpp: number = 30,
) {
const params: Record<string, unknown> = { page, rpp };
if (search && search.length > 0) params.search = search;
const companies = await api.get("/v1/company/companies", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return companies.data;
},
async count(accessToken: string) {
const response = await api.get("/v1/company/count", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data.data.count;
},
async fetchConfigurations(accessToken: string, id: string) {
const configurations = await api.get(
`/v1/company/companies/${id}/configurations`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return configurations.data;
},
async fetchSites(accessToken: string, id: string) {
const response = await api.get(`/v1/company/companies/${id}/sites`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
};
@@ -0,0 +1,89 @@
import api from "../axios";
export interface CredentialTypeField {
id: string;
name: string;
required: boolean;
secure: boolean;
valueType: string;
subFields?: CredentialTypeField[];
}
export interface CredentialType {
id: string;
name: string;
permissionScope: string;
icon?: string;
fields: CredentialTypeField[];
credentialCount: number;
createdAt: string;
updatedAt: string;
}
export const credentialType = {
async fetchMany(accessToken: string) {
const response = await api.get("/v1/credential-type", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetch(accessToken: string, identifier: string) {
const response = await api.get(`/v1/credential-type/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async create(
accessToken: string,
credentialType: Omit<
CredentialType,
"id" | "credentialCount" | "createdAt" | "updatedAt"
>,
) {
const response = await api.post("/v1/credential-type", credentialType, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async update(
accessToken: string,
id: string,
updates: Partial<
Omit<CredentialType, "id" | "credentialCount" | "createdAt" | "updatedAt">
>,
) {
const response = await api.patch(`/v1/credential-type/${id}`, updates, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async delete(accessToken: string, id: string) {
const response = await api.delete(`/v1/credential-type/${id}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchCredentials(accessToken: string, id: string) {
const response = await api.get(`/v1/credential-type/${id}/credentials`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
};
@@ -0,0 +1,182 @@
import api from "../axios";
export interface CredentialField {
id: string;
name: string;
secure: boolean;
required: boolean;
valueType: string;
value: string;
}
export interface Credential {
id: string;
name: string;
notes?: string;
typeId: string;
companyId: string;
subCredentialOfId?: string;
fields: CredentialField[];
type?: {
id: string;
name: string;
fields: unknown[];
permissionScope: string;
};
company?: {
id: string;
name: string;
};
createdAt: string;
updatedAt: string;
}
export const credential = {
async fetchByCompany(accessToken: string, companyId: string) {
const response = await api.get(
`/v1/credential/credentials/company/${companyId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async fetch(accessToken: string, id: string) {
const response = await api.get(`/v1/credential/credentials/${id}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async create(
accessToken: string,
data: Omit<Credential, "id" | "createdAt" | "updatedAt">,
) {
const response = await api.post("/v1/credential/credentials", data, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async update(
accessToken: string,
id: string,
data: { name?: string; notes?: string },
) {
const response = await api.patch(`/v1/credential/credentials/${id}`, data, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async updateFields(
accessToken: string,
id: string,
fields: CredentialField[],
) {
const response = await api.put(
`/v1/credential/credentials/${id}/fields`,
{ fields },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async delete(accessToken: string, id: string) {
const response = await api.delete(`/v1/credential/credentials/${id}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchSecureValue(
accessToken: string,
credentialId: string,
fieldId: string,
) {
const response = await api.get(
`/v1/credential/credentials/${credentialId}/secure-values/${fieldId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async fetchValueTypes(accessToken: string) {
const response = await api.get("/v1/credential/valuetypes", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchSubCredentials(accessToken: string, credentialId: string) {
const response = await api.get(
`/v1/credential/credentials/${credentialId}/sub-credentials`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async addSubCredential(
accessToken: string,
credentialId: string,
data: {
fieldId: string;
name: string;
fields: Array<{ fieldId: string; value: string }>;
},
) {
const response = await api.post(
`/v1/credential/credentials/${credentialId}/sub-credentials`,
data,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async removeSubCredential(
accessToken: string,
credentialId: string,
subId: string,
) {
const response = await api.delete(
`/v1/credential/credentials/${credentialId}/sub-credentials/${subId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
};
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockApi } = vi.hoisted(() => ({
mockApi: {
get: vi.fn(),
},
}));
vi.mock("../axios", () => ({
default: mockApi,
api: mockApi,
}));
import { cw, type CWMember } from "./cw";
describe("cw module", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetchMembers calls /v1/cw/members with auth header", async () => {
const mockMembers: CWMember[] = [
{
id: 1,
identifier: "jdoe",
firstName: "John",
lastName: "Doe",
name: "John Doe",
officeEmail: "jdoe@example.com",
inactive: false,
},
];
mockApi.get.mockResolvedValueOnce({ data: { data: mockMembers } });
const result = await cw.fetchMembers("test-token");
expect(mockApi.get).toHaveBeenCalledWith("/v1/cw/members", {
params: {},
headers: { Authorization: "Bearer test-token" },
});
expect(result).toEqual(mockMembers);
});
it("fetchMembers passes active=false param when specified", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await cw.fetchMembers("test-token", { active: false });
expect(mockApi.get).toHaveBeenCalledWith("/v1/cw/members", {
params: { active: "false" },
headers: { Authorization: "Bearer test-token" },
});
});
it("fetchMembers does not set active param when active is true", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await cw.fetchMembers("test-token", { active: true });
expect(mockApi.get).toHaveBeenCalledWith("/v1/cw/members", {
params: {},
headers: { Authorization: "Bearer test-token" },
});
});
it("fetchMembers propagates API errors", async () => {
mockApi.get.mockRejectedValueOnce(new Error("Network error"));
await expect(cw.fetchMembers("test-token")).rejects.toThrow(
"Network error",
);
});
it("fetchMembers returns empty array when API returns empty", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
const result = await cw.fetchMembers("test-token");
expect(result).toEqual([]);
});
});
+34
View File
@@ -0,0 +1,34 @@
import api from "../axios";
export interface CWMember {
id: number;
identifier: string;
firstName: string;
lastName: string;
name: string;
officeEmail: string;
inactive: boolean;
}
export const cw = {
/**
* Fetch all ConnectWise members from the server-side member cache.
* By default only active members are returned.
*/
async fetchMembers(
accessToken: string,
options?: { active?: boolean },
): Promise<CWMember[]> {
const params: Record<string, string> = {};
if (options?.active === false) params.active = "false";
const response = await api.get("/v1/cw/members", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data.data;
},
};
@@ -0,0 +1,54 @@
import api from "../axios";
export interface PermissionNode {
node: string;
description: string;
usedIn: string[];
dependencies?: string[];
/**
* When present, lists field-level permission nodes gated by this
* permission via `processObjectValuePerms`. Each entry is a full
* permission node string (e.g. `obj.company.id`).
*/
fieldLevelPermissions?: string[];
}
export interface PermissionCategory {
name: string;
description: string;
permissions: PermissionNode[];
subCategories?: Record<string, PermissionCategory>;
}
export interface PermissionsCategorized {
[category: string]: PermissionCategory;
}
export const permission = {
async fetchCategorized(accessToken: string) {
const response = await api.get("/v1/permissions", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchFlat(accessToken: string) {
const response = await api.get("/v1/permissions/nodes", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchByCategory(accessToken: string, category: string) {
const response = await api.get(`/v1/permissions/${category}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
};
@@ -0,0 +1,231 @@
import api from "../axios";
export interface CatalogItemFilters {
search?: string;
category?: string | number;
subcategory?: string | number;
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;
quantity?: number;
partNumber?: string;
vendorSku?: string;
manufacturer?: string;
price?: number;
cost?: number;
taxableFlag?: boolean;
procurementNotes?: string;
productNarrative?: string;
customerDescription?: string;
unitOfMeasure?: string;
onHand?: number;
inactive?: boolean;
category?: string;
subcategory?: string;
[key: string]: unknown;
}
/** Map raw API fields to CatalogItem shape (e.g. salesTaxable → taxableFlag) */
function normalizeCatalogItem(raw: Record<string, unknown>): CatalogItem {
const item = { ...raw } as CatalogItem;
if (item.taxableFlag == null && raw.salesTaxable != null) {
item.taxableFlag = raw.salesTaxable as boolean;
}
return item;
}
export const procurement = {
async fetchMany(
accessToken: string,
page: number = 1,
filters: CatalogItemFilters = {},
rpp: number = 30,
) {
const params: Record<string, unknown> = { page, rpp };
if (filters.search && filters.search.length > 0)
params.search = filters.search;
if (filters.includeInactive) params.includeInactive = true;
if (filters.category != null && filters.category !== "")
params.category = filters.category;
if (filters.subcategory != null && 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,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const result = response.data;
if (Array.isArray(result?.data)) {
result.data = result.data.map(normalizeCatalogItem);
}
return result;
},
async fetch(
accessToken: string,
identifier: string,
options?: { includeLinkedItems?: boolean },
) {
const params: Record<string, string> = {};
if (options?.includeLinkedItems) params.includeLinkedItems = "true";
const response = await api.get(`/v1/procurement/items/${identifier}`, {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const result = response.data;
if (result?.data && !Array.isArray(result.data)) {
result.data = normalizeCatalogItem(result.data);
}
return result;
},
async count(accessToken: string, activeOnly: boolean = false) {
const params: Record<string, string> = {};
if (activeOnly) params.activeOnly = "true";
const response = await api.get("/v1/procurement/count", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data.data.count;
},
async refreshInventory(accessToken: string, identifier: string) {
const response = await api.post(
`/v1/procurement/items/${identifier}/refresh-inventory`,
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async fetchLinkedItems(accessToken: string, identifier: string) {
const response = await api.get(
`/v1/procurement/items/${identifier}/linked`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async linkItem(accessToken: string, identifier: string, targetId: string) {
const response = await api.post(
`/v1/procurement/items/${identifier}/link`,
{ targetId },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async unlinkItem(accessToken: string, identifier: string, targetId: string) {
const response = await api.post(
`/v1/procurement/items/${identifier}/unlink`,
{ targetId },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
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 | number;
subcategory?: string | number;
includeInactive?: boolean;
},
): Promise<FilterValues> {
const params: Record<string, string | number> = {};
if (options?.category != null && options.category !== "")
params.category = options.category;
if (options?.subcategory != null && 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;
},
};
+104
View File
@@ -0,0 +1,104 @@
import api from "../axios";
export interface Role {
id: string;
title: string;
moniker: string;
permissions: string[];
createdAt: string;
updatedAt: string;
}
export const role = {
async fetchMany(accessToken: string) {
const response = await api.get("/v1/role", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetch(accessToken: string, identifier: string) {
const response = await api.get(`/v1/role/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async create(
accessToken: string,
data: Omit<Role, "id" | "createdAt" | "updatedAt">,
) {
const response = await api.post("/v1/role", data, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async update(
accessToken: string,
identifier: string,
updates: Partial<Omit<Role, "id" | "createdAt" | "updatedAt">>,
) {
const response = await api.patch(`/v1/role/${identifier}`, updates, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async delete(accessToken: string, identifier: string) {
const response = await api.delete(`/v1/role/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async addPermissions(
accessToken: string,
identifier: string,
permissions: string[],
) {
const response = await api.post(
`/v1/role/${identifier}/permissions`,
{ permissions },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async removePermissions(
accessToken: string,
identifier: string,
permissions: string[],
) {
const response = await api.delete(`/v1/role/${identifier}/permissions`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
data: { permissions },
});
return response.data;
},
async fetchUsers(accessToken: string, identifier: string) {
const response = await api.get(`/v1/role/${identifier}/users`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
};
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
import api from "../axios";
export interface UnifiSite {
id: string;
name: string;
siteId: string;
companyId: string | null;
company?: {
id: string;
name: string;
};
createdAt: string;
updatedAt: string;
}
export interface UnifiSiteOverview {
health: Array<{
subsystem: string;
status: string;
numAdopted?: number;
numGateway?: number;
[key: string]: unknown;
}>;
sysInfo: {
timezone?: string;
hostname?: string;
version?: string;
[key: string]: unknown;
};
siteInfo: {
description?: string;
name?: string;
[key: string]: unknown;
};
}
export interface UnifiDevice {
id: string;
mac: string;
model: string;
name: string;
type: string;
state: string | number;
ip: string;
version: string;
uptime: number;
radios?: unknown[];
uplink?: unknown;
[key: string]: unknown;
}
export interface UnifiWifiNetwork {
id: string;
name?: string;
siteId?: string;
enabled?: boolean;
security?: string;
wpaMode?: string;
wpaEnc?: string;
wpa3Support?: boolean;
wpa3Transition?: boolean;
wpa3FastRoaming?: boolean;
wpa3Enhanced192?: boolean;
passphrase?: string;
passphraseAutogenerated?: boolean;
hideSSID?: boolean;
isGuest?: boolean;
band?: string;
bands?: string[];
networkconfId?: string;
usergroupId?: string;
apGroupIds?: string[];
apGroupMode?: string;
pmfMode?: string;
groupRekey?: number;
dtimMode?: string;
dtimNg?: number;
dtimNa?: number;
dtim6e?: number;
l2Isolation?: boolean;
fastRoamingEnabled?: boolean;
bssTransition?: boolean;
uapsdEnabled?: boolean;
iappEnabled?: boolean;
proxyArp?: boolean;
mcastenhanceEnabled?: boolean;
macFilterEnabled?: boolean;
macFilterPolicy?: string;
macFilterList?: string[];
radiusDasEnabled?: boolean;
radiusMacAuthEnabled?: boolean;
radiusMacaclFormat?: string;
minrateSettingPreference?: string;
minrateNgEnabled?: boolean;
minrateNgDataRateKbps?: number;
minrateNgAdvertisingRates?: boolean;
minrateNaEnabled?: boolean;
minrateNaDataRateKbps?: number;
minrateNaAdvertisingRates?: boolean;
settingPreference?: string;
no2ghzOui?: boolean;
privatePreSharedKeysEnabled?: boolean;
privatePreSharedKeys?: unknown[];
saeGroups?: unknown[];
saePsk?: unknown[];
schedule?: unknown[];
scheduleWithDuration?: unknown[];
bcFilterList?: unknown[];
externalId?: string;
[key: string]: unknown;
}
export interface UnifiNetwork {
id: string;
name: string;
purpose: string;
subnet: string;
vlanId: number | null;
dhcpEnabled: boolean;
dhcpStart: string;
dhcpStop: string;
domainName: string;
isNat: boolean;
enabled: boolean;
}
export interface UnifiWlanGroup {
id: string;
name: string;
siteId: string;
noDelete: boolean;
noEdit: boolean;
hidden: boolean;
}
export interface UnifiApGroup {
id: string;
name: string;
deviceMacs: string[];
noDelete: boolean;
}
export interface UnifiAccessPoint {
id: string;
mac: string;
model: string;
type: string;
name: string;
state: number;
adopted: boolean;
ip: string;
version: string;
}
export interface UnifiSpeedProfile {
id: string;
name: string;
siteId: string;
noDelete: boolean;
downloadLimitKbps: number;
uploadLimitKbps: number;
}
export interface UnifiPPSK {
key: string;
name: string;
mac: string | null;
vlanId: number | null;
}
export const unifi = {
/** Fetch all UniFi sites */
async fetchSites(accessToken: string) {
const response = await api.get("/v1/unifi/sites", {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Sync sites from UniFi controller */
async syncSites(accessToken: string) {
const response = await api.post(
"/v1/unifi/sites/sync",
{},
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Create a new UniFi site */
async createSite(accessToken: string, description: string) {
const response = await api.post(
"/v1/unifi/sites/create",
{ description },
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Fetch a single UniFi site */
async fetchSite(accessToken: string, id: string) {
const response = await api.get(`/v1/unifi/site/${id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Fetch UniFi sites linked to a company */
async fetchCompanySites(accessToken: string, companyId: string) {
const response = await api.get(
`/v1/company/companies/${companyId}/unifi/sites`,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Link a site to a company */
async linkSite(accessToken: string, siteId: string, companyId: string) {
const response = await api.post(
`/v1/unifi/site/${siteId}/link`,
{ companyId },
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Unlink a site from its company */
async unlinkSite(accessToken: string, siteId: string) {
const response = await api.post(
`/v1/unifi/site/${siteId}/unlink`,
{},
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Get site overview (health, sysInfo, siteInfo) */
async fetchSiteOverview(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/overview`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get site devices */
async fetchSiteDevices(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/devices`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get site WiFi networks */
async fetchSiteWifi(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/wifi`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Update a WiFi network */
async updateWifi(
accessToken: string,
siteId: string,
wlanId: string,
data: Record<string, unknown>,
) {
const response = await api.patch(
`/v1/unifi/site/${siteId}/wifi/${wlanId}`,
data,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Get site networks */
async fetchSiteNetworks(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/networks`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get WLAN groups */
async fetchWlanGroups(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/wlan-groups`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get AP groups (collections of access points for broadcasting) */
async fetchApGroups(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/ap-groups`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get access points */
async fetchAccessPoints(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/access-points`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get speed profiles (user groups) */
async fetchSpeedProfiles(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/speed-profiles`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Create a speed profile */
async createSpeedProfile(
accessToken: string,
siteId: string,
data: {
name: string;
downloadLimitKbps?: number;
uploadLimitKbps?: number;
},
) {
const response = await api.post(
`/v1/unifi/site/${siteId}/speed-profiles`,
data,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Get private PSKs for a WLAN */
async fetchPPSKs(accessToken: string, siteId: string, wlanId: string) {
const response = await api.get(
`/v1/unifi/site/${siteId}/wifi/${wlanId}/ppsk`,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Create a private PSK on a WLAN */
async createPPSK(
accessToken: string,
siteId: string,
wlanId: string,
data: { key: string; name: string; mac?: string; vlanId?: number },
) {
const response = await api.post(
`/v1/unifi/site/${siteId}/wifi/${wlanId}/ppsk`,
data,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Get WiFi limits per AP per radio */
async fetchWifiLimits(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/wifi-limits`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
};
+168
View File
@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockGetRequestEvent, mockRedirect, mockAxiosPost, mockIo, mockApi } =
vi.hoisted(() => ({
mockGetRequestEvent: vi.fn(),
mockRedirect: vi.fn(),
mockAxiosPost: vi.fn(),
mockIo: vi.fn(),
mockApi: {
get: vi.fn(),
post: vi.fn(),
},
}));
vi.mock("$app/server", () => ({
getRequestEvent: mockGetRequestEvent,
}));
vi.mock("$env/static/public", () => ({
PUBLIC_API_URL: "https://api.example.com",
}));
vi.mock("@sveltejs/kit", () => ({
redirect: mockRedirect,
}));
vi.mock("axios", () => ({
default: { post: mockAxiosPost },
post: mockAxiosPost,
}));
vi.mock("../axios", () => ({
default: mockApi,
api: mockApi,
}));
vi.mock("socket.io-client", () => ({
io: mockIo,
}));
import { user } from "./user";
describe("user module", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("isLoggedIn returns true when accessToken cookie exists", () => {
mockGetRequestEvent.mockReturnValueOnce({
cookies: {
get: vi.fn().mockReturnValue("token"),
},
});
expect(user.isLoggedIn()).toBe(true);
});
it("isLoggedIn returns false when accessToken cookie is missing", () => {
mockGetRequestEvent.mockReturnValueOnce({
cookies: {
get: vi.fn().mockReturnValue(undefined),
},
});
expect(user.isLoggedIn()).toBe(false);
});
it("refreshSession posts refresh header and returns token payload", async () => {
mockAxiosPost.mockResolvedValueOnce({
data: {
data: {
accessToken: "new-access",
refreshToken: "new-refresh",
},
},
});
const result = await user.refreshSession("refresh-123");
expect(mockAxiosPost).toHaveBeenCalledWith(
"https://api.example.com/v1/auth/refresh",
{},
{
headers: {
"x-refresh-token": "refresh-123",
},
},
);
expect(result).toEqual({
accessToken: "new-access",
refreshToken: "new-refresh",
});
});
it("fetchInfo and checkPermissions call expected endpoints", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "me" } } });
mockApi.post.mockResolvedValueOnce({ data: { data: { results: [] } } });
await user.fetchInfo("token");
await user.checkPermissions("token", ["company.read"]);
expect(mockApi.get).toHaveBeenCalledWith("/v1/user/@me", {
headers: { Authorization: "Bearer token" },
});
expect(mockApi.post).toHaveBeenCalledWith(
"/v1/user/@me/check-permission",
{ permissions: ["company.read"] },
{ headers: { Authorization: "Bearer token" } },
);
});
it("logout clears auth cookies and redirects", () => {
const deleteCookie = vi.fn();
const fakeRedirect = { status: 303, location: "/login" };
mockRedirect.mockReturnValueOnce(fakeRedirect);
const result = user.logout({
cookies: { delete: deleteCookie },
} as any);
expect(deleteCookie).toHaveBeenCalledWith("accessToken", { path: "/" });
expect(deleteCookie).toHaveBeenCalledWith("refreshToken", { path: "/" });
expect(mockRedirect).toHaveBeenCalledWith(303, "/login");
expect(result).toBe(fakeRedirect);
});
it("awaitAuthCallback resolves when socket event delivers tokens", async () => {
const handlers: Record<string, (payload?: any) => void> = {};
const disconnect = vi.fn();
mockIo.mockReturnValueOnce({
on: vi.fn((event: string, callback: (payload?: any) => void) => {
handlers[event] = callback;
}),
disconnect,
});
const promise = user.awaitAuthCallback("cb-key");
handlers["auth:login:callback:cb-key"]?.({
accessToken: "access",
refreshToken: "refresh",
});
await expect(promise).resolves.toEqual({
accessToken: "access",
refreshToken: "refresh",
});
expect(disconnect).toHaveBeenCalled();
});
it("awaitAuthCallback rejects on connect_error", async () => {
const handlers: Record<string, (payload?: any) => void> = {};
mockIo.mockReturnValueOnce({
on: vi.fn((event: string, callback: (payload?: any) => void) => {
handlers[event] = callback;
}),
disconnect: vi.fn(),
});
const promise = user.awaitAuthCallback("cb-key");
handlers.connect_error?.(new Error("socket failed"));
await expect(promise).rejects.toThrow("socket failed");
});
});
+150
View File
@@ -0,0 +1,150 @@
import { getRequestEvent } from "$app/server";
import { PUBLIC_API_URL } from "$env/static/public";
import { redirect, RequestEvent } from "@sveltejs/kit";
import axios from "axios";
import api from "../axios";
import { io } from "socket.io-client";
export const user = {
isLoggedIn(): boolean {
const event = getRequestEvent();
const authToken = event.cookies.get("accessToken");
return !!authToken;
},
async refreshSession(refreshToken: string) {
const refreshedTokens = (
await axios.post(
`${PUBLIC_API_URL}/v1/auth/refresh`,
{},
{
headers: {
"x-refresh-token": refreshToken,
},
},
)
).data.data;
return refreshedTokens;
},
async fetchInfo(accessToken: string) {
const response = await api.get("/v1/user/@me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
logout(event: RequestEvent) {
if (!event) return;
// Clear authentication cookies
event.cookies.delete("accessToken", { path: "/" });
event.cookies.delete("refreshToken", { path: "/" });
return redirect(303, "/login");
},
async checkPermissions(accessToken: string, permissions: string[]) {
const response = await api.post(
"/v1/user/@me/check-permission",
{ permissions },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
/**
* @todo Get communication with server working and setup a key system so that the frontend can listen for a specific key from the backend so that nobody can poach off of login events.
*
* Note: This function no longer mutates SvelteKit request event/cookies asynchronously.
* It returns the tokens to the caller so the caller (within the same request lifecycle)
* can set cookies using the event object synchronously.
*/
async awaitAuthCallback(callbackKey: string): Promise<{
accessToken: string;
refreshToken: string;
}> {
const base = PUBLIC_API_URL || "";
return new Promise((resolve, reject) => {
let settled = false;
const socket = io(`${base}/auth_callback`, {
transports: ["websocket"],
rejectUnauthorized: false,
});
const timeout = setTimeout(
() => {
if (settled) return;
settled = true;
try {
socket.disconnect();
} catch {}
reject(new Error("Timed out waiting for auth callback"));
},
5 * 60 * 1000,
); // 5 minutes
const handlePayload = (payload: any) => {
try {
const { accessToken, refreshToken } = payload ?? {};
if (accessToken && refreshToken) {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
socket.disconnect();
} catch {}
resolve({ accessToken, refreshToken });
}
} catch {
// ignore parse errors
}
};
socket.on("connect", () => {});
// listen for a specific callback key if provided
if (callbackKey) {
socket.on(`auth:login:callback:${callbackKey}`, handlePayload);
} else {
socket.on("auth-callback", handlePayload);
}
socket.on("message", console.log);
socket.on("connect_error", (err: any) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
socket.disconnect();
} catch {}
reject(err instanceof Error ? err : new Error("Socket connect_error"));
});
socket.on("error", (err: any) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
socket.disconnect();
} catch {}
reject(err instanceof Error ? err : new Error("Socket error"));
});
socket.on("disconnect", (reason: any) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(
new Error(
"Socket disconnected before auth was received: " + String(reason),
),
);
});
});
},
};
+126
View File
@@ -0,0 +1,126 @@
import api from "../axios";
import type { Role } from "./roles";
export interface User {
id: string;
name: string;
email: string;
login: string;
image?: string;
roles: string[];
permissions?: string[];
createdAt: string;
updatedAt: string;
}
export interface PermissionCheckResult {
permission: string;
hasPermission: boolean;
}
export const users = {
/**
* Fetch all users.
* Requires: user.read.other, user.list.other
*/
async fetchAll(accessToken: string): Promise<{ data: User[] }> {
const response = await api.get("/v1/user/users", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Fetch a specific user by their ID.
* Requires: user.read.other
*/
async fetch(
accessToken: string,
identifier: string,
): Promise<{ data: User }> {
const response = await api.get(`/v1/user/users/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Update a specific user's information.
* Requires: user.write.other
* Conditional: user.roles.other (if roles included), user.permissions.other (if permissions included)
*/
async update(
accessToken: string,
identifier: string,
updates: {
name?: string;
image?: string;
roles?: string[];
permissions?: string[];
},
): Promise<{ data: User }> {
const response = await api.patch(`/v1/user/users/${identifier}`, updates, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Delete a specific user.
* Requires: user.delete.other
*/
async delete(
accessToken: string,
identifier: string,
): Promise<{ data: User }> {
const response = await api.delete(`/v1/user/users/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Fetch all roles assigned to a specific user.
* Requires: user.read.other, role.read
*/
async fetchRoles(
accessToken: string,
identifier: string,
): Promise<{ data: Role[] }> {
const response = await api.get(`/v1/user/users/${identifier}/roles`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Check if a specific user has certain permissions.
* Requires: user.read.other
*/
async checkPermissions(
accessToken: string,
identifier: string,
permissions: string[],
): Promise<{ data: { results: PermissionCheckResult[] } }> {
const response = await api.post(
`/v1/user/users/${identifier}/check-permission`,
{ permissions },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
};