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
+34
View File
@@ -0,0 +1,34 @@
/**
* Svelte action: positions a dropdown element using fixed coordinates so it
* escapes `overflow: hidden` parent containers.
*
* Expects the dropdown's parent to contain a sibling element with class `menu-btn`.
*
* Usage:
* ```svelte
* <div class="my-dropdown" use:positionMenu>…</div>
* ```
*/
export function positionMenu(node: HTMLElement) {
const btn = node.parentElement?.querySelector(
".menu-btn",
) as HTMLElement | null;
if (!btn) return;
function update() {
const rect = btn!.getBoundingClientRect();
node.style.top = `${rect.bottom + 4}px`;
node.style.left = `${rect.right - node.offsetWidth}px`;
}
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return {
destroy() {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
},
};
}
+63
View File
@@ -0,0 +1,63 @@
import { goto } from "$app/navigation";
import { browser } from "$app/environment";
export class ClientFetchError extends Error {
constructor(
public status: number,
message: string,
public details?: unknown,
) {
super(message);
this.name = "ClientFetchError";
}
}
async function doFetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(path, options);
if (!res.ok) {
let message = `Request failed with status ${res.status}`;
let details: unknown;
try {
const body = await res.json();
message = body?.message ?? message;
details = body;
} catch {
// response body was not JSON, use default message
}
throw new ClientFetchError(res.status, message, details);
}
return res.json() as Promise<T>;
}
export async function clientFetch<T = unknown>(
path: string,
options?: RequestInit,
): Promise<T> {
try {
return await doFetch<T>(path, options);
} catch (err) {
if (
err instanceof ClientFetchError &&
(err.details as any)?.error === "ExpiredAccessTokenError" &&
browser
) {
// Attempt a token refresh via the server, then retry once
const refreshRes = await fetch("/api/auth/refresh");
if (!refreshRes.ok) {
goto("/login");
throw new ClientFetchError(
401,
"Session expired — redirecting to login",
);
}
// The refresh set new cookies; just retry the original request
return doFetch<T>(path, options);
}
throw err;
}
}
// Convenience alias
export const apiFetch = clientFetch;
+95
View File
@@ -0,0 +1,95 @@
/**
* Composable hook for managing unsaved changes and exit warnings.
*
* Usage in a Svelte component:
* ```svelte
* <script lang="ts">
* import { unsavedChangesGuard } from '$lib/dirtyStateGuard';
*
* let formData = { name: '', description: '' };
* const { isDirty, markDirty, markClean, checkBeforeClose } = unsavedChangesGuard();
*
* function handleInputChange() {
* markDirty();
* }
*
* function handleSave() {
* // ... api call ...
* markClean();
* }
*
* async function handleClose() {
* if (await checkBeforeClose()) {
* // Show confirmation dialog if dirty
* // User can choose to discard or keep editing
* } else {
* // No unsaved changes, safe to close
* isOpen = false;
* }
* }
* </script>
*
* <UnsavedChangesDialog
* bind:isOpen={showConfirmation}
* onDiscard={() => { isOpen = false; }}
* onCancel={() => { showConfirmation = false; }}
* />
* ```
*/
export interface UnsavedChangesGuardOptions {
title?: string;
message?: string;
}
export function unsavedChangesGuard(options: UnsavedChangesGuardOptions = {}) {
let isDirtyValue = false;
let confirmationCallback: (() => void) | null = null;
const defaults = {
title: 'Unsaved Changes',
message: 'You have unsaved changes. Are you sure you want to leave?',
...options,
};
return {
get isDirty() {
return isDirtyValue;
},
markDirty() {
isDirtyValue = true;
},
markClean() {
isDirtyValue = false;
},
reset() {
isDirtyValue = false;
},
/**
* Call this when the user tries to close/exit.
* If there are unsaved changes, it will trigger the confirmation callback.
* Returns true if confirmation is needed, false if safe to close.
*/
async checkBeforeClose(onConfirm?: () => void) {
if (isDirtyValue) {
if (onConfirm) {
confirmationCallback = onConfirm;
onConfirm();
}
return true;
}
return false;
},
/**
* Get the confirmation options (for use with UnsavedChangesDialog).
*/
getConfirmationOptions() {
return defaults;
},
};
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { optima } from "./index";
describe("optima aggregate export", () => {
it("exports all expected API modules", () => {
expect(optima).toBeDefined();
expect(optima.auth).toBeDefined();
expect(optima.company).toBeDefined();
expect(optima.credential).toBeDefined();
expect(optima.credentialType).toBeDefined();
expect(optima.role).toBeDefined();
expect(optima.permission).toBeDefined();
expect(optima.user).toBeDefined();
expect(optima.users).toBeDefined();
expect(optima.unifi).toBeDefined();
expect(optima.procurement).toBeDefined();
expect(optima.sales).toBeDefined();
expect(optima.cw).toBeDefined();
});
it("auth module has fetchAuthRedirectUri", () => {
expect(typeof optima.auth.fetchAuthRedirectUri).toBe("function");
});
it("user module has expected methods", () => {
expect(typeof optima.user.isLoggedIn).toBe("function");
expect(typeof optima.user.refreshSession).toBe("function");
expect(typeof optima.user.fetchInfo).toBe("function");
expect(typeof optima.user.logout).toBe("function");
expect(typeof optima.user.checkPermissions).toBe("function");
expect(typeof optima.user.awaitAuthCallback).toBe("function");
});
it("cw module has fetchMembers", () => {
expect(typeof optima.cw.fetchMembers).toBe("function");
});
});
+35
View File
@@ -0,0 +1,35 @@
// place files you want to import through the `$lib` alias in this folder.
import { auth } from "./optima-api/modules/auth";
import { company } from "./optima-api/modules/companies";
import { credential } from "./optima-api/modules/credentials";
import { credentialType } from "./optima-api/modules/credentialTypes";
import { role } from "./optima-api/modules/roles";
import { permission } from "./optima-api/modules/permissions";
import { user } from "./optima-api/modules/user";
import { users } from "./optima-api/modules/users";
import { unifi } from "./optima-api/modules/unifi";
import { procurement } from "./optima-api/modules/procurement";
import { sales } from "./optima-api/modules/sales";
import { cw } from "./optima-api/modules/cw";
export const optima = {
auth,
company,
credential,
credentialType,
role,
permission,
user,
users,
unifi,
procurement,
sales,
cw,
};
/**
* @TODO
*
* - make companies library to interact with api
* - force an auth check on every single interaction or page change.
*/
+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;
},
};
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockCheckPermissions } = vi.hoisted(() => ({
mockCheckPermissions: vi.fn(),
}));
vi.mock("$lib", () => ({
optima: {
user: {
checkPermissions: mockCheckPermissions,
},
},
}));
import {
checkPermissions,
hasPermission,
resolvePermissions,
} from "./permissions";
describe("permissions helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty map when no permissions are requested", async () => {
const result = await checkPermissions("token", []);
expect(result).toEqual({});
expect(mockCheckPermissions).not.toHaveBeenCalled();
});
it("maps API response into permission booleans", async () => {
mockCheckPermissions.mockResolvedValueOnce({
data: {
results: [
{ permission: "company.read", hasPermission: true },
{ permission: "credential.create", hasPermission: false },
],
},
});
const result = await checkPermissions("token", [
"company.read",
"credential.create",
]);
expect(result).toEqual({
"company.read": true,
"credential.create": false,
});
});
it("defaults requested permissions to true on API error and marks __checkFailed", async () => {
mockCheckPermissions.mockRejectedValueOnce(new Error("request failed"));
const result = await checkPermissions("token", ["a", "b"]);
expect(result.a).toBe(true);
expect(result.b).toBe(true);
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(
false,
);
expect(hasPermission({}, "company.read")).toBe(false);
});
it("exports resolvePermissions as backward-compatible alias", () => {
expect(resolvePermissions).toBe(checkPermissions);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { optima } from "$lib";
import type {
PermissionCategory,
PermissionNode,
} from "$lib/optima-api/modules/permissions";
export type PermissionMap = Record<string, boolean> & {
/** Set to `true` when the permission check itself failed (API error, timeout, etc.) */
__checkFailed?: boolean;
};
/**
* Check multiple permissions for the current user and return a map of
* permission → boolean. Designed to be called from any +page.server.ts
* or +layout.server.ts load function.
*
* @example
* ```ts
* const perms = await checkPermissions(accessToken, [
* "company.fetch.address",
* "credential.create",
* ]);
* // perms => { "company.fetch.address": true, "credential.create": false }
* ```
*/
export async function checkPermissions(
accessToken: string,
permissions: string[],
): 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 ?? [];
const map = results.reduce<PermissionMap>((m, entry) => {
m[entry.permission] = entry.hasPermission === true;
return m;
}, {});
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.
const map = permissions.reduce<PermissionMap>((m, p) => {
m[p] = true;
return m;
}, {} as PermissionMap);
map.__checkFailed = true;
return map;
}
}
export const resolvePermissions = checkPermissions;
/**
* Convenience helper — returns true when a specific permission is
* granted inside a PermissionMap.
*/
export function hasPermission(map: PermissionMap, permission: string): boolean {
return map[permission] === true;
}
// ── Permission tree traversal helpers ────────────────────────────────────────
// Shared by CreateRoleModal, EditUserModal, and any future component that
// needs to walk the categorised permission tree returned by the API.
/**
* Recursively collect all permission node strings from a category tree,
* including field-level permission strings attached to each node.
*/
export function collectAllNodes(cat: PermissionCategory): string[] {
const nodes: string[] = [];
for (const p of cat.permissions ?? []) {
nodes.push(p.node);
if (p.fieldLevelPermissions) nodes.push(...p.fieldLevelPermissions);
}
if (cat.subCategories) {
for (const sub of Object.values(cat.subCategories)) {
nodes.push(...collectAllNodes(sub as PermissionCategory));
}
}
return nodes;
}
/**
* Recursively collect all `PermissionNode` objects from a category tree.
*/
export function collectAllPermNodes(cat: PermissionCategory): PermissionNode[] {
const nodes = [...(cat.permissions ?? [])];
if (cat.subCategories) {
for (const sub of Object.values(cat.subCategories)) {
nodes.push(...collectAllPermNodes(sub as PermissionCategory));
}
}
return nodes;
}
/**
* Collect all selectable strings (node + fieldLevelPermissions) from a flat
* array of `PermissionNode` objects.
*/
export function allSelectableStrings(perms: PermissionNode[]): string[] {
const out: string[] = [];
for (const p of perms) {
out.push(p.node);
if (p.fieldLevelPermissions) out.push(...p.fieldLevelPermissions);
}
return out;
}
+129
View File
@@ -0,0 +1,129 @@
import type { SalesOpportunity } from "../lib/optima-api/modules/sales";
/**
* Status ID → CSS class tier mapping.
* Maps both canonical IDs and equivalency IDs to their tier's CSS class.
*/
const STATUS_TIER: Record<number, string> = (() => {
const map: Record<number, string> = {};
// FutureLead (id 51) + equivalencies
for (const id of [51, 35, 36]) map[id] = "status-future";
// PendingNew (id 37)
map[37] = "status-pending-new";
// New (id 24) + equivalencies
for (const id of [24, 1, 13]) map[id] = "status-new";
// Internal Review (id 56) + equivalencies
for (const id of [56, 10, 26, 27, 28, 41, 54]) map[id] = "status-review";
// QuoteSent (id 43)
map[43] = "status-quote-sent";
// ConfirmedQuote (id 57)
map[57] = "status-quote-confirmed";
// ReadyToSend (id 63)
map[63] = "status-ready-to-send";
// PendingSent (id 60)
map[60] = "status-pending-sent";
// PendingRevision (id 61)
map[61] = "status-pending-revision";
// Active (id 58) + equivalencies
for (const id of [
58, 9, 15, 16, 17, 18, 19, 20, 25, 38, 39, 40, 42, 44, 45, 46, 47, 48, 52,
55,
])
map[id] = "status-active";
// PendingWon (id 49)
map[49] = "status-pending-won";
// Won (id 29) + equivalencies
for (const id of [29, 2]) map[id] = "status-won";
// PendingLost (id 50)
map[50] = "status-pending-lost";
// Lost (id 53) + equivalencies
for (const id of [53, 3, 4, 12, 30, 31, 32, 33, 34]) map[id] = "status-lost";
// Canceled (id 59)
map[59] = "status-canceled";
return map;
})();
/**
* Returns a CSS class name corresponding to an opportunity's workflow status.
* This is the single canonical implementation — do not re-declare this function
* in any component or page file.
*/
export function statusColorClass(
opportunity: SalesOpportunity | undefined | null,
): string {
if (opportunity?.closedFlag) {
const sid = opportunity.status?.id;
if (sid != null && STATUS_TIER[sid]) return STATUS_TIER[sid];
return "status-closed";
}
const sid = opportunity?.status?.id;
if (sid == null) return "status-unknown";
return STATUS_TIER[sid] ?? "status-open";
}
/** Canonical display name for each tier */
const CANONICAL_NAMES: Record<number, string> = {
51: "FutureLead",
37: "Pending New",
24: "New",
56: "Internal Review",
43: "Quote Sent",
57: "Confirmed Quote",
63: "Ready to Send",
60: "Pending Sent",
61: "Pending Revision",
58: "Active",
49: "Pending Won",
29: "Won",
50: "Pending Lost",
53: "Lost",
59: "Canceled",
};
/** IDs that are canonical (not equivalency-mapped) */
const CANONICAL_IDS = new Set([
51, 37, 24, 56, 43, 57, 63, 60, 61, 58, 49, 29, 50, 53, 59,
]);
/**
* Returns a human-readable label for an opportunity's workflow status.
* Resolves equivalency-mapped statuses to their canonical display names.
*/
export function statusLabel(
opportunity: SalesOpportunity | undefined | null,
): string {
if (!opportunity?.status?.id) return "Unknown";
const sid = opportunity.status.id;
// If it IS the canonical ID, use its name
if (CANONICAL_IDS.has(sid)) {
return CANONICAL_NAMES[sid] ?? opportunity.status?.name ?? "Open";
}
// Otherwise it's an equivalency — return the canonical name for the tier
const tier = STATUS_TIER[sid];
if (tier) {
for (const [canonId, name] of Object.entries(CANONICAL_NAMES)) {
if (STATUS_TIER[Number(canonId)] === tier) return name;
}
}
return opportunity.status?.name ?? "Unknown";
}
/** Whether this status is an equivalency-mapped status (not canonical). */
export function isEquivalencyStatus(
opportunity: SalesOpportunity | undefined | null,
): boolean {
const sid = opportunity?.status?.id;
if (sid == null) return false;
return STATUS_TIER[sid] != null && !CANONICAL_IDS.has(sid);
}
/** The original CW status name (for tooltip on equivalency statuses). */
export function originalStatusName(
opportunity: SalesOpportunity | undefined | null,
): string {
return opportunity?.status?.name ?? "Unknown";
}
+40
View File
@@ -0,0 +1,40 @@
import { writable } from "svelte/store";
import { clientFetch } from "$lib/client-fetch";
import type { CWMember } from "$lib/optima-api/modules/cw";
function createCwMembersStore() {
const { subscribe, set } = writable<CWMember[]>([]);
let fetched = false;
let inflight: Promise<void> | null = null;
return {
subscribe,
async load(): Promise<void> {
if (fetched) return;
if (inflight) return inflight;
inflight = (async () => {
try {
const json = await clientFetch<{ data: CWMember[] }>(
"/api/cw/members",
);
set(json.data ?? []);
fetched = true;
} finally {
inflight = null;
}
})();
return inflight;
},
reset() {
fetched = false;
inflight = null;
set([]);
},
};
}
export const cwMembers = createCwMembersStore();
+18
View File
@@ -0,0 +1,18 @@
import { writable } from 'svelte/store';
/**
* A store that tracks whether a form or modal has unsaved changes.
* Use this with the unsavedChangesGuard composable.
*/
function createDirtyState() {
const { subscribe, set, update } = writable(false);
return {
subscribe,
markDirty: () => set(true),
markClean: () => set(false),
toggle: () => update(v => !v),
};
}
export const dirtyState = createDirtyState();
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockBrowser } = vi.hoisted(() => ({
mockBrowser: { value: true },
}));
vi.mock("$app/environment", () => ({
get browser() {
return mockBrowser.value;
},
}));
// Mock localStorage and document before importing theme
const mockLocalStorage: Record<string, string> = {};
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => mockLocalStorage[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
mockLocalStorage[key] = value;
}),
});
vi.stubGlobal("document", {
documentElement: {
setAttribute: vi.fn(),
},
});
describe("theme store", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.keys(mockLocalStorage).forEach((k) => delete mockLocalStorage[k]);
mockBrowser.value = true;
});
it("defaults to dark theme", async () => {
// Re-import to get a fresh store
const { theme } = await import("./theme");
let value: string | undefined;
const unsub = theme.subscribe((v) => {
value = v;
});
expect(value).toBe("dark");
unsub();
});
it("toggle switches between dark and light", async () => {
const { theme } = await import("./theme");
let value: string | undefined;
const unsub = theme.subscribe((v) => {
value = v;
});
theme.toggle();
expect(value).toBe("light");
theme.toggle();
expect(value).toBe("dark");
unsub();
});
it("set updates the theme directly", async () => {
const { theme } = await import("./theme");
let value: string | undefined;
const unsub = theme.subscribe((v) => {
value = v;
});
theme.set("light");
expect(value).toBe("light");
theme.set("dark");
expect(value).toBe("dark");
unsub();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { writable } from "svelte/store";
import { browser } from "$app/environment";
type Theme = "light" | "dark";
function createThemeStore() {
const initial: Theme = browser
? ((localStorage.getItem("theme") as Theme) ?? "dark")
: "dark";
const { subscribe, set, update } = writable<Theme>(initial);
function applyTheme(theme: Theme) {
if (browser) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}
}
// Apply on init
if (browser) applyTheme(initial);
return {
subscribe,
toggle() {
update((current) => {
const next = current === "dark" ? "light" : "dark";
applyTheme(next);
return next;
});
},
set(theme: Theme) {
applyTheme(theme);
set(theme);
},
};
}
export const theme = createThemeStore();
+42
View File
@@ -0,0 +1,42 @@
/**
* Format an ISO date string into a human-readable locale date.
* Returns an empty string (or "—" when `dash` is true) for missing/invalid values.
*/
export function formatDate(
dateStr?: string | null,
opts: { dash?: boolean } = { dash: true },
): string {
const fallback = opts.dash ? "—" : "";
if (!dateStr) return fallback;
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return fallback;
}
}
/**
* Compute a list of page numbers (with "..." ellipsis) for a pagination control.
* Always includes page 1 and the last page; collapses distant pages into ellipsis.
*/
export function getPageNumbers(
current: number,
total: number,
): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "...")[] = [1];
if (current > 3) pages.push("...");
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push("...");
pages.push(total);
return pages;
}