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