mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
9de904dd4c
* feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash <sash@fominykh.io> * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: fix ci checks (#966) * Fix dashboard service link creation (#950) * Fix jump host SOCKS5 proxy selection (#951) * Fix jump host SOCKS5 proxy selection * fix: type jump host socks proxy config * Fix tmux detection path handling (#949) * Support GUACD_URL environment config (#952) * Retry autostart tunnel host fetches (#953) * Retry autostart tunnel host fetches * chore: format tunnel route * Fix PUID html ownership in Docker entrypoint (#954) * feat: allow custom tunnel endpoints (#977) * fix: skip metrics start for non-ssh hosts (#976) * Fix Proxmox import auth fallback (#956) * Fix Proxmox import auth fallback * chore: format proxmox import auth * Fix SSH heading syntax highlighting (#955) * Fix SSH heading syntax highlighting * chore: format terminal highlighter * Initialize auth before fullscreen terminal routes (#957) * Add WebAuthn passkey authentication (#959) * chore: add Biome tooling (#965) * chore: add biome tooling * chore: support tailwind syntax in biome * Fix VNC required argument handshake (#968) * Prioritize host results in command palette search (#969) * Prevent sidebar host hover layout shift (#970) * Add terminal font zoom with mouse wheel (#971) * Add host temperature metrics card (#972) * Make file downloads reliable in desktop app (#973) * Add app rail hover expansion setting (#974) * fix: use correct translation key for nav.close (#964) * fix(tunnel): skip endpoint credential validation for direct tunnels (#963) * fix: SSH port connection bug (#975) * fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948) * feat: support SSH agent auth across SSH features (#960) * feat: add Podman container runtime support (#958) * chore: update readme and release notes * fix: stabilize Windows app icon (#978) * Add safe host sharing export (#979) * Add external editor support for file manager (#985) * Rework SSH credential password handling (#984) * Support password fallback for SSH key credentials * Complete SSH credential password fallback * Fix runtime base path for auth callbacks (#982) * Fix TUI terminal output highlighting (#983) Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Add app fullscreen mode (#993) * Fix host metrics startup polling (#986) * chore: update release notes * fix: line chart text overlap * chore: update RELEASE_NOTES.md * chore: add donation goal to readme * chore: update readme * fix: hide full-screen button in electron app * fix: failed unit tests * chore: lint, format, and bump version to 2.5.0 * chore: remove timeout from crowdin translate * chore: sync Crowdin translations for 2.5.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
2166 lines
56 KiB
TypeScript
2166 lines
56 KiB
TypeScript
import axios, { AxiosError, type AxiosInstance } from "axios";
|
|
import { toast } from "sonner";
|
|
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 | 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: "view";
|
|
expiresAt: string | null;
|
|
createdAt: string;
|
|
}
|
|
import {
|
|
apiLogger,
|
|
authLogger,
|
|
sshLogger,
|
|
tunnelLogger,
|
|
fileLogger,
|
|
statsLogger,
|
|
systemLogger,
|
|
dashboardLogger,
|
|
type LogContext,
|
|
} from "@/lib/frontend-logger";
|
|
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
|
|
|
export type ServerStatus = {
|
|
status: "online" | "offline";
|
|
lastChecked: string;
|
|
};
|
|
|
|
export type SSHHostWithStatus = SSHHost & {
|
|
status: "online" | "offline" | "unknown";
|
|
};
|
|
|
|
interface CpuMetrics {
|
|
percent: number | null;
|
|
cores: number | null;
|
|
load: [number, number, number] | null;
|
|
}
|
|
|
|
interface MemoryMetrics {
|
|
percent: number | null;
|
|
usedGiB: number | null;
|
|
totalGiB: number | null;
|
|
}
|
|
|
|
interface DiskMetrics {
|
|
percent: number | null;
|
|
usedHuman: string | null;
|
|
totalHuman: string | null;
|
|
availableHuman?: string | null;
|
|
}
|
|
|
|
export interface NetworkInterface {
|
|
name: string;
|
|
ip: string;
|
|
state: string;
|
|
rx?: string | null;
|
|
tx?: string | null;
|
|
rxBytes?: string | null;
|
|
txBytes?: string | null;
|
|
}
|
|
|
|
export interface ProcessInfo {
|
|
pid: string;
|
|
user: string;
|
|
cpu: string;
|
|
mem: string;
|
|
command: string;
|
|
}
|
|
|
|
export interface LoginRecord {
|
|
user: string;
|
|
ip: string;
|
|
time: string;
|
|
status: "success" | "failed";
|
|
}
|
|
|
|
export interface ListeningPort {
|
|
protocol: "tcp" | "udp";
|
|
localAddress: string;
|
|
localPort: number;
|
|
state?: string;
|
|
pid?: number;
|
|
process?: string;
|
|
}
|
|
|
|
export interface FirewallRule {
|
|
chain: string;
|
|
target: string;
|
|
protocol: string;
|
|
source: string;
|
|
destination: string;
|
|
dport?: string;
|
|
sport?: string;
|
|
state?: string;
|
|
interface?: string;
|
|
extra?: string;
|
|
}
|
|
|
|
export interface FirewallChain {
|
|
name: string;
|
|
policy: string;
|
|
rules: FirewallRule[];
|
|
}
|
|
|
|
export type ServerMetrics = {
|
|
cpu: CpuMetrics;
|
|
memory: MemoryMetrics;
|
|
disk: DiskMetrics;
|
|
network?: { interfaces?: NetworkInterface[] };
|
|
uptime?: { seconds?: number | null; formatted?: string | null };
|
|
system?: {
|
|
hostname?: string | null;
|
|
os?: string | null;
|
|
kernel?: string | null;
|
|
arch?: string | null;
|
|
};
|
|
processes?: {
|
|
total?: number | null;
|
|
running?: number | null;
|
|
top?: ProcessInfo[];
|
|
};
|
|
login_stats?: {
|
|
recentLogins?: LoginRecord[];
|
|
failedLogins?: LoginRecord[];
|
|
totalLogins?: number;
|
|
uniqueIPs?: number;
|
|
};
|
|
ports?: {
|
|
source?: "ss" | "netstat" | "none";
|
|
ports?: ListeningPort[];
|
|
};
|
|
firewall?: {
|
|
type?: "iptables" | "nftables" | "none";
|
|
status?: "active" | "inactive" | "unknown";
|
|
chains?: FirewallChain[];
|
|
};
|
|
temperature?: {
|
|
source?: "sysfs" | "sensors" | "none";
|
|
highestCelsius?: number | null;
|
|
sensors?: Array<{
|
|
label: string;
|
|
celsius: number;
|
|
}>;
|
|
};
|
|
lastChecked: string;
|
|
};
|
|
|
|
export interface AuthResponse {
|
|
success?: boolean;
|
|
is_admin?: boolean;
|
|
username?: string;
|
|
userId?: string;
|
|
is_oidc?: boolean;
|
|
totp_enabled?: boolean;
|
|
data_unlocked?: boolean;
|
|
requires_totp?: boolean;
|
|
temp_token?: string;
|
|
rememberMe?: boolean;
|
|
token?: string;
|
|
}
|
|
|
|
export interface UserInfo {
|
|
totp_enabled: boolean;
|
|
userId: string;
|
|
username: string;
|
|
is_admin: boolean;
|
|
is_oidc: boolean;
|
|
data_unlocked: boolean;
|
|
password_hash?: string;
|
|
}
|
|
|
|
interface UserCount {
|
|
count: number;
|
|
}
|
|
|
|
interface OIDCAuthorize {
|
|
auth_url: string;
|
|
}
|
|
|
|
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 };
|
|
|
|
function getLoggerForService(serviceName: string) {
|
|
if (serviceName.includes("SSH") || serviceName.includes("ssh")) {
|
|
return sshLogger;
|
|
} else if (serviceName.includes("TUNNEL") || serviceName.includes("tunnel")) {
|
|
return tunnelLogger;
|
|
} else if (serviceName.includes("FILE") || serviceName.includes("file")) {
|
|
return fileLogger;
|
|
} else if (serviceName.includes("STATS") || serviceName.includes("stats")) {
|
|
return statsLogger;
|
|
} 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"];
|
|
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);
|
|
console.log(`[Electron] Loaded setting ${key} from main process`);
|
|
} 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);
|
|
});
|
|
}
|
|
|
|
console.log(`[Electron] Set setting: ${name}`);
|
|
} 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);
|
|
}
|
|
console.log(`[Electron] Get setting: ${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;
|
|
|
|
export function markUserAuthenticated(): void {
|
|
userWasAuthenticated = true;
|
|
latestAuthSuccessAt =
|
|
typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
}
|
|
|
|
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: AxiosRequestConfig) => {
|
|
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);
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (isElectron()) {
|
|
const electronAPI = (
|
|
window as unknown as {
|
|
electronAPI?: { clearSessionCookies?: () => Promise<void> };
|
|
}
|
|
).electronAPI;
|
|
electronAPI?.clearSessionCookies?.().catch(() => {});
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
document.cookie =
|
|
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
|
}
|
|
|
|
if (isSessionExpired && typeof window !== "undefined") {
|
|
console.warn("Session expired - please log in again");
|
|
toast.warning("Session expired. Please log in again.");
|
|
}
|
|
|
|
dbHealthMonitor.reportSessionExpired();
|
|
|
|
userWasAuthenticated = false;
|
|
}
|
|
} else if (!isSilentRetry) {
|
|
const wasAuthenticated = userWasAuthenticated;
|
|
dbHealthMonitor.reportDatabaseError(error, wasAuthenticated);
|
|
}
|
|
|
|
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 || "localhost";
|
|
let configuredServerUrl: string | null = null;
|
|
let embeddedMode = false;
|
|
|
|
export interface ServerConfig {
|
|
serverUrl: string;
|
|
lastUpdated: string;
|
|
allowInvalidCertificate?: boolean;
|
|
}
|
|
|
|
interface AxiosRequestConfigExtended extends AxiosRequestConfig {
|
|
startTime?: number;
|
|
requestId?: string;
|
|
__silentRetry?: boolean;
|
|
}
|
|
|
|
interface AxiosErrorExtended extends AxiosError {
|
|
config?: AxiosRequestConfigExtended;
|
|
}
|
|
|
|
export async function getServerConfig(): Promise<ServerConfig | null> {
|
|
if (!isElectron()) return null;
|
|
|
|
try {
|
|
const result = await (
|
|
window as Window &
|
|
typeof globalThis & {
|
|
IS_ELECTRON?: boolean;
|
|
electronAPI?: unknown;
|
|
configuredServerUrl?: string;
|
|
}
|
|
).electronAPI?.invoke("get-server-config");
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Failed to get server config:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
|
|
if (!isElectron()) return false;
|
|
|
|
try {
|
|
const result = await (
|
|
window as Window &
|
|
typeof globalThis & {
|
|
IS_ELECTRON?: boolean;
|
|
electronAPI?: unknown;
|
|
configuredServerUrl?: string;
|
|
}
|
|
).electronAPI?.invoke("save-server-config", config);
|
|
if (result?.success) {
|
|
configuredServerUrl = config.serverUrl;
|
|
(
|
|
window as Window &
|
|
typeof globalThis & {
|
|
IS_ELECTRON?: boolean;
|
|
electronAPI?: unknown;
|
|
configuredServerUrl?: string;
|
|
}
|
|
).configuredServerUrl = configuredServerUrl;
|
|
updateApiInstances();
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
console.error("Failed to save server config:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function getConfiguredServerUrl(): string | null {
|
|
return configuredServerUrl;
|
|
}
|
|
|
|
export async function testServerConnection(
|
|
serverUrl: string,
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
if (!isElectron())
|
|
return { success: false, error: "Not in Electron environment" };
|
|
|
|
try {
|
|
const result = await (
|
|
window as Window &
|
|
typeof globalThis & {
|
|
IS_ELECTRON?: boolean;
|
|
electronAPI?: unknown;
|
|
configuredServerUrl?: string;
|
|
}
|
|
).electronAPI?.invoke("test-server-connection", serverUrl);
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Failed to test server connection:", error);
|
|
return { success: false, error: "Connection test failed" };
|
|
}
|
|
}
|
|
|
|
export async function checkElectronUpdate(): Promise<{
|
|
success: boolean;
|
|
status?: "up_to_date" | "requires_update" | "beta";
|
|
localVersion?: string;
|
|
remoteVersion?: string;
|
|
latest_release?: {
|
|
tag_name: string;
|
|
name: string;
|
|
published_at: string;
|
|
html_url: string;
|
|
body: string;
|
|
};
|
|
cached?: boolean;
|
|
cache_age?: number;
|
|
error?: string;
|
|
}> {
|
|
if (!isElectron())
|
|
return { success: false, error: "Not in Electron environment" };
|
|
|
|
try {
|
|
const result = await (
|
|
window as Window &
|
|
typeof globalThis & {
|
|
IS_ELECTRON?: boolean;
|
|
electronAPI?: unknown;
|
|
configuredServerUrl?: string;
|
|
}
|
|
).electronAPI?.invoke("check-electron-update");
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Failed to check Electron update:", error);
|
|
return { success: false, error: "Update check failed" };
|
|
}
|
|
}
|
|
|
|
export async function getEmbeddedServerStatus(): Promise<{
|
|
running: boolean;
|
|
embedded: boolean;
|
|
dataDir: string | null;
|
|
} | null> {
|
|
if (!isElectron()) return null;
|
|
|
|
try {
|
|
const result = await (
|
|
window as Window &
|
|
typeof globalThis & {
|
|
IS_ELECTRON?: boolean;
|
|
electronAPI?: {
|
|
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
|
|
};
|
|
}
|
|
).electronAPI?.invoke("get-embedded-server-status");
|
|
return result as {
|
|
running: boolean;
|
|
embedded: boolean;
|
|
dataDir: string | null;
|
|
} | null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function isEmbeddedMode(): boolean {
|
|
return embeddedMode;
|
|
}
|
|
|
|
export function setEmbeddedMode(value: boolean): void {
|
|
embeddedMode = value;
|
|
if (value) {
|
|
configuredServerUrl = null;
|
|
initializeApiInstances();
|
|
}
|
|
}
|
|
|
|
function getApiUrl(path: string, defaultPort: number): string {
|
|
const devMode = isDev();
|
|
const electronMode = isElectron();
|
|
|
|
if (electronMode) {
|
|
if (embeddedMode && !configuredServerUrl) {
|
|
return `http://localhost:${defaultPort}${path}`;
|
|
}
|
|
if (configuredServerUrl) {
|
|
const baseUrl = configuredServerUrl.replace(/\/$/, "");
|
|
const url = `${baseUrl}${path}`;
|
|
return url;
|
|
}
|
|
console.warn("Electron mode but no server configured!");
|
|
return "http://no-server-configured";
|
|
} else if (devMode) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
function initializeApiInstances() {
|
|
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
|
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
|
|
sshHostApi = hostApi;
|
|
|
|
// Tunnel Management API (port 30003)
|
|
tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL");
|
|
|
|
// File Manager Operations API (port 30004)
|
|
fileManagerApi = createApiInstance(
|
|
getApiUrl("/ssh/file_manager", 30004),
|
|
"FILE_MANAGER",
|
|
);
|
|
|
|
// Server Statistics API (port 30005)
|
|
statsApi = createApiInstance(getApiUrl("", 30005), "STATS");
|
|
|
|
// Authentication API (port 30001)
|
|
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
|
|
|
// Dashboard API (port 30006)
|
|
dashboardApi = createApiInstance(getApiUrl("", 30006), "DASHBOARD");
|
|
|
|
// RBAC API (port 30001)
|
|
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
|
|
|
// Docker Management API (port 30007)
|
|
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
|
|
|
|
// Tmux Monitor API (port 30010) --- tmux-monitor ---
|
|
tmuxMonitorApi = createApiInstance(
|
|
getApiUrl("/tmux_monitor", 30010),
|
|
"TMUX_MONITOR",
|
|
);
|
|
|
|
// Homepage API (port 30012)
|
|
homepageApi = createApiInstance(getApiUrl("/homepage", 30012), "HOMEPAGE");
|
|
}
|
|
|
|
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
|
export let hostApi: AxiosInstance;
|
|
// Backward compatibility
|
|
export let sshHostApi: AxiosInstance;
|
|
|
|
// Tunnel Management API (port 30003)
|
|
export let tunnelApi: AxiosInstance;
|
|
|
|
// File Manager Operations API (port 30004)
|
|
export let fileManagerApi: AxiosInstance;
|
|
|
|
// Server Statistics API (port 30005)
|
|
export let statsApi: AxiosInstance;
|
|
|
|
// Authentication API (port 30001)
|
|
export let authApi: AxiosInstance;
|
|
|
|
// Dashboard API (port 30006)
|
|
export let dashboardApi: AxiosInstance;
|
|
|
|
// RBAC API (port 30001)
|
|
export let rbacApi: AxiosInstance;
|
|
|
|
// Docker Management API (port 30007)
|
|
export let dockerApi: AxiosInstance;
|
|
|
|
// Tmux Monitor API (port 30010) --- tmux-monitor ---
|
|
export let tmuxMonitorApi: AxiosInstance;
|
|
|
|
// Homepage API (port 30012)
|
|
export let homepageApi: 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() {
|
|
if (isElectron()) {
|
|
Promise.all([getServerConfig(), getEmbeddedServerStatus()])
|
|
.then(([config, status]) => {
|
|
if (status?.embedded && status?.running && !config?.serverUrl) {
|
|
embeddedMode = true;
|
|
}
|
|
if (config?.serverUrl) {
|
|
configuredServerUrl = config.serverUrl;
|
|
(
|
|
window as Window &
|
|
typeof globalThis & {
|
|
IS_ELECTRON?: boolean;
|
|
electronAPI?: unknown;
|
|
configuredServerUrl?: string;
|
|
}
|
|
).configuredServerUrl = configuredServerUrl;
|
|
} else if (embeddedMode) {
|
|
// Embedded backend running, no remote server needed
|
|
} else {
|
|
console.warn("No server URL in config");
|
|
}
|
|
initializeApiInstances();
|
|
})
|
|
.catch((error) => {
|
|
console.error(
|
|
"Failed to load server config, initializing with default:",
|
|
error,
|
|
);
|
|
initializeApiInstances();
|
|
})
|
|
.finally(() => {
|
|
_resolveAppReady();
|
|
});
|
|
} else {
|
|
initializeApiInstances();
|
|
_resolveAppReady();
|
|
}
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initializeApp);
|
|
} else {
|
|
initializeApp();
|
|
}
|
|
|
|
function updateApiInstances() {
|
|
systemLogger.info("Updating API instances with new server configuration", {
|
|
operation: "api_instance_update",
|
|
configuredServerUrl,
|
|
});
|
|
|
|
initializeApiInstances();
|
|
|
|
(
|
|
window as Window &
|
|
typeof globalThis & {
|
|
IS_ELECTRON?: boolean;
|
|
electronAPI?: unknown;
|
|
configuredServerUrl?: string;
|
|
}
|
|
).configuredServerUrl = configuredServerUrl;
|
|
|
|
systemLogger.success("All API instances updated successfully", {
|
|
operation: "api_instance_update_complete",
|
|
configuredServerUrl,
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 = error instanceof Error ? error.message : "Unknown error";
|
|
apiLogger.error(
|
|
`Unexpected error during ${operation}: ${errorMessage}`,
|
|
error,
|
|
context,
|
|
);
|
|
throw new ApiError(
|
|
`Unexpected error during ${operation}: ${errorMessage}`,
|
|
undefined,
|
|
"UNKNOWN_ERROR",
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
|
|
// ============================================================================
|
|
// HOST-TO-HOST TRANSFER
|
|
// ============================================================================
|
|
|
|
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
|
|
|
|
export interface TransferScanSummary {
|
|
fileCount: number;
|
|
totalBytes: number;
|
|
largestFileBytes: number;
|
|
incompressibleRatio: number;
|
|
}
|
|
|
|
export interface TransferMethodPreview {
|
|
methodPreference: TransferMethodPreference;
|
|
resolvedMethod: "tar" | "item_sftp";
|
|
reasonKey: string;
|
|
sourcePlatform: "unix" | "windows";
|
|
destPlatform: "unix" | "windows";
|
|
sourceHasTar: boolean;
|
|
destHasTar: boolean;
|
|
summary: TransferScanSummary;
|
|
}
|
|
|
|
export async function getTransferMethodPreview(
|
|
sourceSessionId: string,
|
|
sourcePaths: string[],
|
|
destSessionId: string,
|
|
destPath: string,
|
|
methodPreference?: TransferMethodPreference,
|
|
): Promise<TransferMethodPreview> {
|
|
try {
|
|
const response = await fileManagerApi.post("/ssh/transferMethodPreview", {
|
|
sourceSessionId,
|
|
sourcePaths,
|
|
destSessionId,
|
|
destPath,
|
|
methodPreference: methodPreference ?? "auto",
|
|
});
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "preview transfer method");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export interface TransferHopMetrics {
|
|
id: string;
|
|
mbPerSec?: number;
|
|
}
|
|
|
|
export interface TransferTimings {
|
|
prepareDestMs?: number;
|
|
compressMs?: number;
|
|
transferMs?: number;
|
|
extractMs?: number;
|
|
sourceDeleteMs?: number;
|
|
totalMs?: number;
|
|
transferBytes?: number;
|
|
endToEndMbPerSec?: number;
|
|
hops?: TransferHopMetrics[];
|
|
}
|
|
|
|
export function getTransferProgressPercent(
|
|
status: TransferProgressResponse,
|
|
): number | undefined {
|
|
if (
|
|
status.bytesTransferred !== undefined &&
|
|
status.totalBytes !== undefined &&
|
|
status.totalBytes > 0
|
|
) {
|
|
return Math.min(
|
|
100,
|
|
Math.round((status.bytesTransferred / status.totalBytes) * 100),
|
|
);
|
|
}
|
|
if (
|
|
status.itemsCompleted !== undefined &&
|
|
status.totalItems !== undefined &&
|
|
status.totalItems > 0
|
|
) {
|
|
return Math.min(
|
|
100,
|
|
Math.round((status.itemsCompleted / status.totalItems) * 100),
|
|
);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function formatTransferMbPerSec(
|
|
mbPerSec?: number,
|
|
_bytes?: number,
|
|
_ms?: number,
|
|
): string {
|
|
if (mbPerSec === undefined || mbPerSec <= 0) return "";
|
|
if (mbPerSec < 1) return `${(mbPerSec * 1024).toFixed(0)} KB/s`;
|
|
return `${mbPerSec.toFixed(1)} MB/s`;
|
|
}
|
|
|
|
export function formatDurationMs(ms?: number): string {
|
|
if (ms === undefined) return "";
|
|
if (ms < 1000) return `${ms}ms`;
|
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
const minutes = Math.floor(ms / 60000);
|
|
const seconds = Math.round((ms % 60000) / 1000);
|
|
return `${minutes}m ${seconds}s`;
|
|
}
|
|
|
|
interface TransferProgressTracker {
|
|
update(status: TransferProgressResponse): {
|
|
rate: number | undefined;
|
|
stalled: boolean;
|
|
};
|
|
}
|
|
|
|
export function createTransferProgressTracker(): TransferProgressTracker {
|
|
let lastBytes: number | undefined;
|
|
let lastTime: number | undefined;
|
|
let lastRate: number | undefined;
|
|
let lastActivityTime: number | undefined;
|
|
const STALL_THRESHOLD_MS = 5000;
|
|
|
|
return {
|
|
update(status) {
|
|
const now = Date.now();
|
|
const bytes = status.bytesTransferred;
|
|
|
|
if (
|
|
bytes !== undefined &&
|
|
lastBytes !== undefined &&
|
|
lastTime !== undefined
|
|
) {
|
|
const deltaBytes = bytes - lastBytes;
|
|
const deltaMs = now - lastTime;
|
|
if (deltaMs > 0 && deltaBytes >= 0) {
|
|
lastRate = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
|
|
if (deltaBytes > 0) lastActivityTime = now;
|
|
}
|
|
} else if (bytes !== undefined) {
|
|
lastActivityTime = now;
|
|
}
|
|
|
|
lastBytes = bytes;
|
|
lastTime = now;
|
|
|
|
const stalled =
|
|
lastActivityTime !== undefined &&
|
|
now - lastActivityTime > STALL_THRESHOLD_MS &&
|
|
status.status === "running" &&
|
|
status.phase === "transferring";
|
|
|
|
return { rate: lastRate, stalled };
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface TransferProgressResponse {
|
|
transferId: string;
|
|
status: "running" | "success" | "partial" | "error" | "cancelled";
|
|
phase: "compressing" | "transferring" | "extracting" | "reconnecting";
|
|
bytesTransferred?: number;
|
|
totalBytes?: number;
|
|
itemsCompleted?: number;
|
|
totalItems?: number;
|
|
failedPaths?: string[];
|
|
message?: string;
|
|
method?: "stream" | "tar" | "item_sftp";
|
|
sourcePaths?: string[];
|
|
destPath?: string;
|
|
sourceSessionId?: string;
|
|
destSessionId?: string;
|
|
startedAt?: number;
|
|
timings?: TransferTimings;
|
|
sourceDeleted?: boolean;
|
|
moveRequested?: boolean;
|
|
partialDestRemaining?: boolean;
|
|
cleanupCompleted?: boolean;
|
|
retryable?: boolean;
|
|
parallelSegmentCount?: number;
|
|
}
|
|
|
|
export async function transferToHost(
|
|
sourceSessionId: string,
|
|
sourcePaths: string[],
|
|
destSessionId: string,
|
|
destPath: string,
|
|
move?: boolean,
|
|
methodPreference?: TransferMethodPreference,
|
|
parallelSegmentCount = 2,
|
|
): Promise<{ transferId: string }> {
|
|
try {
|
|
fileLogger.info("Starting host transfer", {
|
|
operation: "host_transfer",
|
|
sourceSessionId,
|
|
destSessionId,
|
|
sourcePaths,
|
|
destPath,
|
|
move,
|
|
methodPreference,
|
|
parallelSegmentCount,
|
|
});
|
|
|
|
const response = await fileManagerApi.post("/ssh/transferToHost", {
|
|
sourceSessionId,
|
|
sourcePaths,
|
|
destSessionId,
|
|
destPath,
|
|
move,
|
|
methodPreference: methodPreference ?? "auto",
|
|
parallelSegmentCount,
|
|
});
|
|
|
|
return response.data;
|
|
} catch (error) {
|
|
fileLogger.error("Failed to start host transfer", error, {
|
|
operation: "host_transfer",
|
|
sourceSessionId,
|
|
destSessionId,
|
|
sourcePaths,
|
|
});
|
|
handleApiError(error, "transfer to host");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getTransferStatus(
|
|
transferId: string,
|
|
): Promise<TransferProgressResponse> {
|
|
try {
|
|
const response = await fileManagerApi.get(
|
|
`/ssh/transferStatus/${transferId}`,
|
|
);
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "get transfer status");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function listActiveTransfers(): Promise<{
|
|
transfers: TransferProgressResponse[];
|
|
}> {
|
|
try {
|
|
const response = await fileManagerApi.get("/ssh/activeTransfers");
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "list active transfers");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function cancelTransferToHost(transferId: string): Promise<void> {
|
|
try {
|
|
await fileManagerApi.post(`/ssh/transferCancel/${transferId}`);
|
|
} catch (error) {
|
|
fileLogger.warn("Transfer cancel request failed (non-fatal)", {
|
|
operation: "host_transfer",
|
|
transferId,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function cleanupCancelledTransfer(
|
|
transferId: string,
|
|
): Promise<{ removedPaths: string[]; failedPaths: string[] }> {
|
|
try {
|
|
const response = await fileManagerApi.post(
|
|
`/ssh/transferCleanup/${transferId}`,
|
|
);
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "clean up cancelled transfer");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function retryTransferToHost(
|
|
transferId: string,
|
|
): Promise<{ ok: boolean; transferId: string }> {
|
|
try {
|
|
const response = await fileManagerApi.post(
|
|
`/ssh/transferRetry/${transferId}`,
|
|
);
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "retry transfer");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function pollTransferUntilComplete(
|
|
transferId: string,
|
|
onProgress?: (status: TransferProgressResponse) => void,
|
|
intervalMs = 500,
|
|
): Promise<TransferProgressResponse> {
|
|
return new Promise((resolve, reject) => {
|
|
const poll = async () => {
|
|
try {
|
|
const status = await getTransferStatus(transferId);
|
|
onProgress?.(status);
|
|
|
|
if (
|
|
status.status === "success" ||
|
|
status.status === "partial" ||
|
|
status.status === "error" ||
|
|
status.status === "cancelled"
|
|
) {
|
|
resolve(status);
|
|
return;
|
|
}
|
|
|
|
setTimeout(poll, intervalMs);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
};
|
|
|
|
void poll();
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// FILE MANAGER DATA
|
|
// ============================================================================
|
|
|
|
export interface TransferDestination {
|
|
id: number;
|
|
userId: string;
|
|
sourceHostId: number;
|
|
destHostId: number;
|
|
destPath: string;
|
|
destPathLabel?: string;
|
|
lastUsed?: string;
|
|
}
|
|
|
|
export async function getTransferRecent(
|
|
sourceHostId: number,
|
|
): Promise<TransferDestination[]> {
|
|
try {
|
|
const response = await authApi.get("/host/transfer/recent", {
|
|
params: { sourceHostId },
|
|
});
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "get transfer recent destinations");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function addTransferRecent(
|
|
sourceHostId: number,
|
|
destHostId: number,
|
|
destPath: string,
|
|
destPathLabel?: string,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await authApi.post("/host/transfer/recent", {
|
|
sourceHostId,
|
|
destHostId,
|
|
destPath,
|
|
destPathLabel,
|
|
});
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "add transfer recent destination");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export {
|
|
getSSHHosts,
|
|
createSSHHost,
|
|
updateSSHHost,
|
|
wakeOnLan,
|
|
bulkImportSSHHosts,
|
|
importSSHConfigHosts,
|
|
discoverProxmoxGuests,
|
|
bulkUpdateSSHHosts,
|
|
deleteSSHHost,
|
|
getSSHHostById,
|
|
exportSSHHostWithCredentials,
|
|
exportAllSSHHosts,
|
|
enableAutoStart,
|
|
disableAutoStart,
|
|
getAutoStartStatus,
|
|
testProxyConnection,
|
|
} from "@/api/ssh-host-management-api";
|
|
|
|
export {
|
|
getTunnelStatuses,
|
|
subscribeTunnelStatuses,
|
|
getTunnelStatusByName,
|
|
connectTunnel,
|
|
disconnectTunnel,
|
|
cancelTunnel,
|
|
getC2STunnelPresets,
|
|
createC2STunnelPreset,
|
|
updateC2STunnelPreset,
|
|
deleteC2STunnelPreset,
|
|
} from "@/api/tunnel-api";
|
|
|
|
export {
|
|
getFileManagerRecent,
|
|
addFileManagerRecent,
|
|
removeFileManagerRecent,
|
|
getFileManagerPinned,
|
|
addFileManagerPinned,
|
|
removeFileManagerPinned,
|
|
getFileManagerShortcuts,
|
|
addFileManagerShortcut,
|
|
removeFileManagerShortcut,
|
|
} from "@/api/file-manager-metadata-api";
|
|
|
|
export {
|
|
connectSSH,
|
|
disconnectSSH,
|
|
verifySSHTOTP,
|
|
verifySSHWarpgate,
|
|
quickConnect,
|
|
getSSHStatus,
|
|
keepSSHAlive,
|
|
listSSHFiles,
|
|
identifySSHSymlink,
|
|
resolveSSHPath,
|
|
readSSHFile,
|
|
writeSSHFile,
|
|
uploadSSHFile,
|
|
downloadSSHFile,
|
|
downloadSSHFileStream,
|
|
createSSHFile,
|
|
createSSHFolder,
|
|
deleteSSHItem,
|
|
setSudoPassword,
|
|
copySSHItem,
|
|
renameSSHItem,
|
|
moveSSHItem,
|
|
changeSSHPermissions,
|
|
extractSSHArchive,
|
|
compressSSHFiles,
|
|
ensureSSHSessionForHost,
|
|
browseSSHDirectory,
|
|
type HostConnectionState,
|
|
type EnsureSSHSessionResult,
|
|
type BrowseSSHDirectoryResult,
|
|
} from "@/api/ssh-file-operations-api";
|
|
|
|
export {
|
|
getRecentFiles,
|
|
addRecentFile,
|
|
removeRecentFile,
|
|
getPinnedFiles,
|
|
addPinnedFile,
|
|
removePinnedFile,
|
|
getFolderShortcuts,
|
|
addFolderShortcut,
|
|
removeFolderShortcut,
|
|
} from "@/api/file-manager-data-api";
|
|
|
|
export {
|
|
getAllServerStatuses,
|
|
getServerStatusById,
|
|
getServerMetricsById,
|
|
startMetricsPolling,
|
|
stopMetricsPolling,
|
|
sendMetricsHeartbeat,
|
|
registerMetricsViewer,
|
|
unregisterMetricsViewer,
|
|
submitMetricsTOTP,
|
|
refreshServerPolling,
|
|
notifyHostCreatedOrUpdated,
|
|
} from "@/api/host-metrics-status-api";
|
|
|
|
export {
|
|
getHostMetricsLayout,
|
|
saveHostMetricsLayout,
|
|
getHostPlatform,
|
|
managerGet,
|
|
managerGetSub,
|
|
managerPost,
|
|
type PlatformInfo,
|
|
} from "@/api/host-metrics-api";
|
|
|
|
export {
|
|
getGlobalMonitoringSettings,
|
|
updateGlobalMonitoringSettings,
|
|
getLogLevel,
|
|
updateLogLevel,
|
|
getSessionTimeout,
|
|
updateSessionTimeout,
|
|
getGuacamoleSettings,
|
|
updateGuacamoleSettings,
|
|
} 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,
|
|
});
|
|
|
|
const isInIframe =
|
|
typeof window !== "undefined" && window.self !== window.top;
|
|
|
|
if (isInIframe && isElectron() && response.data.success) {
|
|
try {
|
|
window.parent.postMessage(
|
|
{
|
|
type: "AUTH_SUCCESS",
|
|
source: "login_api",
|
|
platform: "desktop",
|
|
timestamp: Date.now(),
|
|
},
|
|
window.location.origin,
|
|
);
|
|
} catch (e) {
|
|
console.error("[main-axios] Error posting message to parent:", e);
|
|
}
|
|
}
|
|
|
|
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_oidc: response.data.is_oidc,
|
|
totp_enabled: response.data.totp_enabled,
|
|
data_unlocked: response.data.data_unlocked,
|
|
token: response.data.token,
|
|
};
|
|
} catch (error) {
|
|
throw handleApiError(error, "login user");
|
|
}
|
|
}
|
|
|
|
export async function logoutUser(): Promise<{
|
|
success: boolean;
|
|
message: string;
|
|
}> {
|
|
try {
|
|
const response = await authApi.post("/users/logout");
|
|
|
|
clearTermixSessionStorage();
|
|
|
|
if (isElectron()) {
|
|
const electronAPI = (
|
|
window as unknown as {
|
|
electronAPI?: { clearSessionCookies?: () => Promise<void> };
|
|
}
|
|
).electronAPI;
|
|
electronAPI?.clearSessionCookies?.().catch(() => {});
|
|
} else {
|
|
const isSecure = window.location.protocol === "https:";
|
|
const cookieString = 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";
|
|
document.cookie = cookieString;
|
|
}
|
|
|
|
return response.data;
|
|
} catch (error) {
|
|
clearTermixSessionStorage();
|
|
|
|
if (isElectron()) {
|
|
const electronAPI = (
|
|
window as unknown as {
|
|
electronAPI?: { clearSessionCookies?: () => Promise<void> };
|
|
}
|
|
).electronAPI;
|
|
electronAPI?.clearSessionCookies?.().catch(() => {});
|
|
} else {
|
|
const isSecure = window.location.protocol === "https:";
|
|
const cookieString = 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";
|
|
document.cookie = cookieString;
|
|
}
|
|
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 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 }> {
|
|
try {
|
|
const response = await authApi.get("/users/password-login-allowed");
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "check password login status");
|
|
}
|
|
}
|
|
|
|
export async function getOIDCConfig(): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await authApi.get("/users/oidc-config");
|
|
return response.data;
|
|
} catch (error: unknown) {
|
|
console.warn(
|
|
"Failed to fetch OIDC config:",
|
|
error.response?.data?.error || error.message,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getAdminOIDCConfig(): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await authApi.get("/users/oidc-config/admin");
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "fetch admin OIDC config");
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
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,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await authApi.post("/users/complete-reset", {
|
|
username,
|
|
tempToken,
|
|
newPassword,
|
|
});
|
|
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 async function getOIDCAuthorizeUrl(
|
|
rememberMe = false,
|
|
desktopCallbackPort?: number,
|
|
providerId?: number,
|
|
appCallbackUrl?: string,
|
|
): Promise<OIDCAuthorize> {
|
|
try {
|
|
const response = await authApi.get("/users/oidc/authorize", {
|
|
params: { rememberMe, desktopCallbackPort, providerId, appCallbackUrl },
|
|
});
|
|
return response.data;
|
|
} catch (error) {
|
|
handleApiError(error, "get OIDC authorize URL");
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
export {
|
|
getUserList,
|
|
getSessions,
|
|
revokeSession,
|
|
revokeAllUserSessions,
|
|
createApiKey,
|
|
getApiKeys,
|
|
deleteApiKey,
|
|
makeUserAdmin,
|
|
removeAdminStatus,
|
|
deleteUser,
|
|
deleteAccount,
|
|
updateRegistrationAllowed,
|
|
getOidcAutoProvision,
|
|
updateOidcAutoProvision,
|
|
getOidcSilentLoginDefault,
|
|
updateOidcSilentLoginDefault,
|
|
updatePasswordLoginAllowed,
|
|
getPasswordResetAllowed,
|
|
updatePasswordResetAllowed,
|
|
updateOIDCConfig,
|
|
disableOIDCConfig,
|
|
getCommandHistoryEnabled,
|
|
updateCommandHistoryEnabled,
|
|
type ApiKey,
|
|
type CreatedApiKey,
|
|
} from "@/api/user-management-api";
|
|
|
|
export {
|
|
setupTOTP,
|
|
enableTOTP,
|
|
disableTOTP,
|
|
verifyTOTPLogin,
|
|
generateBackupCodes,
|
|
getUserAlerts,
|
|
dismissAlert,
|
|
getReleasesRSS,
|
|
getVersionInfo,
|
|
getDatabaseHealth,
|
|
} from "@/api/system-status-api";
|
|
|
|
// SSH CREDENTIALS MANAGEMENT
|
|
// ============================================================================
|
|
|
|
export {
|
|
getCredentials,
|
|
getCredentialDetails,
|
|
createCredential,
|
|
updateCredential,
|
|
deleteCredential,
|
|
getCredentialHosts,
|
|
getCredentialFolders,
|
|
getSSHHostWithCredentials,
|
|
getHostPassword,
|
|
applyCredentialToHost,
|
|
removeCredentialFromHost,
|
|
migrateHostToCredential,
|
|
getFoldersWithStats,
|
|
renameFolder,
|
|
getSSHFolders,
|
|
updateFolderMetadata,
|
|
deleteAllHostsInFolder,
|
|
renameCredentialFolder,
|
|
detectKeyType,
|
|
detectPublicKeyType,
|
|
validateKeyPair,
|
|
generatePublicKeyFromPrivate,
|
|
generateKeyPair,
|
|
deployCredentialToHost,
|
|
} from "@/api/credentials-api";
|
|
|
|
export {
|
|
getVaultProfiles,
|
|
createVaultProfile,
|
|
updateVaultProfile,
|
|
deleteVaultProfile,
|
|
type VaultProfilePayload,
|
|
} from "@/api/vault-profiles-api";
|
|
|
|
// ============================================================================
|
|
// SNIPPETS API
|
|
// ============================================================================
|
|
|
|
export type {
|
|
NetworkTopologyNode,
|
|
NetworkTopologyEdge,
|
|
NetworkTopologyData,
|
|
} from "@/api/snippets-api";
|
|
export {
|
|
getSnippets,
|
|
createSnippet,
|
|
updateSnippet,
|
|
deleteSnippet,
|
|
executeSnippet,
|
|
getNetworkTopology,
|
|
saveNetworkTopology,
|
|
getSnippetFolders,
|
|
createSnippetFolder,
|
|
updateSnippetFolderMetadata,
|
|
renameSnippetFolder,
|
|
deleteSnippetFolder,
|
|
reorderSnippets,
|
|
} from "@/api/snippets-api";
|
|
|
|
// ============================================================================
|
|
export type {
|
|
UptimeInfo,
|
|
RecentActivityItem,
|
|
ServiceLink,
|
|
} from "@/api/dashboard-api";
|
|
export {
|
|
getUptime,
|
|
getRecentActivity,
|
|
logActivity,
|
|
resetRecentActivity,
|
|
getServiceLinks,
|
|
createServiceLink,
|
|
deleteServiceLink,
|
|
updateServiceLink,
|
|
} from "@/api/dashboard-api";
|
|
|
|
// ============================================================================
|
|
export {
|
|
saveCommandToHistory,
|
|
getCommandHistory,
|
|
deleteCommandFromHistory,
|
|
clearCommandHistory,
|
|
} from "@/api/command-history-api";
|
|
|
|
export {
|
|
linkOIDCToPasswordAccount,
|
|
unlinkOIDCFromPasswordAccount,
|
|
} from "@/api/oidc-account-api";
|
|
|
|
export type {
|
|
GuacamoleTokenRequest,
|
|
GuacamoleTokenResponse,
|
|
} from "@/api/guacamole-api";
|
|
export {
|
|
getGuacamoleDpi,
|
|
getGuacamoleToken,
|
|
getGuacamoleTokenFromHost,
|
|
getGuacdStatus,
|
|
} from "@/api/guacamole-api";
|
|
|
|
// ============================================================================
|
|
// RBAC MANAGEMENT
|
|
// ============================================================================
|
|
|
|
export {
|
|
getRoles,
|
|
createRole,
|
|
updateRole,
|
|
deleteRole,
|
|
getUserRoles,
|
|
assignRoleToUser,
|
|
removeRoleFromUser,
|
|
shareHost,
|
|
getHostAccess,
|
|
revokeHostAccess,
|
|
shareSnippet,
|
|
getSnippetAccess,
|
|
revokeSnippetAccess,
|
|
getSharedSnippets,
|
|
} from "@/api/rbac-api";
|
|
|
|
// ============================================================================
|
|
// DOCKER MANAGEMENT API
|
|
// ============================================================================
|
|
|
|
export {
|
|
connectDockerSession,
|
|
verifyDockerTOTP,
|
|
verifyDockerWarpgate,
|
|
disconnectDockerSession,
|
|
keepaliveDockerSession,
|
|
getDockerSessionStatus,
|
|
validateDockerAvailability,
|
|
listDockerContainers,
|
|
getDockerContainerDetails,
|
|
startDockerContainer,
|
|
stopDockerContainer,
|
|
restartDockerContainer,
|
|
pauseDockerContainer,
|
|
unpauseDockerContainer,
|
|
removeDockerContainer,
|
|
getContainerLogs,
|
|
downloadContainerLogs,
|
|
getContainerStats,
|
|
} from "@/api/docker-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";
|
|
|
|
// ============================================================================
|
|
// TERMIX ID API
|
|
// ============================================================================
|
|
|
|
export {
|
|
getMyTermixId,
|
|
checkTermixIdHandle,
|
|
createTermixId,
|
|
updateTermixId,
|
|
deleteTermixId,
|
|
addTermixIdKey,
|
|
generateTermixIdKey,
|
|
setTermixIdKeyEnabled,
|
|
deleteTermixIdKey,
|
|
getMyCa,
|
|
createCa,
|
|
rotateCa,
|
|
deleteCa,
|
|
issueCertificate,
|
|
getLinkedCredentialIds,
|
|
type TermixIdentity,
|
|
type TermixIdentityKey,
|
|
type TermixIdMe,
|
|
type GeneratedKey,
|
|
type TermixIdCa,
|
|
type IssuedCertificate,
|
|
} from "@/api/termix-id-api";
|