Files
Termix/src/ui/main-axios.ts
T

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";