fix: remove nested .git folders, re-add as normal directories
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user