mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
1349 lines
38 KiB
TypeScript
1349 lines
38 KiB
TypeScript
import { getErrorMessage } from "./lib/error-message.js";
|
|
import axios, {
|
|
AxiosError,
|
|
type AxiosInstance,
|
|
type AxiosResponse,
|
|
type InternalAxiosRequestConfig,
|
|
} from "axios";
|
|
import { toast } from "sonner";
|
|
import i18n from "i18next";
|
|
import { getBasePath } from "@/lib/base-path";
|
|
import { isElectron } from "@/lib/electron";
|
|
import { clearTermixSessionStorage } from "@/shell/TabContext";
|
|
import type { SSHHost } from "@/types/index";
|
|
|
|
// ============================================================================
|
|
// RBAC TYPE DEFINITIONS
|
|
// ============================================================================
|
|
|
|
export interface Role {
|
|
id: number;
|
|
name: string;
|
|
displayName: string;
|
|
description: string | null;
|
|
isSystem: boolean;
|
|
permissions: string[] | string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface UserRole {
|
|
userId: string;
|
|
roleId: number;
|
|
roleName: string;
|
|
roleDisplayName: string;
|
|
grantedBy: string;
|
|
grantedByUsername: string;
|
|
grantedAt: string;
|
|
}
|
|
|
|
export interface AccessRecord {
|
|
id: number;
|
|
targetType: "user" | "role";
|
|
userId: string | null;
|
|
roleId: number | null;
|
|
username: string | null;
|
|
roleName: string | null;
|
|
roleDisplayName: string | null;
|
|
grantedBy: string;
|
|
grantedByUsername: string;
|
|
permissionLevel: "connect" | "view" | "edit" | "manage";
|
|
expiresAt: string | null;
|
|
createdAt: string;
|
|
}
|
|
import {
|
|
apiLogger,
|
|
authLogger,
|
|
sshLogger,
|
|
fileLogger,
|
|
dashboardLogger,
|
|
type LogContext,
|
|
} from "@/lib/frontend-logger";
|
|
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
|
import { getDeviceId } from "@/lib/device-id";
|
|
|
|
export type ServerStatus = {
|
|
status: "online" | "offline";
|
|
lastChecked: string;
|
|
};
|
|
|
|
export type SSHHostWithStatus = SSHHost & {
|
|
status: "online" | "offline" | "unknown";
|
|
};
|
|
|
|
export interface AuthResponse {
|
|
success?: boolean;
|
|
is_admin?: boolean;
|
|
username?: string;
|
|
userId?: string;
|
|
is_external?: boolean;
|
|
totp_enabled?: boolean;
|
|
requires_totp?: boolean;
|
|
temp_token?: string;
|
|
rememberMe?: boolean;
|
|
token?: string;
|
|
}
|
|
|
|
export interface UserInfo {
|
|
/** Any second factor, kept under its 2.8 name for older clients. */
|
|
totp_enabled: boolean;
|
|
/** Admin user list only. */
|
|
second_factor_enabled?: boolean;
|
|
userId: string;
|
|
username: string;
|
|
is_admin: boolean;
|
|
/** Signs in through an external login such as SSO or LDAP. */
|
|
is_external?: boolean;
|
|
is_dual_auth?: boolean;
|
|
password_hash?: string;
|
|
data_unlocked?: boolean;
|
|
show_donation_modal?: boolean;
|
|
/** On a desktop linked to a server, the account it is signed in to there. */
|
|
linked?: LinkedAccountInfo | null;
|
|
}
|
|
|
|
export interface LinkedAccountInfo {
|
|
serverUrl: string;
|
|
serverName: string | null;
|
|
username: string | null;
|
|
isAdmin: boolean;
|
|
roles: string[];
|
|
permissions: string[];
|
|
status: string;
|
|
}
|
|
|
|
interface UserCount {
|
|
count: number;
|
|
}
|
|
|
|
type ElectronApi = {
|
|
isElectron?: boolean;
|
|
getSetting?: (key: string) => Promise<string | null | undefined>;
|
|
setSetting?: (key: string, value: string) => Promise<void>;
|
|
};
|
|
|
|
type ElectronWindow = Window &
|
|
typeof globalThis & {
|
|
IS_ELECTRON?: boolean;
|
|
electronAPI?: ElectronApi;
|
|
ReactNativeWebView?: unknown;
|
|
};
|
|
|
|
// ============================================================================
|
|
// UTILITY FUNCTIONS
|
|
// ============================================================================
|
|
|
|
export { isElectron };
|
|
|
|
export const TRUST_TOKEN_KEY = "termixTrustToken";
|
|
|
|
function getLoggerForService(serviceName: string) {
|
|
if (serviceName.includes("SSH") || serviceName.includes("ssh")) {
|
|
return sshLogger;
|
|
} else if (serviceName.includes("FILE") || serviceName.includes("file")) {
|
|
return fileLogger;
|
|
} else if (serviceName.includes("AUTH") || serviceName.includes("auth")) {
|
|
return authLogger;
|
|
} else if (
|
|
serviceName.includes("DASHBOARD") ||
|
|
serviceName.includes("dashboard")
|
|
) {
|
|
return dashboardLogger;
|
|
} else {
|
|
return apiLogger;
|
|
}
|
|
}
|
|
|
|
const electronSettingsCache = new Map<string, string>();
|
|
|
|
if (isElectron()) {
|
|
(async () => {
|
|
try {
|
|
const electronAPI = (window as ElectronWindow).electronAPI;
|
|
|
|
if (electronAPI?.getSetting) {
|
|
const settingsToLoad = ["rightClickCopyPaste", "copyOnSelect"];
|
|
for (const key of settingsToLoad) {
|
|
const value = await electronAPI.getSetting(key);
|
|
if (value !== null && value !== undefined) {
|
|
// Only populate if not already set to prevent overwriting new values during login
|
|
if (!localStorage.getItem(key)) {
|
|
electronSettingsCache.set(key, value);
|
|
localStorage.setItem(key, value);
|
|
} else {
|
|
// Even if we don't overwrite localStorage, update the cache
|
|
electronSettingsCache.set(key, localStorage.getItem(key)!);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("[Electron] Failed to load settings cache:", error);
|
|
}
|
|
})();
|
|
}
|
|
|
|
export function setCookie(
|
|
name: string,
|
|
value: string,
|
|
days = 7,
|
|
): void | Promise<void> {
|
|
if (isElectron()) {
|
|
try {
|
|
if (name === "jwt") {
|
|
return;
|
|
}
|
|
|
|
const electronAPI = (window as ElectronWindow).electronAPI;
|
|
|
|
if (electronAPI?.setSetting) {
|
|
electronSettingsCache.set(name, value);
|
|
localStorage.setItem(name, value);
|
|
electronAPI.setSetting(name, value).catch((err: Error) => {
|
|
console.error(`[Electron] Failed to persist setting ${name}:`, err);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`[Electron] Failed to set setting: ${name}`, error);
|
|
}
|
|
} else {
|
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
|
}
|
|
}
|
|
|
|
export function getCookie(name: string): string | undefined {
|
|
if (isElectron()) {
|
|
try {
|
|
if (name === "jwt") {
|
|
return undefined;
|
|
}
|
|
|
|
if (electronSettingsCache.has(name)) {
|
|
return electronSettingsCache.get(name);
|
|
}
|
|
|
|
const token = localStorage.getItem(name) || undefined;
|
|
if (token) {
|
|
electronSettingsCache.set(name, token);
|
|
}
|
|
return token;
|
|
} catch (error) {
|
|
console.error(`[Electron] Failed to get setting: ${name}`, error);
|
|
return undefined;
|
|
}
|
|
} else {
|
|
const value = `; ${document.cookie}`;
|
|
const parts = value.split(`; ${name}=`);
|
|
const encodedToken =
|
|
parts.length === 2 ? parts.pop()?.split(";").shift() : undefined;
|
|
const token = encodedToken ? decodeURIComponent(encodedToken) : undefined;
|
|
return token;
|
|
}
|
|
}
|
|
|
|
let userWasAuthenticated = false;
|
|
let latestAuthSuccessAt = 0;
|
|
let authInvalidationHandled = false;
|
|
|
|
export function markUserAuthenticated(): void {
|
|
userWasAuthenticated = true;
|
|
authInvalidationHandled = false;
|
|
latestAuthSuccessAt =
|
|
typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
}
|
|
|
|
function clearClientAuthState(): void {
|
|
clearTermixSessionStorage();
|
|
try {
|
|
localStorage.removeItem("jwt");
|
|
localStorage.removeItem("termix_auth");
|
|
} catch {
|
|
// localStorage may be unavailable in restricted contexts.
|
|
}
|
|
|
|
if (isElectron()) {
|
|
const electronAPI = (
|
|
window as unknown as {
|
|
electronAPI?: { clearSessionCookies?: () => Promise<void> };
|
|
}
|
|
).electronAPI;
|
|
electronAPI?.clearSessionCookies?.().catch(() => {});
|
|
} else if (typeof window !== "undefined") {
|
|
const isSecure = window.location.protocol === "https:";
|
|
document.cookie = isSecure
|
|
? "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; Secure; SameSite=Lax"
|
|
: "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax";
|
|
}
|
|
}
|
|
|
|
export function isCurrentAuthInvalidationError(error: unknown): boolean {
|
|
const authError = error as {
|
|
__staleAuthInvalidation?: boolean;
|
|
};
|
|
|
|
if (authError.__staleAuthInvalidation) {
|
|
return false;
|
|
}
|
|
|
|
const axiosError = error as AxiosError;
|
|
const apiError = error as ApiError;
|
|
const responseData = axiosError.response?.data as
|
|
Record<string, unknown> | undefined;
|
|
const errorCode = responseData?.code || apiError.code;
|
|
const errorMessage = responseData?.error || apiError.message;
|
|
const status = axiosError.response?.status || apiError.status;
|
|
const isMissingAuthenticationToken =
|
|
errorMessage === "Missing authentication token";
|
|
|
|
return (
|
|
status === 401 &&
|
|
(errorCode === "SESSION_EXPIRED" ||
|
|
errorCode === "SESSION_NOT_FOUND" ||
|
|
(errorCode === "AUTH_REQUIRED" && userWasAuthenticated) ||
|
|
errorMessage === "Invalid token" ||
|
|
(errorMessage === "Authentication required" && userWasAuthenticated) ||
|
|
(isMissingAuthenticationToken && userWasAuthenticated))
|
|
);
|
|
}
|
|
|
|
function createApiInstance(
|
|
baseURL: string,
|
|
serviceName: string = "API",
|
|
): AxiosInstance {
|
|
const instance = axios.create({
|
|
baseURL,
|
|
headers: { "Content-Type": "application/json" },
|
|
timeout: 30000,
|
|
withCredentials: true,
|
|
});
|
|
|
|
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
const startTime = performance.now();
|
|
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
const configWithMetadata = config as AxiosRequestConfigExtended;
|
|
configWithMetadata.startTime = startTime;
|
|
configWithMetadata.requestId = requestId;
|
|
|
|
const method = config.method?.toUpperCase() || "UNKNOWN";
|
|
const url = config.url || "UNKNOWN";
|
|
const fullUrl = `${config.baseURL}${url}`;
|
|
|
|
const context: LogContext = {
|
|
requestId,
|
|
method,
|
|
url: fullUrl,
|
|
operation: "request_start",
|
|
};
|
|
|
|
const logger = getLoggerForService(serviceName);
|
|
|
|
const isDevMode = process.env.NODE_ENV === "development";
|
|
|
|
if (isDevMode) {
|
|
logger.requestStart(method, fullUrl, context);
|
|
}
|
|
|
|
const deviceId = getDeviceId();
|
|
if (deviceId) {
|
|
if (config.headers.set) {
|
|
config.headers.set("X-Termix-Device-ID", deviceId);
|
|
} else {
|
|
config.headers["X-Termix-Device-ID"] = deviceId;
|
|
}
|
|
}
|
|
|
|
if (isElectron()) {
|
|
if (config.headers.set) {
|
|
config.headers.set("X-Electron-App", "true");
|
|
} else {
|
|
config.headers["X-Electron-App"] = "true";
|
|
}
|
|
const jwt = localStorage.getItem("jwt");
|
|
if (jwt) {
|
|
if (config.headers.set) {
|
|
config.headers.set("Authorization", `Bearer ${jwt}`);
|
|
} else {
|
|
config.headers["Authorization"] = `Bearer ${jwt}`;
|
|
}
|
|
}
|
|
// The desktop app has no cookies with the server, so it carries the
|
|
// remember-this-device token itself, and only to login.
|
|
const trustToken = localStorage.getItem(TRUST_TOKEN_KEY);
|
|
const isLogin =
|
|
config.url?.includes("/users/login") ||
|
|
/\/users\/auth\/[^/]+\/verify$/.test(config.url ?? "");
|
|
if (trustToken && isLogin) {
|
|
if (config.headers.set) {
|
|
config.headers.set("X-Termix-Trust-Token", trustToken);
|
|
} else {
|
|
config.headers["X-Termix-Trust-Token"] = trustToken;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
typeof window !== "undefined" &&
|
|
(window as ElectronWindow).ReactNativeWebView
|
|
) {
|
|
let platform = "Unknown";
|
|
if (typeof navigator !== "undefined" && navigator.userAgent) {
|
|
if (navigator.userAgent.includes("Android")) {
|
|
platform = "Android";
|
|
} else if (
|
|
navigator.userAgent.includes("iPhone") ||
|
|
navigator.userAgent.includes("iPad") ||
|
|
navigator.userAgent.includes("iOS")
|
|
) {
|
|
platform = "iOS";
|
|
}
|
|
}
|
|
if (config.headers.set) {
|
|
config.headers.set("User-Agent", `Termix-Mobile/${platform}`);
|
|
} else {
|
|
config.headers["User-Agent"] = `Termix-Mobile/${platform}`;
|
|
}
|
|
}
|
|
|
|
return config;
|
|
});
|
|
|
|
instance.interceptors.response.use(
|
|
(response: AxiosResponse) => {
|
|
const endTime = performance.now();
|
|
const responseConfig = response.config as AxiosRequestConfigExtended;
|
|
const startTime = responseConfig.startTime;
|
|
const requestId = responseConfig.requestId;
|
|
const responseTime = Math.round(endTime - (startTime || endTime));
|
|
|
|
const method = response.config.method?.toUpperCase() || "UNKNOWN";
|
|
const url = response.config.url || "UNKNOWN";
|
|
const fullUrl = `${response.config.baseURL}${url}`;
|
|
|
|
const context: LogContext = {
|
|
requestId,
|
|
method,
|
|
url: fullUrl,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
responseTime,
|
|
operation: "request_success",
|
|
};
|
|
|
|
const logger = getLoggerForService(serviceName);
|
|
|
|
if (process.env.NODE_ENV === "development") {
|
|
logger.requestSuccess(
|
|
method,
|
|
fullUrl,
|
|
response.status,
|
|
responseTime,
|
|
context,
|
|
);
|
|
}
|
|
|
|
if (responseTime > 3000) {
|
|
logger.warn(`🐌 Slow request: ${responseTime}ms`, context);
|
|
}
|
|
|
|
dbHealthMonitor.reportDatabaseSuccess();
|
|
|
|
return response;
|
|
},
|
|
(error: AxiosErrorExtended) => {
|
|
const endTime = performance.now();
|
|
const startTime = error.config?.startTime;
|
|
const requestId = error.config?.requestId;
|
|
const responseTime = startTime
|
|
? Math.round(endTime - startTime)
|
|
: undefined;
|
|
|
|
const method = error.config?.method?.toUpperCase() || "UNKNOWN";
|
|
const url = error.config?.url || "UNKNOWN";
|
|
const fullUrl = error.config ? `${error.config.baseURL}${url}` : url;
|
|
const status = error.response?.status;
|
|
const message =
|
|
(error.response?.data as { error?: string })?.error ||
|
|
(error as Error).message ||
|
|
"Unknown error";
|
|
const errorCode =
|
|
(error.response?.data as { code?: string })?.code || error.code;
|
|
|
|
const context: LogContext = {
|
|
requestId,
|
|
method,
|
|
url: fullUrl,
|
|
status,
|
|
responseTime,
|
|
errorCode,
|
|
errorMessage: message,
|
|
operation: "request_error",
|
|
};
|
|
|
|
const logger = getLoggerForService(serviceName);
|
|
// A caller can mark a request as a silent retry (see progressive /status
|
|
// retry) so we don't spam error logs / health events on each attempt.
|
|
const isSilentRetry = !!error.config?.__silentRetry;
|
|
|
|
if (process.env.NODE_ENV === "development" && !isSilentRetry) {
|
|
if (status === 401) {
|
|
logger.authError(method, fullUrl, context);
|
|
} else if (status === 0 || !status) {
|
|
logger.networkError(method, fullUrl, message, context);
|
|
} else {
|
|
logger.requestError(
|
|
method,
|
|
fullUrl,
|
|
status || 0,
|
|
message,
|
|
responseTime,
|
|
context,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (status === 401) {
|
|
const errorCode = (error.response?.data as Record<string, unknown>)
|
|
?.code;
|
|
const errorMessage = (error.response?.data as Record<string, unknown>)
|
|
?.error;
|
|
const isSessionExpired = errorCode === "SESSION_EXPIRED";
|
|
const isSessionNotFound = errorCode === "SESSION_NOT_FOUND";
|
|
const isMissingAuthenticationToken =
|
|
errorMessage === "Missing authentication token";
|
|
const isInvalidToken =
|
|
errorCode === "AUTH_REQUIRED" ||
|
|
errorMessage === "Invalid token" ||
|
|
errorMessage === "Authentication required" ||
|
|
(isMissingAuthenticationToken && userWasAuthenticated);
|
|
|
|
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
|
|
const requestStartedAt =
|
|
typeof error.config?.startTime === "number"
|
|
? error.config.startTime
|
|
: 0;
|
|
const isStaleAuthInvalidation =
|
|
latestAuthSuccessAt > 0 &&
|
|
requestStartedAt > 0 &&
|
|
requestStartedAt < latestAuthSuccessAt;
|
|
|
|
if (isStaleAuthInvalidation) {
|
|
(
|
|
error as { __staleAuthInvalidation?: boolean }
|
|
).__staleAuthInvalidation = true;
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (!authInvalidationHandled) {
|
|
authInvalidationHandled = true;
|
|
clearClientAuthState();
|
|
|
|
if (typeof window !== "undefined") {
|
|
console.warn("Session expired - please log in again");
|
|
toast.warning(i18n.t("errors.sessionExpiredLogin"));
|
|
window.dispatchEvent(new Event("termix:logout"));
|
|
}
|
|
|
|
dbHealthMonitor.reportSessionExpired();
|
|
}
|
|
|
|
userWasAuthenticated = false;
|
|
}
|
|
} else if (!isSilentRetry) {
|
|
dbHealthMonitor.reportDatabaseError(error);
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
return instance;
|
|
}
|
|
|
|
// ============================================================================
|
|
// API INSTANCES
|
|
// ============================================================================
|
|
|
|
function isDev(): boolean {
|
|
if (isElectron()) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
process.env.NODE_ENV === "development" &&
|
|
(window.location.port === "3000" ||
|
|
window.location.port === "5173" ||
|
|
window.location.port === "" ||
|
|
window.location.hostname === "localhost" ||
|
|
window.location.hostname === "127.0.0.1")
|
|
);
|
|
}
|
|
|
|
const apiHost =
|
|
import.meta.env.VITE_API_HOST ||
|
|
(typeof window !== "undefined" ? window.location.hostname : "localhost");
|
|
|
|
interface AxiosRequestConfigExtended extends InternalAxiosRequestConfig {
|
|
startTime?: number;
|
|
requestId?: string;
|
|
__silentRetry?: boolean;
|
|
}
|
|
|
|
interface AxiosErrorExtended extends AxiosError {
|
|
config?: AxiosRequestConfigExtended;
|
|
}
|
|
|
|
/** A URL on the main backend, resolved for web, dev proxy and Electron. */
|
|
export function getBackendUrl(path: string): string {
|
|
return getApiUrl(path, 30001);
|
|
}
|
|
|
|
function getApiUrl(path: string, defaultPort: number): string {
|
|
const devMode = isDev();
|
|
const electronMode = isElectron();
|
|
|
|
if (electronMode) {
|
|
// The desktop app always talks to its embedded backend. The server it
|
|
// may be linked to is reached through createRemoteOriginApiInstance.
|
|
return `http://localhost:${defaultPort}${path}`;
|
|
} else if (devMode) {
|
|
if (!import.meta.env.VITE_API_HOST) {
|
|
return `/__termix_api/${defaultPort}${path}`;
|
|
}
|
|
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
|
const sslPort = protocol === "https" ? 8443 : defaultPort;
|
|
const url = `${protocol}://${apiHost}:${sslPort}${path}`;
|
|
return url;
|
|
} else {
|
|
return getBasePath() + path;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PER-HOST ORIGIN ROUTING (Electron desktop only)
|
|
// ============================================================================
|
|
//
|
|
// hostApi above always points at the embedded local backend. When a host's
|
|
// connection origin resolves to "remote" (see src/ui/lib/connection-origin.ts),
|
|
// the backend that holds that host's live session is the connected remote
|
|
// server instead, so calls for that host must follow it there.
|
|
//
|
|
// These instances look up the linked server and its session on every
|
|
// request, so linking or unlinking needs no reload.
|
|
|
|
export function createRemoteOriginApiInstance(path: string): AxiosInstance {
|
|
const instance = axios.create({
|
|
headers: { "Content-Type": "application/json" },
|
|
timeout: 30000,
|
|
});
|
|
|
|
instance.interceptors.request.use(
|
|
async (config: InternalAxiosRequestConfig) => {
|
|
const { getLinkedSession } = await import("@/lib/linked-server");
|
|
const linked = await getLinkedSession();
|
|
config.baseURL = linked
|
|
? `${linked.serverUrl}${path}`
|
|
: "http://no-server-configured";
|
|
|
|
if (config.headers.set) {
|
|
config.headers.set("X-Electron-App", "true");
|
|
if (linked)
|
|
config.headers.set("Authorization", `Bearer ${linked.token}`);
|
|
} else {
|
|
config.headers["X-Electron-App"] = "true";
|
|
if (linked) config.headers["Authorization"] = `Bearer ${linked.token}`;
|
|
}
|
|
|
|
return config;
|
|
},
|
|
);
|
|
|
|
return instance;
|
|
}
|
|
|
|
let remoteCoreApi: AxiosInstance | null = null;
|
|
|
|
/** The linked server's core routes, unprefixed. */
|
|
export function getRemoteCoreApi(): AxiosInstance {
|
|
if (!remoteCoreApi) {
|
|
remoteCoreApi = createRemoteOriginApiInstance("");
|
|
}
|
|
return remoteCoreApi;
|
|
}
|
|
|
|
function initializeApiInstances() {
|
|
// Host Management API (port 30001) - SSH and plugin protocols
|
|
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
|
|
sshHostApi = hostApi;
|
|
|
|
// Authentication API (port 30001)
|
|
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
|
|
|
// RBAC API (port 30001)
|
|
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
|
}
|
|
|
|
// Host Management API (port 30001) - SSH and plugin protocols
|
|
export let hostApi: AxiosInstance;
|
|
// Backward compatibility
|
|
export let sshHostApi: AxiosInstance;
|
|
|
|
// Authentication API (port 30001)
|
|
export let authApi: AxiosInstance;
|
|
|
|
// RBAC API (port 30001)
|
|
export let rbacApi: AxiosInstance;
|
|
|
|
// Pre-initialize with default values to avoid undefined errors during early mounting
|
|
initializeApiInstances();
|
|
|
|
let _resolveAppReady!: () => void;
|
|
export const appReadyPromise: Promise<void> = new Promise((resolve) => {
|
|
_resolveAppReady = resolve;
|
|
});
|
|
|
|
function initializeApp() {
|
|
initializeApiInstances();
|
|
_resolveAppReady();
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initializeApp);
|
|
} else {
|
|
initializeApp();
|
|
}
|
|
|
|
// ============================================================================
|
|
// ERROR HANDLING
|
|
// ============================================================================
|
|
|
|
class ApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public status?: number,
|
|
public code?: string,
|
|
) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
}
|
|
}
|
|
|
|
export function handleApiError(error: unknown, operation: string): never {
|
|
const context: LogContext = {
|
|
operation: "error_handling",
|
|
errorOperation: operation,
|
|
};
|
|
|
|
if (axios.isAxiosError(error)) {
|
|
const status = error.response?.status;
|
|
const message =
|
|
error.response?.data?.message ||
|
|
error.response?.data?.error ||
|
|
error.message;
|
|
const code = error.response?.data?.code || error.response?.data?.error;
|
|
const url = error.config?.url;
|
|
const method = error.config?.method?.toUpperCase();
|
|
|
|
const errorContext: LogContext = {
|
|
...context,
|
|
method,
|
|
url,
|
|
status,
|
|
errorCode: code,
|
|
errorMessage: message,
|
|
};
|
|
|
|
if (status === 401) {
|
|
authLogger.warn(
|
|
`Auth failed: ${method} ${url} - ${message}`,
|
|
errorContext,
|
|
);
|
|
|
|
const isLoginEndpoint = url?.includes("/users/login");
|
|
const errorMessage = isLoginEndpoint
|
|
? message
|
|
: "Authentication required. Please log in again.";
|
|
|
|
throw new ApiError(errorMessage, 401, code || "AUTH_REQUIRED");
|
|
} else if (status === 403) {
|
|
authLogger.warn(`Access denied: ${method} ${url}`, errorContext);
|
|
const apiError = new ApiError(
|
|
code === "TOTP_REQUIRED"
|
|
? message
|
|
: "Access denied. You do not have permission to perform this action.",
|
|
403,
|
|
code || "ACCESS_DENIED",
|
|
);
|
|
(apiError as ApiError & { response?: unknown }).response = error.response;
|
|
throw apiError;
|
|
} else if (status === 404) {
|
|
apiLogger.warn(`Not found: ${method} ${url}`, errorContext);
|
|
throw new ApiError(
|
|
"Resource not found. The requested item may have been deleted.",
|
|
404,
|
|
"NOT_FOUND",
|
|
);
|
|
} else if (status === 409) {
|
|
apiLogger.warn(`Conflict: ${method} ${url}`, errorContext);
|
|
throw new ApiError(
|
|
"Conflict. The resource already exists or is in use.",
|
|
409,
|
|
"CONFLICT",
|
|
);
|
|
} else if (status === 422) {
|
|
apiLogger.warn(
|
|
`Validation error: ${method} ${url} - ${message}`,
|
|
errorContext,
|
|
);
|
|
throw new ApiError(
|
|
"Validation error. Please check your input and try again.",
|
|
422,
|
|
"VALIDATION_ERROR",
|
|
);
|
|
} else if (status && status >= 500) {
|
|
apiLogger.error(
|
|
`Server error: ${method} ${url} - ${message}`,
|
|
error,
|
|
errorContext,
|
|
);
|
|
throw new ApiError(
|
|
"Server error occurred. Please try again later.",
|
|
status,
|
|
"SERVER_ERROR",
|
|
);
|
|
} else if (status === 0) {
|
|
if (url.includes("no-server-configured")) {
|
|
apiLogger.error(
|
|
`No server configured: ${method} ${url}`,
|
|
error,
|
|
errorContext,
|
|
);
|
|
throw new ApiError(
|
|
"No server configured. Please configure a Termix server first.",
|
|
0,
|
|
"NO_SERVER_CONFIGURED",
|
|
);
|
|
}
|
|
apiLogger.error(
|
|
`Network error: ${method} ${url} - ${message}`,
|
|
error,
|
|
errorContext,
|
|
);
|
|
throw new ApiError(
|
|
"Network error. Please check your connection and try again.",
|
|
0,
|
|
"NETWORK_ERROR",
|
|
);
|
|
} else {
|
|
apiLogger.error(
|
|
`Request failed: ${method} ${url} - ${message}`,
|
|
error,
|
|
errorContext,
|
|
);
|
|
throw new ApiError(message || `Failed to ${operation}`, status, code);
|
|
}
|
|
}
|
|
|
|
if (error instanceof ApiError) {
|
|
throw error;
|
|
}
|
|
|
|
const errorMessage = getErrorMessage(error);
|
|
apiLogger.error(
|
|
`Unexpected error during ${operation}: ${errorMessage}`,
|
|
error,
|
|
context,
|
|
);
|
|
throw new ApiError(
|
|
`Unexpected error during ${operation}: ${errorMessage}`,
|
|
undefined,
|
|
"UNKNOWN_ERROR",
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
|
|
export {
|
|
getSSHHosts,
|
|
createSSHHost,
|
|
updateSSHHost,
|
|
bulkImportSSHHosts,
|
|
importSSHConfigHosts,
|
|
bulkUpdateSSHHosts,
|
|
reorderSSHHosts,
|
|
deleteSSHHost,
|
|
getSSHHostById,
|
|
exportSSHHostWithCredentials,
|
|
exportAllSSHHosts,
|
|
testProxyConnection,
|
|
} from "@/api/ssh-host-management-api";
|
|
|
|
export {
|
|
getAllServerStatuses,
|
|
getServerStatusById,
|
|
refreshServerPolling,
|
|
getStatusCheckSettings,
|
|
updateStatusCheckSettings,
|
|
} from "@/api/host-status-api";
|
|
|
|
export {
|
|
getHostSidebarPreferences,
|
|
saveHostSidebarPreferences,
|
|
} from "@/api/host-sidebar-preferences-api";
|
|
|
|
export {
|
|
getCredentialSidebarPreferences,
|
|
saveCredentialSidebarPreferences,
|
|
} from "@/api/credential-sidebar-preferences-api";
|
|
|
|
export { getUiPreferences, saveUiPreferences } from "@/api/ui-preferences-api";
|
|
|
|
export {
|
|
getLogLevel,
|
|
updateLogLevel,
|
|
getSessionTimeout,
|
|
updateSessionTimeout,
|
|
} from "@/api/settings-api";
|
|
|
|
// ============================================================================
|
|
// AUTHENTICATION
|
|
// ============================================================================
|
|
|
|
export async function registerUser(
|
|
username: string,
|
|
password: string,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await authApi.post("/users/create", {
|
|
username,
|
|
password,
|
|
});
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "register user");
|
|
}
|
|
}
|
|
|
|
export async function adminCreateUser(
|
|
username: string,
|
|
password: string,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await authApi.post("/users/admin-create", {
|
|
username,
|
|
password,
|
|
});
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "admin create user");
|
|
}
|
|
}
|
|
|
|
export async function loginUser(
|
|
username: string,
|
|
password: string,
|
|
rememberMe: boolean = false,
|
|
): Promise<AuthResponse> {
|
|
try {
|
|
const response = await authApi.post("/users/login", {
|
|
username,
|
|
password,
|
|
rememberMe,
|
|
});
|
|
|
|
if (response.data.token) {
|
|
localStorage.setItem("jwt", response.data.token);
|
|
}
|
|
|
|
if (response.data.success && !response.data.requires_totp) {
|
|
markUserAuthenticated();
|
|
}
|
|
|
|
return {
|
|
success: response.data.success,
|
|
is_admin: response.data.is_admin,
|
|
username: response.data.username,
|
|
requires_totp: response.data.requires_totp,
|
|
temp_token: response.data.temp_token,
|
|
rememberMe: response.data.rememberMe,
|
|
is_external: response.data.is_external,
|
|
totp_enabled: response.data.totp_enabled,
|
|
token: response.data.token,
|
|
};
|
|
} catch (error) {
|
|
throw handleApiError(error, "login user");
|
|
}
|
|
}
|
|
|
|
export async function requestTrustedProxyLogin(): Promise<{
|
|
enabled: boolean;
|
|
success?: boolean;
|
|
username?: string;
|
|
userId?: string;
|
|
is_admin?: boolean;
|
|
token?: string;
|
|
}> {
|
|
const response = await authApi.post("/users/proxy-login");
|
|
if (response.data.token) localStorage.setItem("jwt", response.data.token);
|
|
if (response.data.success) markUserAuthenticated();
|
|
return response.data;
|
|
}
|
|
|
|
export async function logoutUser(): Promise<{
|
|
success: boolean;
|
|
message: string;
|
|
}> {
|
|
try {
|
|
const response = await authApi.post("/users/logout");
|
|
clearClientAuthState();
|
|
userWasAuthenticated = false;
|
|
authInvalidationHandled = false;
|
|
return response.data;
|
|
} catch (error) {
|
|
clearClientAuthState();
|
|
userWasAuthenticated = false;
|
|
authInvalidationHandled = false;
|
|
handleApiError(error, "logout user");
|
|
}
|
|
}
|
|
|
|
export async function getUserInfo(): Promise<UserInfo> {
|
|
try {
|
|
const response = await authApi.get("/users/me");
|
|
markUserAuthenticated();
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "fetch user info");
|
|
}
|
|
}
|
|
|
|
export async function dismissDonationModal(): Promise<void> {
|
|
try {
|
|
await authApi.post("/users/me/dismiss-donation-modal");
|
|
} catch (error) {
|
|
handleApiError(error, "dismiss donation modal");
|
|
}
|
|
}
|
|
|
|
export async function getCurrentToken(): Promise<string | null> {
|
|
try {
|
|
const response = await authApi.get("/users/me/token");
|
|
return response.data?.token ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function unlockUserData(
|
|
password: string,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
try {
|
|
const response = await authApi.post("/users/unlock-data", { password });
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "unlock user data");
|
|
}
|
|
}
|
|
|
|
export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> {
|
|
try {
|
|
const response = await authApi.get("/users/registration-allowed");
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "check registration status");
|
|
}
|
|
}
|
|
|
|
export async function getPasswordLoginAllowed(): Promise<{
|
|
allowed: boolean;
|
|
/** Turned off by an admin but kept on because nothing else can sign in. */
|
|
forced?: boolean;
|
|
}> {
|
|
try {
|
|
const response = await authApi.get("/users/password-login-allowed");
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "check password login status");
|
|
}
|
|
}
|
|
|
|
export async function getSetupRequired(): Promise<{ setup_required: boolean }> {
|
|
try {
|
|
const response = await authApi.get("/users/setup-required");
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "check setup status");
|
|
}
|
|
}
|
|
|
|
// Module-level (not per-component) so concurrent/duplicate mounts -- e.g.
|
|
// React StrictMode's intentional double-invoke of effects in dev, or any
|
|
// other double-call -- share one in-flight request instead of each minting
|
|
// its own session. Minting more than one session here is not just wasted
|
|
// work: the backend sets a fresh `jwt` cookie on every call, and since the
|
|
// auth middleware prefers the cookie over the Authorization header, a
|
|
// second mint silently invalidates whichever token the app already started
|
|
// using, surfacing as a spurious "session expired/revoked" on the very
|
|
// next request.
|
|
let desktopAutoSessionRequest: Promise<DesktopAutoSessionOutcome> | null = null;
|
|
|
|
// A 403 from the auto-session endpoint means the backend positively
|
|
// evaluated the request and declined (not loopback, or not exactly one
|
|
// local user) -- that verdict won't change by retrying. Any other failure
|
|
// (connection refused, timeout, 5xx) most likely means the embedded
|
|
// backend process hasn't finished booting yet, which is routine on a cold
|
|
// first launch, so it's worth retrying rather than treated as final.
|
|
export type DesktopAutoSessionOutcome =
|
|
| { kind: "success"; data: AuthResponse }
|
|
| { kind: "declined" }
|
|
| { kind: "retry" };
|
|
|
|
/**
|
|
* Electron-only, non-iframed local login: exchanges the embedded backend's
|
|
* auto-provisioned local user for a session without ever showing a login
|
|
* form. If the local database contains multiple users, the backend
|
|
* deterministically chooses the admin account, or the earliest registered
|
|
* account if no admin exists.
|
|
*/
|
|
export async function requestDesktopAutoSession(): Promise<DesktopAutoSessionOutcome> {
|
|
if (desktopAutoSessionRequest) return desktopAutoSessionRequest;
|
|
|
|
desktopAutoSessionRequest = (async () => {
|
|
try {
|
|
const response = await authApi.post("/users/internal/auto-session");
|
|
if (response.data?.token) {
|
|
localStorage.setItem("jwt", response.data.token);
|
|
}
|
|
if (response.data?.success) {
|
|
markUserAuthenticated();
|
|
return { kind: "success" as const, data: response.data };
|
|
}
|
|
return { kind: "declined" as const };
|
|
} catch (err) {
|
|
const status =
|
|
(err as { status?: number; response?: { status?: number } })?.status ??
|
|
(err as { response?: { status?: number } })?.response?.status;
|
|
if (status === 403) {
|
|
return { kind: "declined" as const };
|
|
}
|
|
return { kind: "retry" as const };
|
|
}
|
|
})();
|
|
|
|
try {
|
|
return await desktopAutoSessionRequest;
|
|
} finally {
|
|
desktopAutoSessionRequest = null;
|
|
}
|
|
}
|
|
|
|
export async function getUserCount(): Promise<UserCount> {
|
|
try {
|
|
const response = await authApi.get("/users/count");
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "fetch user count");
|
|
}
|
|
}
|
|
|
|
export async function initiatePasswordReset(
|
|
username: string,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await authApi.post("/users/initiate-reset", { username });
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "initiate password reset");
|
|
}
|
|
}
|
|
|
|
export async function verifyPasswordResetCode(
|
|
username: string,
|
|
resetCode: string,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await authApi.post("/users/verify-reset-code", {
|
|
username,
|
|
resetCode,
|
|
});
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "verify reset code");
|
|
}
|
|
}
|
|
|
|
export async function completePasswordReset(
|
|
username: string,
|
|
tempToken: string,
|
|
newPassword: string,
|
|
confirmDataWipe = false,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await authApi.post("/users/complete-reset", {
|
|
username,
|
|
tempToken,
|
|
newPassword,
|
|
confirmDataWipe,
|
|
});
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "complete password reset");
|
|
}
|
|
}
|
|
|
|
export async function changePassword(oldPassword: string, newPassword: string) {
|
|
try {
|
|
const response = await authApi.post("/users/change-password", {
|
|
oldPassword,
|
|
newPassword,
|
|
});
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "change password");
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
export {
|
|
getUserList,
|
|
getSessions,
|
|
revokeSession,
|
|
revokeAllUserSessions,
|
|
createApiKey,
|
|
getApiKeys,
|
|
deleteApiKey,
|
|
makeUserAdmin,
|
|
removeAdminStatus,
|
|
deleteUser,
|
|
deleteAccount,
|
|
updateRegistrationAllowed,
|
|
getExternalAutoProvision,
|
|
updateExternalAutoProvision,
|
|
getSecondFactorAfterExternalLogin,
|
|
updateSecondFactorAfterExternalLogin,
|
|
updatePasswordLoginAllowed,
|
|
getPasswordResetAllowed,
|
|
updatePasswordResetAllowed,
|
|
adminResetUserPassword,
|
|
adminExportUserData,
|
|
type ApiKey,
|
|
type CreatedApiKey,
|
|
} from "@/api/user-management-api";
|
|
|
|
// ADMIN USER DATA MANAGEMENT
|
|
// ============================================================================
|
|
|
|
export {
|
|
adminGetUserHosts,
|
|
adminCreateUserHost,
|
|
adminUpdateUserHost,
|
|
adminDeleteUserHost,
|
|
adminGetHostPassword,
|
|
adminGetUserCredentials,
|
|
adminGetUserCredentialDetails,
|
|
adminCreateUserCredential,
|
|
adminUpdateUserCredential,
|
|
adminDeleteUserCredential,
|
|
} from "@/api/admin-user-data-api";
|
|
|
|
export {
|
|
getReleasesRSS,
|
|
getVersionInfo,
|
|
releaseUrlFrom,
|
|
getDatabaseHealth,
|
|
} from "@/api/system-status-api";
|
|
|
|
// SSH CREDENTIALS MANAGEMENT
|
|
// ============================================================================
|
|
|
|
export {
|
|
getCredentials,
|
|
getCredentialDetails,
|
|
createCredential,
|
|
updateCredential,
|
|
duplicateCredential,
|
|
deleteCredential,
|
|
getCredentialHosts,
|
|
getCredentialFolders,
|
|
getSSHHostWithCredentials,
|
|
getHostPassword,
|
|
applyCredentialToHost,
|
|
removeCredentialFromHost,
|
|
migrateHostToCredential,
|
|
getFoldersWithStats,
|
|
renameFolder,
|
|
getSSHFolders,
|
|
updateFolderMetadata,
|
|
reorderFolders,
|
|
deleteAllHostsInFolder,
|
|
renameCredentialFolder,
|
|
reorderCredentials,
|
|
detectKeyType,
|
|
detectPublicKeyType,
|
|
validateKeyPair,
|
|
generatePublicKeyFromPrivate,
|
|
generateKeyPair,
|
|
deployCredentialToHost,
|
|
} from "@/api/credentials-api";
|
|
|
|
// ============================================================================
|
|
export type { UptimeInfo, RecentActivityItem } from "@/api/dashboard-api";
|
|
export {
|
|
getUptime,
|
|
getRecentActivity,
|
|
logActivity,
|
|
resetRecentActivity,
|
|
} from "@/api/dashboard-api";
|
|
|
|
// ============================================================================
|
|
export {
|
|
linkExternalToPasswordAccount,
|
|
unlinkExternalFromPasswordAccount,
|
|
} from "@/api/external-account-api";
|
|
|
|
// ============================================================================
|
|
// RBAC MANAGEMENT
|
|
// ============================================================================
|
|
|
|
export {
|
|
getRoles,
|
|
createRole,
|
|
updateRole,
|
|
deleteRole,
|
|
getUserRoles,
|
|
assignRoleToUser,
|
|
removeRoleFromUser,
|
|
shareHost,
|
|
shareFolder,
|
|
updateHostAccess,
|
|
getHostAccess,
|
|
revokeHostAccess,
|
|
getHostAuthOverride,
|
|
setHostAuthOverride,
|
|
getPermissionsCatalog,
|
|
getSharedHosts,
|
|
} from "@/api/rbac-api";
|
|
export type {
|
|
SharePermissionLevel,
|
|
ShareTarget,
|
|
PermissionCatalogEntry,
|
|
PermissionCatalogItem,
|
|
} from "@/api/rbac-api";
|
|
|
|
export {
|
|
getOpenTabs,
|
|
syncOpenTabs,
|
|
deleteOpenTab,
|
|
patchOpenTab,
|
|
addOpenTab,
|
|
getActiveSessions,
|
|
getUserPreferences,
|
|
saveUserPreferences,
|
|
type OpenTabRecord,
|
|
type OpenTabSyncPayload,
|
|
type OpenTabUpsertPayload,
|
|
type ActiveSessionInfo,
|
|
type UserPreferences,
|
|
} from "@/api/open-tabs-api";
|