* fix: patch critical security vulnerabilities (GHSA-5fqh, GHSA-ccm8, GHSA-wqfw, GHSA-xmjh)

- Remove passwordHash from /users/list API response
- Require both password and TOTP code for MFA-critical operations
- Restrict tunnel kill commands to tunnelMarker-only matching
- Add session ownership middleware for file manager endpoints

* fix: allow navigating away from split-view to non-pane tabs

Show the normal view container on top of the split view when the active
tab is not assigned to any pane, so users can switch to dashboard or
other tabs while split mode is active.

Closes #739

* fix: add inline quick-action buttons on host name row

Show Terminal, Files, RDP, and VNC shortcut icons on the host name row
on hover, so users can launch connections with a single click without
expanding the full action tray.

Closes #736

* fix: restore SSH keepalive interval to 30s to prevent random disconnects

Revert keepalive defaults from 60s/5 to 30s/3 across terminal, tunnel,
and server-stats SSH connections. The 60s interval introduced in 2.3.0
causes firewalls and NAT devices to drop idle connections before the
next keepalive probe.

Closes #733

* fix: apply guacamole-lite protocol patch in Docker builds

The Dockerfile uses --ignore-scripts which skips the postinstall hook
that patches guacamole-lite for guacd 1.6.0 protocol VERSION_1_5_0.
Without this patch, the timezone handshake instruction is not sent for
protocol versions above 1.1.0, causing VNC connections to fail
immediately on connect.

Closes #734

* fix: show correct icons for network interface types

Detect interface type from name pattern and show appropriate icons:
WiFi for wlan/wl*, Ethernet (Cable) for eth/en*, Container for
docker/bridge/virtual, generic Network for others.

Closes #720

* fix: resolve sudo password for shared host users

The password endpoint required hosts.userId to match the requesting
user, which fails for shared hosts. Now falls back to decrypting with
the owner's key when the requesting user doesn't own the host.

Closes #717

* fix: use jump hosts for online status check and metrics collection

Status polling now pings the first jump host instead of the unreachable
target when jump hosts are configured. The /metrics/start endpoint now
tunnels through the jump host chain to reach the target host.

Closes #716

* fix: broaden sudo prompt detection for newer distros

Add patterns for 'password for <user>:' and bare 'Password:' prompts
in addition to the existing [sudo] and sudo: patterns. Covers Ubuntu
26.04 and other distros that use different sudo prompt formats.

Closes #718

* fix: recalculate terminal layout after web fonts load

xterm.js measures character widths at open() time. If custom fonts
haven't loaded yet, measurements use the fallback font and spacing
becomes incorrect. Now refresh and re-fit the terminal once
document.fonts.ready resolves.

Closes #710

* fix: improve terminal cwd detection and initial directory command

Remove '&& pwd' from initial directory command — the shell prompt
shows the new directory naturally. Fixes PowerShell 5.1 which doesn't
support '&&' as a statement separator.

Prepend Ctrl+U to get_cwd command to clear any pending input before
injecting the cwd probe, reducing interference with foreground programs.

Closes #713, #714

* fix: decode base64 file content as UTF-8 in file manager

Replace bare atob() with TextDecoder('utf-8') for base64 content
decoding. atob() only handles Latin-1, so multi-byte UTF-8 characters
like 'é' were decoded as 'é'.

Closes #719

* fix: normalize lazy import default exports for iOS compatibility

Wrap all lazy() imports with explicit .then(m => ({ default: m.default }))
to ensure consistent module resolution across platforms. iOS Safari/WebView
may handle bare lazy(() => import(...)) differently, returning the module
object instead of extracting the default export.

Closes #721

* fix: prevent RDP display from snapping back after container resize

Remove immediate rescaleDisplay() from ResizeObserver callback. The
display.onresize event already triggers rescaling when the RDP server
responds with the new resolution. Calling rescaleDisplay before the
server responds uses stale display dimensions, causing the bottom of
the screen to be truncated.

Closes #725

* fix: add portal Desktop DBus permission for Flatpak URL opening

Flatpak sandbox blocks window.open() without the portal permission,
causing terminal link clicks to open about:blank. Add talk-name for
org.freedesktop.portal.Desktop to enable xdg-desktop-portal URL
handling.

Closes #704

* chore: remove unused code and fix PR checks (#851)

* chore: remove unused frontend code

* chore: prune unused theme exports

* ci: fix pr check failures

* chore: reduce lint warnings

* feat(oidc): expose admin_group via OIDC_ADMIN_GROUP env var (#828)

The admin-group OIDC sync added in 2.3.0 (#782) reads `config.admin_group`
to sync the user's admin flag from OIDC group membership on each login.
That field is only populated when the OIDC config is stored in the
in-app DB — `getOIDCConfigFromEnv()` does not expose it, so deployments
using the env-var config path (declarative IaC: Helm/Compose/Puppet)
cannot enable the feature without abandoning env vars and pasting the
client_secret into the admin UI.

Add `admin_group: process.env.OIDC_ADMIN_GROUP || ""` to the env-config
return type and object. Backward compatible: when unset, the existing
`if (config.admin_group)` guard at users.ts:1336 keeps the sync block
skipped, matching today's behavior.

* chore: reduce explicit-any warnings

* chore: reduce more explicit-any warnings

* chore: reduce lint warnings

* chore: silence intentional hook dependency warnings

* chore: clean dependency tooling

* chore: narrow frontend tsconfig scope

* chore: reduce type assertion debt

* refactor: split host manager components

* refactor: split host editor sections

* refactor: split api client modules

* refactor: split more api clients

* refactor: split user settings api clients

* refactor: split tab and history api clients

* refactor: split tunnel api clients

* refactor: split server stats api client

* refactor: split file manager data api

* refactor: split ssh file operations api

* refactor: split host editor general tab

* refactor: split host editor guacamole tabs

* refactor: split ssh host management api

* refactor: split admin general settings sections

* refactor: split admin database section

* refactor: split admin management sections

* refactor: split admin keys and dialogs

* refactor: split system status api clients

* refactor: split user route helpers

* refactor: split host route helpers

* refactor: split file manager ssh helpers

* refactor: split file manager session helpers

* refactor: split file manager listing routes

* refactor: split host opkssh routes

* refactor: split file manager content routes

* refactor: split user api key routes

* refactor: split host folder routes

* refactor: split user settings routes

* refactor: split user totp routes

* refactor: split host file manager bookmark routes

* refactor: split file manager operation routes

* refactor: split server stats settings routes

* refactor: split user session routes

* refactor: split host command history routes

* refactor: split server stats viewer routes

* refactor: split docker container routes

* refactor: split user oidc account routes

* refactor: split host autostart routes

* refactor: split host internal routes

* refactor: split host network routes

* refactor: split user password reset routes

* refactor: split user admin routes

* refactor: split user data access routes

* refactor: split credential key routes

* refactor: split credential deploy routes

* refactor: split host bulk routes

* refactor: split server stats connection helpers

* refactor: split tunnel helpers

* refactor: split file manager action routes

* refactor: split terminal auth helpers

* refactor: split terminal jump host helpers

* refactor: split tunnel relay helpers

* refactor: split tunnel socks relay helpers

* refactor: split tunnel c2s relay handlers

* refactor: split server stats session helpers

* refactor: split terminal presentation helpers

* refactor: split file manager presentation helpers

* refactor: split file manager toolbar

* fix(guacamole-lite): send name instruction for protocol >= 1.3.0

The Guacamole protocol added the `name` handshake instruction in 1.3.0
(an optional human-readable identifier for the joining user). guacd 1.6.0
began requiring it during the VNC handshake even when negotiating older
protocol versions, causing connections to silently drop right after the
"User joined" log line with no client-visible error.

This patch extends scripts/patch-guacamole-lite.cjs with a third
idempotent string-replacement that injects the `name` instruction send
when guacamole-lite has negotiated protocol VERSION_1_3_0 or VERSION_1_5_0.

Verified end-to-end: guacd debug logs now show `Processing instruction:
name` and `Client is using protocol version "VERSION_1_5_0"` (previously
stuck at VERSION_1_1_0). VNC session connects successfully against
guacd 1.5.5 / macOS Tahoe target.

Related: Termix-SSH/Support#567, #734

* fix: resolve recent support bugs

* fix(admin): wire up OIDC-to-password link dialog submit + visibility

The admin user-management UI already shipped a link icon and a "Link
Account" dialog, but two things blocked the flow:

1. The submit button had no onClick handler and the username input was
   uncontrolled (no value/onChange). Clicking "Link Accounts" was a
   no-op — no network request, no console error, no toast.
2. The link icon's visibility condition was `user.isOidc &&
   !user.passwordHash`, which hid the button on OIDC users that had
   been auto-provisioned with a passwordHash. Termix's OIDC provisioning
   sets a passwordHash by default, so the button was hidden on virtually
   every OIDC-provisioned user.

This change:
- Adds `linkOIDCToPasswordAccount` to the imports from `@/main-axios`.
- Adds two pieces of dialog state: `linkAccountTargetUsername` and
  `linkAccountSubmitting`.
- Makes the dialog's Input field a controlled component.
- Wires the submit Button's onClick to call `linkOIDCToPasswordAccount`,
  emit success/error toasts, refresh the local user list, and close
  the dialog.
- Loosens the visibility condition to `user.isOidc` (the backend
  handler already enforces all integrity checks).
- Adds `linkAccountSuccess`, `linkAccountFailed`, and
  `linkAccountInProgress` translation keys to `en.json`.

Verified locally: full Docker build via docker/Dockerfile passes;
`tsc --noEmit` is clean; `prettier --check .` is clean; ESLint produces
the same warning count as upstream (16 pre-existing `any`-type warnings,
0 errors).

* fix: support native oidc callbacks (#856)

* docs: add cloudflare tunnel guidance (#857)

* fix: sync appearance preferences (#858)

* fix: pass through terminal tab completion (#859)

* fix: resolve terminal jump hosts server-side (#860)

* fix(electron): auto-allow SSL certificates for private network hosts (#861)

Add private network IP detection (RFC 1918, link-local, loopback, IPv6
ULA) to the Electron certificate-error handler so that connections to
local/private servers like 192.168.x.x bypass SSL validation
automatically. Also add an explicit "Allow invalid certificate" toggle
in the server config UI for public HTTPS servers with self-signed certs.

* fix: restore host password copy actions (#862)

* feat: support single-host direct tunnels (ssh -L style) (#863)

Add direct tunnel mode that uses a single SSH host for port forwarding,
matching the behavior of ssh -L / ssh -R / ssh -D without requiring a
second endpoint host in the Termix database. The Termix server creates a
local TCP listener and forwards through the SSH channel directly.

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* fix: backend build errors (Type)

* fix: mobile auth failing to login with webview

* fix: mobile app geting incorrectly sent auth token

* feat: commit existing frontend/backend e2e/unit tests (skipped tests containing private info like OIDC and real server testing)

* feat: host-to-host file transfer via server relay

* feat: removed host management from command palette, fixed command palette opening wrong protocol, export/import failing for ssh key hosts, docker ssh2 native crypto not compiled, persisted terminal tabs attempt SSh on RDP hosts after migration, improved layout for click to expand hosts, show ip/username without having to hover over hosts

* fix: credentials not indexing into host manager until refresh

* feat: update credentials lists to match hosts list UI/UX

* feat: add rename folder UI

* feat: improve transfer to host UI/UX

* chore: increment ver

* feat: improve transfer to host UI

* feat: implement initial auto release system

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: nicodarge <43711429+nicodarge@users.noreply.github.com>
Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com>
Co-authored-by: luc <luc_cook@hotmail.co.uk>
This commit is contained in:
Luke Gustafson
2026-06-04 15:16:53 -04:00
committed by GitHub
parent da79b01db4
commit 52f4e51ae0
223 changed files with 40358 additions and 26189 deletions
+43 -14
View File
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
/* eslint-disable react-hooks/exhaustive-deps */
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Separator } from "@/components/separator";
@@ -29,8 +31,11 @@ import type {
Host,
SplitMode,
HostFolder,
ThemeId,
FontSizeId,
} from "@/types/ui-types";
import { PANE_COUNTS } from "@/lib/theme";
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
import { useTheme } from "@/components/theme-provider";
import {
getSSHHosts,
getUserInfo,
@@ -46,6 +51,7 @@ import {
import { dbHealthMonitor } from "@/lib/db-health-monitor";
import type { SSHHostWithStatus } from "@/main-axios";
import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel";
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
function sshHostToHost(h: SSHHostWithStatus): Host {
return {
@@ -140,7 +146,8 @@ export function AppShell({
username: string;
onLogout: () => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { setTheme } = useTheme();
const [tabs, setTabs] = useState<Tab[]>([
{
id: "dashboard",
@@ -362,7 +369,19 @@ export function AppShell({
useEffect(() => {
getUserPreferences()
.then((prefs) => setUserPrefs(prefs))
.then((prefs) => {
setUserPrefs(prefs);
if (prefs.theme) setTheme(prefs.theme as ThemeId);
if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId);
if (prefs.accentColor) {
localStorage.setItem("termix-accent", prefs.accentColor);
applyAccentColor(prefs.accentColor);
}
if (prefs.language && prefs.language !== i18n.language) {
localStorage.setItem("i18nextLng", prefs.language);
void i18n.changeLanguage(prefs.language);
}
})
.catch(() => {})
.finally(() => setUserPrefsLoaded(true));
}, []);
@@ -464,6 +483,13 @@ export function AppShell({
if (!host && !hostlessTypes.includes(saved.tabType as TabType))
continue;
if (host) {
if (saved.tabType === "terminal" && !host.enableSsh) continue;
if (saved.tabType === "rdp" && !host.enableRdp) continue;
if (saved.tabType === "vnc" && !host.enableVnc) continue;
if (saved.tabType === "telnet" && !host.enableTelnet) continue;
}
// Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID
const tabId = host
? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}`
@@ -509,7 +535,6 @@ export function AppShell({
}
loadSavedTabs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostsLoaded, userPrefsLoaded]);
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
@@ -844,7 +869,7 @@ export function AppShell({
const id = requestAnimationFrame(() => {
tabs.forEach((tab) => {
if (!tab.terminalRef) return;
const ref = tab.terminalRef.current as any;
const ref = tab.terminalRef.current;
ref?.fit?.();
ref?.notifyResize?.();
});
@@ -896,7 +921,7 @@ export function AppShell({
node.style.display = "";
node.style.visibility = activeInline ? "visible" : "hidden";
node.style.pointerEvents = activeInline ? "auto" : "none";
node.style.zIndex = activeInline && !isSplit ? "1" : "0";
node.style.zIndex = activeInline ? "1" : "0";
} else {
node.style.visibility = "";
node.style.pointerEvents = "";
@@ -907,7 +932,6 @@ export function AppShell({
}
});
const activeTab = tabs.find((t) => t.id === activeTabId)!;
const terminalTabs = tabs.filter((t) => t.type === "terminal");
// Sidebar panel content — shared between desktop inline sidebar and mobile sheet
@@ -1086,10 +1110,6 @@ export function AppShell({
railView={railView}
sidebarOpen={sidebarOpen}
splitMode={splitMode}
connectionCount={
tabs.filter((t) => PERSISTENT_TAB_TYPES.includes(t.type)).length +
backgroundTabRecords.length
}
username={username}
isAdmin={isAdmin}
profileDropdownOpen={profileDropdownOpen}
@@ -1189,12 +1209,20 @@ export function AppShell({
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
by the DOM-placement effect above. React portals each tab's content
into its stable per-tab node so the component is never remounted.
Hidden when split is active — pane-assigned nodes escape via vanilla DOM
appendChild to paneEl, so hiding this doesn't affect them. */}
When split is active, shown on top only if the active tab is not in a pane. */}
<div
ref={normalViewRef}
className="absolute inset-0"
style={{ display: isSplit && !isMobile ? "none" : undefined }}
style={{
display:
isSplit && !isMobile && paneTabIds.includes(activeTabId)
? "none"
: undefined,
zIndex:
isSplit && !paneTabIds.includes(activeTabId)
? 10
: undefined,
}}
>
{tabs.map((tab) => {
const tabNode = getTabNode(tab.id, tab.type === "terminal");
@@ -1247,6 +1275,7 @@ export function AppShell({
}
}}
/>
<TransferMonitor />
</>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { authApi, handleApiError } from "@/main-axios";
// COMMAND HISTORY API
// ============================================================================
export async function saveCommandToHistory(
hostId: number,
command: string,
): Promise<{ id: number; command: string; executedAt: string }> {
try {
const response = await authApi.post("/terminal/command_history", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save command to history");
}
}
export async function getCommandHistory(
hostId: number,
limit: number = 100,
): Promise<string[]> {
try {
const response = await authApi.get(`/terminal/command_history/${hostId}`, {
params: { limit },
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch command history");
}
}
export async function deleteCommandFromHistory(
hostId: number,
command: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/terminal/command_history/delete", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete command from history");
}
}
export async function clearCommandHistory(
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/terminal/command_history/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "clear command history");
}
}
// ============================================================================
+365
View File
@@ -0,0 +1,365 @@
import { authApi, handleApiError, sshHostApi } from "@/main-axios";
import type { SSHFolder } from "@/types/index";
import { sshLogger } from "@/lib/frontend-logger";
export async function getCredentials(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/credentials");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch credentials");
}
}
export async function getCredentialDetails(
credentialId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get(`/credentials/${credentialId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch credential details");
}
}
export async function createCredential(
credentialData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/credentials", credentialData);
return response.data;
} catch (error) {
throw handleApiError(error, "create credential");
}
}
export async function updateCredential(
credentialId: number,
credentialData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(
`/credentials/${credentialId}`,
credentialData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update credential");
}
}
export async function deleteCredential(
credentialId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/credentials/${credentialId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete credential");
}
}
export async function getCredentialHosts(
credentialId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get(`/credentials/${credentialId}/hosts`);
return response.data;
} catch (error) {
handleApiError(error, "fetch credential hosts");
}
}
export async function getCredentialFolders(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/credentials/folders");
return response.data;
} catch (error) {
handleApiError(error, "fetch credential folders");
}
}
export async function getSSHHostWithCredentials(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.get(
`/db/host/${hostId}/with-credentials`,
);
return response.data;
} catch (error) {
handleApiError(error, "fetch SSH host with credentials");
}
}
export async function getHostPassword(
hostId: number,
field: "password" | "sudoPassword" = "password",
): Promise<string | null> {
try {
const response = await sshHostApi.get(
`/db/host/${hostId}/password?field=${field}`,
);
return response.data?.value || null;
} catch {
return null;
}
}
export async function applyCredentialToHost(
hostId: number,
credentialId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post(
`/db/host/${hostId}/apply-credential`,
{ credentialId },
);
return response.data;
} catch (error) {
throw handleApiError(error, "apply credential to host");
}
}
export async function removeCredentialFromHost(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
return response.data;
} catch (error) {
throw handleApiError(error, "remove credential from host");
}
}
export async function migrateHostToCredential(
hostId: number,
credentialName: string,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post(
`/db/host/${hostId}/migrate-to-credential`,
{ credentialName },
);
return response.data;
} catch (error) {
throw handleApiError(error, "migrate host to credential");
}
}
export async function getFoldersWithStats(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/host/db/folders/with-stats");
return response.data;
} catch (error) {
handleApiError(error, "fetch folders with statistics");
}
}
export async function renameFolder(
oldName: string,
newName: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put("/host/folders/rename", {
oldName,
newName,
});
return response.data;
} catch (error) {
handleApiError(error, "rename folder");
}
}
export async function getSSHFolders(): Promise<SSHFolder[]> {
try {
sshLogger.info("Fetching SSH folders", {
operation: "fetch_ssh_folders",
});
const response = await authApi.get("/host/folders");
sshLogger.success("SSH folders fetched successfully", {
operation: "fetch_ssh_folders",
count: response.data.length,
});
return response.data;
} catch (error) {
sshLogger.error("Failed to fetch SSH folders", error, {
operation: "fetch_ssh_folders",
});
handleApiError(error, "fetch SSH folders");
throw error;
}
}
export async function updateFolderMetadata(
name: string,
color?: string,
icon?: string,
): Promise<void> {
try {
sshLogger.info("Updating folder metadata", {
operation: "update_folder_metadata",
name,
color,
icon,
});
await authApi.put("/host/folders/metadata", {
name,
color,
icon,
});
sshLogger.success("Folder metadata updated successfully", {
operation: "update_folder_metadata",
name,
});
} catch (error) {
sshLogger.error("Failed to update folder metadata", error, {
operation: "update_folder_metadata",
name,
});
handleApiError(error, "update folder metadata");
throw error;
}
}
export async function deleteAllHostsInFolder(
folderName: string,
): Promise<{ deletedCount: number }> {
try {
sshLogger.info("Deleting all hosts in folder", {
operation: "delete_folder_hosts",
folderName,
});
const response = await authApi.delete(
`/host/folders/${encodeURIComponent(folderName)}/hosts`,
);
sshLogger.success("All hosts in folder deleted successfully", {
operation: "delete_folder_hosts",
folderName,
deletedCount: response.data.deletedCount,
});
return response.data;
} catch (error) {
sshLogger.error("Failed to delete hosts in folder", error, {
operation: "delete_folder_hosts",
folderName,
});
handleApiError(error, "delete hosts in folder");
throw error;
}
}
export async function renameCredentialFolder(
oldName: string,
newName: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put("/credentials/folders/rename", {
oldName,
newName,
});
return response.data;
} catch (error) {
throw handleApiError(error, "rename credential folder");
}
}
export async function detectKeyType(
privateKey: string,
keyPassword?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/credentials/detect-key-type", {
privateKey,
keyPassword,
});
return response.data;
} catch (error) {
throw handleApiError(error, "detect key type");
}
}
export async function detectPublicKeyType(
publicKey: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/credentials/detect-public-key-type", {
publicKey,
});
return response.data;
} catch (error) {
throw handleApiError(error, "detect public key type");
}
}
export async function validateKeyPair(
privateKey: string,
publicKey: string,
keyPassword?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/credentials/validate-key-pair", {
privateKey,
publicKey,
keyPassword,
});
return response.data;
} catch (error) {
throw handleApiError(error, "validate key pair");
}
}
export async function generatePublicKeyFromPrivate(
privateKey: string,
keyPassword?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/credentials/generate-public-key", {
privateKey,
keyPassword,
});
return response.data;
} catch (error) {
throw handleApiError(error, "generate public key from private key");
}
}
export async function generateKeyPair(
keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256",
keySize?: number,
passphrase?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/credentials/generate-key-pair", {
keyType,
keySize,
passphrase,
});
return response.data;
} catch (error) {
throw handleApiError(error, "generate SSH key pair");
}
}
export async function deployCredentialToHost(
credentialId: number,
targetHostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post(
`/credentials/${credentialId}/deploy-to-host`,
{ targetHostId },
);
return response.data;
} catch (error) {
throw handleApiError(error, "deploy credential to host");
}
}
+83
View File
@@ -0,0 +1,83 @@
import { dashboardApi, handleApiError } from "@/main-axios";
// DASHBOARD API
// ============================================================================
export interface UptimeInfo {
uptimeMs: number;
uptimeSeconds: number;
formatted: string;
}
export interface RecentActivityItem {
id: number;
userId: string;
type:
| "terminal"
| "file_manager"
| "server_stats"
| "tunnel"
| "docker"
| "telnet"
| "vnc"
| "rdp";
hostId: number;
hostName: string;
timestamp: string;
}
export async function getUptime(): Promise<UptimeInfo> {
try {
const response = await dashboardApi.get("/uptime");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch uptime");
}
}
export async function getRecentActivity(
limit?: number,
): Promise<RecentActivityItem[]> {
try {
const response = await dashboardApi.get("/activity/recent", {
params: { limit },
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch recent activity");
}
}
export async function logActivity(
type:
| "terminal"
| "file_manager"
| "server_stats"
| "tunnel"
| "docker"
| "rdp"
| "vnc"
| "telnet",
hostId: number,
hostName: string,
): Promise<{ message: string; id: number | string }> {
try {
const response = await dashboardApi.post("/activity/log", {
type,
hostId,
hostName,
});
return response.data;
} catch (error) {
throw handleApiError(error, "log activity");
}
}
export async function resetRecentActivity(): Promise<{ message: string }> {
try {
const response = await dashboardApi.delete("/activity/reset");
return response.data;
} catch (error) {
throw handleApiError(error, "reset recent activity");
}
}
+26
View File
@@ -0,0 +1,26 @@
import { dashboardApi } from "@/main-axios";
export interface DashboardLayout {
cards: Array<{
id: string;
enabled: boolean;
order: number;
panel?: "main" | "side";
height?: number | null;
}>;
mainWidthPct?: number;
}
export async function getDashboardPreferences(): Promise<DashboardLayout> {
const response = await dashboardApi.get("/dashboard/preferences");
return response.data;
}
export async function saveDashboardPreferences(
layout: DashboardLayout,
): Promise<{ success: boolean }> {
const response = await dashboardApi.post("/dashboard/preferences", layout);
return response.data;
}
// ============================================================================
+345
View File
@@ -0,0 +1,345 @@
import axios from "axios";
import { dockerApi, handleApiError } from "@/main-axios";
import type {
DockerContainer,
DockerLogOptions,
DockerStats,
DockerValidation,
} from "@/types/index";
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
};
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string;
reason?: string;
};
export async function connectDockerSession(
sessionId: string,
hostId: number,
config?: {
userProvidedPassword?: string;
userProvidedSshKey?: string;
userProvidedKeyPassword?: string;
forceKeyboardInteractive?: boolean;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
},
): Promise<{
success?: boolean;
message?: string;
requires_totp?: boolean;
prompt?: string;
isPassword?: boolean;
status?: string;
reason?: string;
connectionLogs?: ApiConnectionLog[];
requires_warpgate?: boolean;
url?: string;
securityKey?: string;
}> {
try {
const response = await dockerApi.post("/ssh/connect", {
sessionId,
hostId,
...config,
});
return response.data;
} catch (error: unknown) {
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.status === "auth_required"
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_totp
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_warpgate
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.connectionLogs
) {
const data = error.response.data;
const errorWithLogs = new Error(
data.error || data.message || error.message,
);
Object.assign(errorWithLogs, {
connectionLogs: data.connectionLogs,
});
throw errorWithLogs;
}
throw handleApiError(error, "connect to Docker SSH session");
}
}
export async function verifyDockerTOTP(
sessionId: string,
totpCode: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker TOTP");
}
}
export async function verifyDockerWarpgate(
sessionId: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-warpgate", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker Warpgate");
}
}
export async function disconnectDockerSession(
sessionId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post("/ssh/disconnect", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "disconnect from Docker SSH session");
}
}
export async function keepaliveDockerSession(
sessionId: string,
): Promise<{ success: boolean }> {
try {
const response = await dockerApi.post("/ssh/keepalive", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "keepalive Docker SSH session");
}
}
export async function getDockerSessionStatus(
sessionId: string,
): Promise<{ success: boolean; connected: boolean }> {
try {
const response = await dockerApi.get("/ssh/status", {
params: { sessionId },
});
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker session status");
}
}
export async function validateDockerAvailability(
sessionId: string,
): Promise<DockerValidation> {
try {
const response = await dockerApi.get(`/validate/${sessionId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "validate Docker availability");
}
}
export async function listDockerContainers(
sessionId: string,
all: boolean = true,
): Promise<DockerContainer[]> {
try {
const response = await dockerApi.get(`/containers/${sessionId}`, {
params: { all },
});
return response.data;
} catch (error) {
throw handleApiError(error, "list Docker containers");
}
}
export async function getDockerContainerDetails(
sessionId: string,
containerId: string,
): Promise<DockerContainer> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker container details");
}
}
export async function startDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/start`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "start Docker container");
}
}
export async function stopDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/stop`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "stop Docker container");
}
}
export async function restartDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/restart`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "restart Docker container");
}
}
export async function pauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/pause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "pause Docker container");
}
}
export async function unpauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/unpause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "unpause Docker container");
}
}
export async function removeDockerContainer(
sessionId: string,
containerId: string,
force: boolean = false,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.delete(
`/containers/${sessionId}/${containerId}/remove`,
{
params: { force },
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove Docker container");
}
}
export async function getContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<{ logs: string }> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: options,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container logs");
}
}
export async function downloadContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<Blob> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: { ...options, download: true },
responseType: "blob",
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "download container logs");
}
}
export async function getContainerStats(
sessionId: string,
containerId: string,
): Promise<DockerStats> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/stats`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container stats");
}
}
+147
View File
@@ -0,0 +1,147 @@
import { authApi, handleApiError } from "@/main-axios";
// FILE MANAGER DATA
// ============================================================================
export async function getRecentFiles(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/host/file_manager/recent", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get recent files");
throw error;
}
}
export async function addRecentFile(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/file_manager/recent", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add recent file");
throw error;
}
}
export async function removeRecentFile(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/host/file_manager/recent", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove recent file");
throw error;
}
}
export async function getPinnedFiles(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/host/file_manager/pinned", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get pinned files");
throw error;
}
}
export async function addPinnedFile(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/file_manager/pinned", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add pinned file");
throw error;
}
}
export async function removePinnedFile(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/host/file_manager/pinned", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove pinned file");
throw error;
}
}
export async function getFolderShortcuts(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/host/file_manager/shortcuts", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get folder shortcuts");
throw error;
}
}
export async function addFolderShortcut(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/file_manager/shortcuts", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add folder shortcut");
throw error;
}
}
export async function removeFolderShortcut(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/host/file_manager/shortcuts", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove folder shortcut");
throw error;
}
}
// ============================================================================
+126
View File
@@ -0,0 +1,126 @@
import { handleApiError, sshHostApi } from "@/main-axios";
import type { FileManagerFile, FileManagerShortcut } from "@/types/index";
interface FileManagerOperation {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number;
}
// FILE MANAGER METADATA (Recent, Pinned, Shortcuts)
// ============================================================================
export async function getFileManagerRecent(
hostId: number,
): Promise<FileManagerFile[]> {
try {
const response = await sshHostApi.get(
`/file_manager/recent?hostId=${hostId}`,
);
return response.data || [];
} catch {
return [];
}
}
export async function addFileManagerRecent(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/file_manager/recent", file);
return response.data;
} catch (error) {
handleApiError(error, "add recent file");
}
}
export async function removeFileManagerRecent(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/file_manager/recent", {
data: file,
});
return response.data;
} catch (error) {
handleApiError(error, "remove recent file");
}
}
export async function getFileManagerPinned(
hostId: number,
): Promise<FileManagerFile[]> {
try {
const response = await sshHostApi.get(
`/file_manager/pinned?hostId=${hostId}`,
);
return response.data || [];
} catch {
return [];
}
}
export async function addFileManagerPinned(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/file_manager/pinned", file);
return response.data;
} catch (error) {
handleApiError(error, "add pinned file");
}
}
export async function removeFileManagerPinned(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/file_manager/pinned", {
data: file,
});
return response.data;
} catch (error) {
handleApiError(error, "remove pinned file");
}
}
export async function getFileManagerShortcuts(
hostId: number,
): Promise<FileManagerShortcut[]> {
try {
const response = await sshHostApi.get(
`/file_manager/shortcuts?hostId=${hostId}`,
);
return response.data || [];
} catch {
return [];
}
}
export async function addFileManagerShortcut(
shortcut: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/file_manager/shortcuts", shortcut);
return response.data;
} catch (error) {
handleApiError(error, "add shortcut");
}
}
export async function removeFileManagerShortcut(
shortcut: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/file_manager/shortcuts", {
data: shortcut,
});
return response.data;
} catch (error) {
handleApiError(error, "remove shortcut");
}
}
// ============================================================================
+228
View File
@@ -0,0 +1,228 @@
import { authApi, handleApiError } from "@/main-axios";
export interface GuacamoleTokenRequest {
protocol: "rdp" | "vnc" | "telnet";
hostname: string;
port?: number;
username?: string;
password?: string;
domain?: string;
security?: string;
ignoreCert?: boolean;
guacamoleConfig?: {
colorDepth?: number;
width?: number;
height?: number;
dpi?: number;
resizeMethod?: string;
forceLossless?: boolean;
disableAudio?: boolean;
enableAudioInput?: boolean;
enableWallpaper?: boolean;
enableTheming?: boolean;
enableFontSmoothing?: boolean;
enableFullWindowDrag?: boolean;
enableDesktopComposition?: boolean;
enableMenuAnimations?: boolean;
disableBitmapCaching?: boolean;
disableOffscreenCaching?: boolean;
disableGlyphCaching?: boolean;
disableGfx?: boolean;
enablePrinting?: boolean;
printerName?: string;
enableDrive?: boolean;
driveName?: string;
drivePath?: string;
createDrivePath?: boolean;
disableDownload?: boolean;
disableUpload?: boolean;
enableTouch?: boolean;
clientName?: string;
console?: boolean;
initialProgram?: string;
serverLayout?: string;
timezone?: string;
gatewayHostname?: string;
gatewayPort?: number;
gatewayUsername?: string;
gatewayPassword?: string;
gatewayDomain?: string;
remoteApp?: string;
remoteAppDir?: string;
remoteAppArgs?: string;
normalizeClipboard?: string;
disableCopy?: boolean;
disablePaste?: boolean;
cursor?: string;
swapRedBlue?: boolean;
readOnly?: boolean;
recordingPath?: string;
recordingName?: string;
createRecordingPath?: boolean;
recordingExcludeOutput?: boolean;
recordingExcludeMouse?: boolean;
recordingIncludeKeys?: boolean;
wolSendPacket?: boolean;
wolMacAddr?: string;
wolBroadcastAddr?: string;
wolUdpPort?: number;
wolWaitTime?: number;
};
}
export interface GuacamoleTokenResponse {
token: string;
}
type GuacamoleConfigSource = {
guacamoleConfig?: string | Record<string, unknown> | null;
};
export function getGuacamoleDpi(
source?: GuacamoleConfigSource,
): number | undefined {
const config = source?.guacamoleConfig;
if (!config) return undefined;
let dpi: unknown;
if (typeof config === "string") {
try {
dpi = JSON.parse(config).dpi;
} catch {
return undefined;
}
} else {
dpi = config.dpi;
}
const parsedDpi = typeof dpi === "string" ? Number(dpi) : dpi;
if (
typeof parsedDpi !== "number" ||
!Number.isFinite(parsedDpi) ||
parsedDpi <= 0
) {
return undefined;
}
return Math.trunc(parsedDpi);
}
function toGuacamoleParams(
config: GuacamoleTokenRequest["guacamoleConfig"],
): Record<string, unknown> {
if (!config) return {};
const params: Record<string, unknown> = {};
const mappings: Record<string, string> = {
colorDepth: "color-depth",
resizeMethod: "resize-method",
forceLossless: "force-lossless",
disableAudio: "disable-audio",
enableAudioInput: "enable-audio-input",
enableWallpaper: "enable-wallpaper",
enableTheming: "enable-theming",
enableFontSmoothing: "enable-font-smoothing",
enableFullWindowDrag: "enable-full-window-drag",
enableDesktopComposition: "enable-desktop-composition",
enableMenuAnimations: "enable-menu-animations",
disableBitmapCaching: "disable-bitmap-caching",
disableOffscreenCaching: "disable-offscreen-caching",
disableGlyphCaching: "disable-glyph-caching",
disableGfx: "disable-gfx",
enablePrinting: "enable-printing",
printerName: "printer-name",
enableDrive: "enable-drive",
driveName: "drive-name",
drivePath: "drive-path",
createDrivePath: "create-drive-path",
disableDownload: "disable-download",
disableUpload: "disable-upload",
enableTouch: "enable-touch",
clientName: "client-name",
initialProgram: "initial-program",
serverLayout: "server-layout",
gatewayHostname: "gateway-hostname",
gatewayPort: "gateway-port",
gatewayUsername: "gateway-username",
gatewayPassword: "gateway-password",
gatewayDomain: "gateway-domain",
remoteApp: "remote-app",
remoteAppDir: "remote-app-dir",
remoteAppArgs: "remote-app-args",
normalizeClipboard: "normalize-clipboard",
disableCopy: "disable-copy",
disablePaste: "disable-paste",
swapRedBlue: "swap-red-blue",
readOnly: "read-only",
recordingPath: "recording-path",
recordingName: "recording-name",
createRecordingPath: "create-recording-path",
recordingExcludeOutput: "recording-exclude-output",
recordingExcludeMouse: "recording-exclude-mouse",
recordingIncludeKeys: "recording-include-keys",
wolSendPacket: "wol-send-packet",
wolMacAddr: "wol-mac-addr",
wolBroadcastAddr: "wol-broadcast-addr",
wolUdpPort: "wol-udp-port",
wolWaitTime: "wol-wait-time",
};
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null && value !== "") {
const paramName = mappings[key] || key;
if (typeof value === "boolean") {
params[paramName] = value ? "true" : "false";
} else {
params[paramName] = value;
}
}
}
return params;
}
export async function getGuacamoleToken(
request: GuacamoleTokenRequest,
): Promise<GuacamoleTokenResponse> {
try {
const guacParams = toGuacamoleParams(request.guacamoleConfig);
const response = await authApi.post("/guacamole/token", {
type: request.protocol,
hostname: request.hostname,
port: request.port,
username: request.username,
password: request.password,
domain: request.domain,
security: request.security,
"ignore-cert": request.ignoreCert,
...guacParams,
});
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token");
}
}
export async function getGuacamoleTokenFromHost(
hostId: number,
protocol?: "rdp" | "vnc" | "telnet",
): Promise<GuacamoleTokenResponse> {
try {
const response = await authApi.post(
`/guacamole/connect-host/${hostId}`,
protocol ? { protocol } : {},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token from host");
}
}
export async function getGuacdStatus(): Promise<{
guacd: { status: string };
}> {
const response = await authApi.get("/guacamole/status");
return response.data;
}
+32
View File
@@ -0,0 +1,32 @@
import { authApi, handleApiError } from "@/main-axios";
// OIDC ACCOUNT LINKING
// ============================================================================
export async function linkOIDCToPasswordAccount(
oidcUserId: string,
targetUsername: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/link-oidc-to-password", {
oidcUserId,
targetUsername,
});
return response.data;
} catch (error) {
throw handleApiError(error, "link OIDC account to password account");
}
}
export async function unlinkOIDCFromPasswordAccount(
userId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/unlink-oidc-from-password", {
userId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "unlink OIDC from password account");
}
}
+97
View File
@@ -0,0 +1,97 @@
import { authApi } from "@/main-axios";
// OPEN TABS API
// ============================================================================
export interface OpenTabRecord {
id: string;
userId: string;
tabType: string;
hostId: number | null;
label: string;
tabOrder: number;
backendSessionId: string | null;
createdAt: string;
updatedAt: string;
}
export interface OpenTabSyncPayload {
id: string;
tabType: string;
hostId?: number | null;
label: string;
tabOrder: number;
backendSessionId?: string | null;
}
export interface OpenTabUpsertPayload {
id: string;
tabType: string;
hostId?: number | null;
label: string;
tabOrder: number;
backendSessionId?: string | null;
}
export interface ActiveSessionInfo {
sessionId: string;
hostId: number;
hostName: string;
tabInstanceId: string | null;
isConnected: boolean;
createdAt: number;
}
export async function getOpenTabs(): Promise<OpenTabRecord[]> {
const response = await authApi.get("/open-tabs");
return response.data;
}
export async function syncOpenTabs(tabs: OpenTabSyncPayload[]): Promise<void> {
await authApi.put("/open-tabs", { tabs });
}
export async function deleteOpenTab(instanceId: string): Promise<void> {
await authApi.delete(`/open-tabs/${instanceId}`);
}
export async function patchOpenTab(
instanceId: string,
updates: Partial<
Pick<OpenTabRecord, "label" | "tabOrder" | "backendSessionId">
>,
): Promise<void> {
await authApi.patch(`/open-tabs/${instanceId}`, updates);
}
export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
await authApi.post("/open-tabs", tab);
}
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
const response = await authApi.get("/open-tabs/active-sessions");
return response.data;
}
// ============================================================================
// USER PREFERENCES API
// ============================================================================
export interface UserPreferences {
reopenTabsOnLogin: boolean;
theme?: string | null;
fontSize?: string | null;
accentColor?: string | null;
language?: string | null;
}
export async function getUserPreferences(): Promise<UserPreferences> {
const response = await authApi.get("/user-preferences");
return response.data;
}
export async function saveUserPreferences(
prefs: Partial<UserPreferences>,
): Promise<void> {
await authApi.put("/user-preferences", prefs);
}
+204
View File
@@ -0,0 +1,204 @@
import { handleApiError, rbacApi } from "@/main-axios";
import type { AccessRecord, Role, UserRole } from "@/main-axios";
export async function getRoles(): Promise<{ roles: Role[] }> {
try {
const response = await rbacApi.get("/rbac/roles");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch roles");
}
}
export async function createRole(roleData: {
name: string;
displayName: string;
description?: string | null;
}): Promise<{ role: Role }> {
try {
const response = await rbacApi.post("/rbac/roles", roleData);
return response.data;
} catch (error) {
throw handleApiError(error, "create role");
}
}
export async function updateRole(
roleId: number,
roleData: {
displayName?: string;
description?: string | null;
},
): Promise<{ role: Role }> {
try {
const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
return response.data;
} catch (error) {
throw handleApiError(error, "update role");
}
}
export async function deleteRole(
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(`/rbac/roles/${roleId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete role");
}
}
export async function getUserRoles(
userId: string,
): Promise<{ roles: UserRole[] }> {
try {
const response = await rbacApi.get(`/rbac/users/${userId}/roles`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch user roles");
}
}
export async function assignRoleToUser(
userId: string,
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(`/rbac/users/${userId}/roles`, {
roleId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "assign role to user");
}
}
export async function removeRoleFromUser(
userId: string,
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(
`/rbac/users/${userId}/roles/${roleId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove role from user");
}
}
export async function shareHost(
hostId: number,
shareData: {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
permissionLevel: "view";
durationHours?: number;
},
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(
`/rbac/host/${hostId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share host");
}
}
export async function getHostAccess(
hostId: number,
): Promise<{ accessList: AccessRecord[] }> {
try {
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch host access");
}
}
export async function revokeHostAccess(
hostId: number,
accessId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(
`/rbac/host/${hostId}/access/${accessId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "revoke host access");
}
}
// ============================================================================
// SNIPPET SHARING
// ============================================================================
export async function shareSnippet(
snippetId: number,
shareData: {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
durationHours?: number;
},
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(
`/rbac/snippet/${snippetId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share snippet");
}
}
export async function getSnippetAccess(
snippetId: number,
): Promise<{ accessList: AccessRecord[] }> {
try {
const response = await rbacApi.get(`/rbac/snippet/${snippetId}/access`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippet access");
}
}
export async function revokeSnippetAccess(
snippetId: number,
accessId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(
`/rbac/snippet/${snippetId}/access/${accessId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "revoke snippet access");
}
}
export async function getSharedSnippets(): Promise<{
sharedSnippets: Array<{
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
permissionLevel: string;
expiresAt: string | null;
}>;
}> {
try {
const response = await rbacApi.get("/rbac/shared-snippets");
return response.data;
} catch (error) {
handleApiError(error, "fetch shared snippets");
}
}
+262
View File
@@ -0,0 +1,262 @@
import axios, { type AxiosRequestConfig } from "axios";
import { handleApiError, statsApi } from "@/main-axios";
import type { ServerMetrics, ServerStatus } from "@/main-axios";
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
};
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string;
reason?: string;
};
// SERVER STATISTICS
// ============================================================================
/**
* Progressive retry schedule for the background /status poll.
*
* Each entry describes one attempt's per-request timeout and the pause to
* observe before the next attempt. The pause on the last entry is `null`:
* after that final failure we surface the network error, which flows
* through the response interceptor + dbHealthMonitor (which decides
* between the degraded toast and the full-outage overlay based on whether
* any WebSocket is still alive).
*
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
* poll cadence, so the next tick acts as the next retry without overlap.
*/
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
timeoutMs: number;
pauseAfterMs: number | null;
}> = [
{ timeoutMs: 2000, pauseAfterMs: 3000 },
{ timeoutMs: 5000, pauseAfterMs: 5000 },
{ timeoutMs: 8000, pauseAfterMs: null },
];
function isTransientStatusError(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false;
if (error.response) {
// Definitive server response (even 5xx) is not something more retries
// will fix in a useful timeframe; bail out and report it normally.
return false;
}
const code = error.code;
if (!code) {
// No code + no response means classic network error (offline / DNS / TCP)
return true;
}
return (
code === "ECONNABORTED" ||
code === "ETIMEDOUT" ||
code === "ERR_NETWORK" ||
code === "ECONNREFUSED" ||
code === "ECONNRESET"
);
}
export async function getAllServerStatuses(): Promise<
Record<number, ServerStatus>
> {
let lastError: unknown = null;
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
try {
const response = await statsApi.get("/status", {
timeout: timeoutMs,
// Silence per-attempt interceptor logging & health-monitor side
// effects on all attempts except the final one, so background
// blips don't look like real outages.
__silentRetry: !isFinalAttempt,
} as AxiosRequestConfig & { __silentRetry?: boolean });
return response.data || {};
} catch (error) {
lastError = error;
if (!isTransientStatusError(error)) {
break;
}
if (pauseAfterMs === null) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
}
}
handleApiError(lastError, "fetch server statuses");
}
export async function getServerStatusById(id: number): Promise<ServerStatus> {
try {
const response = await statsApi.get(`/status/${id}`);
return response.data;
} catch (error) {
handleApiError(error, "fetch server status");
throw error;
}
}
export async function getServerMetricsById(
id: number,
): Promise<ServerMetrics | null> {
try {
const response = await statsApi.get(`/metrics/${id}`, {
// Treat 404 as an expected "no metrics yet / disabled" signal rather
// than an error so we don't spam warn logs on the client.
validateStatus: (status) => status === 200 || status === 404,
});
if (response.status === 404) {
return null;
}
return response.data;
} catch (error) {
// If a 404 still slips through (e.g. intercepted before reaching here),
// swallow it quietly; everything else still flows through handleApiError.
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
handleApiError(error, "fetch server metrics");
throw error;
}
}
export async function startMetricsPolling(hostId: number): Promise<{
success: boolean;
requires_totp?: boolean;
sessionId?: string;
prompt?: string;
viewerSessionId?: string;
connectionLogs?: ApiConnectionLog[];
}> {
try {
const response = await statsApi.post(`/metrics/start/${hostId}`);
return response.data;
} catch (error: unknown) {
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.connectionLogs
) {
const data = error.response.data;
const errorWithLogs = new Error(
data.error || data.message || error.message,
);
Object.assign(errorWithLogs, {
connectionLogs: data.connectionLogs,
});
throw errorWithLogs;
}
handleApiError(error, "start metrics polling");
throw error;
}
}
export async function stopMetricsPolling(
hostId: number,
viewerSessionId?: string,
): Promise<void> {
try {
await statsApi.post(`/metrics/stop/${hostId}`, { viewerSessionId });
} catch (error) {
handleApiError(error, "stop metrics polling");
throw error;
}
}
export async function sendMetricsHeartbeat(
viewerSessionId: string,
): Promise<void> {
try {
await statsApi.post("/metrics/heartbeat", { viewerSessionId });
} catch (error) {
handleApiError(error, "send metrics heartbeat");
throw error;
}
}
export async function registerMetricsViewer(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
skipped?: boolean;
reason?: string;
}> {
try {
const response = await statsApi.post("/metrics/register-viewer", {
hostId,
});
return response.data;
} catch (error) {
handleApiError(error, "register metrics viewer");
throw error;
}
}
export async function unregisterMetricsViewer(
hostId: number,
viewerSessionId: string,
): Promise<void> {
try {
await statsApi.post("/metrics/unregister-viewer", {
hostId,
viewerSessionId,
});
} catch (error) {
handleApiError(error, "unregister metrics viewer");
throw error;
}
}
export async function submitMetricsTOTP(
sessionId: string,
totpCode: string,
): Promise<{
success: boolean;
viewerSessionId?: string;
}> {
try {
const response = await statsApi.post("/metrics/connect-totp", {
sessionId,
totpCode,
});
return response.data;
} catch (error) {
handleApiError(error, "submit metrics TOTP");
throw error;
}
}
export async function refreshServerPolling(): Promise<void> {
try {
await statsApi.post("/refresh");
} catch (error) {
console.warn("Failed to refresh server polling:", error);
}
}
export async function notifyHostCreatedOrUpdated(
hostId: number,
): Promise<void> {
try {
await statsApi.post("/host-updated", { hostId });
} catch (error) {
console.warn("Failed to notify stats server of host update:", error);
}
}
// ============================================================================
+100
View File
@@ -0,0 +1,100 @@
import { authApi, handleApiError, statsApi } from "@/main-axios";
// GLOBAL MONITORING SETTINGS
// ============================================================================
export async function getGlobalMonitoringSettings(): Promise<{
statusCheckInterval: number;
metricsInterval: number;
}> {
try {
const response = await statsApi.get("/global-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch global monitoring settings");
}
}
export async function updateGlobalMonitoringSettings(settings: {
statusCheckInterval?: number;
metricsInterval?: number;
}): Promise<void> {
try {
await statsApi.post("/global-settings", settings);
} catch (error) {
handleApiError(error, "update global monitoring settings");
}
}
// ============================================================================
// LOG LEVEL SETTINGS
// ============================================================================
export async function getLogLevel(): Promise<{ level: string }> {
try {
const response = await authApi.get("/users/log-level");
return response.data;
} catch (error) {
handleApiError(error, "fetch log level");
}
}
export async function updateLogLevel(level: string): Promise<void> {
try {
await authApi.patch("/users/log-level", { level });
} catch (error) {
handleApiError(error, "update log level");
}
}
// ============================================================================
// SESSION TIMEOUT SETTINGS
// ============================================================================
export async function getSessionTimeout(): Promise<{ timeoutHours: number }> {
try {
const response = await authApi.get("/users/session-timeout");
return response.data;
} catch (error) {
handleApiError(error, "fetch session timeout");
}
}
export async function updateSessionTimeout(
timeoutHours: number,
): Promise<void> {
try {
await authApi.patch("/users/session-timeout", { timeoutHours });
} catch (error) {
handleApiError(error, "update session timeout");
}
}
// ============================================================================
// GUACAMOLE SETTINGS
// ============================================================================
export async function getGuacamoleSettings(): Promise<{
enabled: boolean;
url: string;
}> {
try {
const response = await authApi.get("/users/guacamole-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch guacamole settings");
}
}
export async function updateGuacamoleSettings(settings: {
enabled?: boolean;
url?: string;
}): Promise<void> {
try {
await authApi.patch("/users/guacamole-settings", settings);
} catch (error) {
handleApiError(error, "update guacamole settings");
}
}
// ============================================================================
+183
View File
@@ -0,0 +1,183 @@
import { authApi, handleApiError } from "@/main-axios";
export interface NetworkTopologyNode {
data: {
id: string;
label?: string;
ip?: string;
status?: string;
tags?: string[];
parent?: string;
color?: string;
};
position?: { x: number; y: number };
}
export interface NetworkTopologyEdge {
data: {
id?: string;
source: string;
target: string;
};
}
export interface NetworkTopologyData {
nodes: NetworkTopologyNode[];
edges: NetworkTopologyEdge[];
}
export async function getSnippets(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/snippets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippets");
}
}
export async function createSnippet(
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/snippets", snippetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create snippet");
}
}
export async function updateSnippet(
snippetId: number,
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(`/snippets/${snippetId}`, snippetData);
return response.data;
} catch (error) {
throw handleApiError(error, "update snippet");
}
}
export async function deleteSnippet(
snippetId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/snippets/${snippetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete snippet");
}
}
export async function executeSnippet(
snippetId: number,
hostId: number,
): Promise<{ success: boolean; output: string; error?: string }> {
try {
const response = await authApi.post("/snippets/execute", {
snippetId,
hostId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "execute snippet");
}
}
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
try {
const response = await authApi.get("/network-topology/");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch network topology");
}
}
export async function saveNetworkTopology(
topology: NetworkTopologyData,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/network-topology/", { topology });
return response.data;
} catch (error) {
throw handleApiError(error, "save network topology");
}
}
export async function getSnippetFolders(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/snippets/folders");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippet folders");
}
}
export async function createSnippetFolder(folderData: {
name: string;
color?: string;
icon?: string;
}): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/snippets/folders", folderData);
return response.data;
} catch (error) {
throw handleApiError(error, "create snippet folder");
}
}
export async function updateSnippetFolderMetadata(
folderName: string,
metadata: { color?: string; icon?: string },
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(
`/snippets/folders/${encodeURIComponent(folderName)}/metadata`,
metadata,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update snippet folder metadata");
}
}
export async function renameSnippetFolder(
oldName: string,
newName: string,
): Promise<{ success: boolean; oldName: string; newName: string }> {
try {
const response = await authApi.put("/snippets/folders/rename", {
oldName,
newName,
});
return response.data;
} catch (error) {
throw handleApiError(error, "rename snippet folder");
}
}
export async function deleteSnippetFolder(
folderName: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/snippets/folders/${encodeURIComponent(folderName)}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "delete snippet folder");
}
}
export async function reorderSnippets(
updates: Array<{ id: number; order: number; folder?: string }>,
): Promise<{ success: boolean }> {
try {
const response = await authApi.put("/snippets/reorder", {
snippets: updates,
});
return response.data;
} catch (error) {
throw handleApiError(error, "reorder snippets");
}
}
+794
View File
@@ -0,0 +1,794 @@
import axios from "axios";
import { authApi, fileManagerApi, handleApiError } from "@/main-axios";
import { fileLogger } from "@/lib/frontend-logger";
import type { SSHHost } from "@/types/index";
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
};
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string;
reason?: string;
};
// SSH FILE OPERATIONS
// ============================================================================
export async function connectSSH(
sessionId: string,
config: {
hostId?: number;
ip: string;
port: number;
username: string;
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
jumpHosts?: Array<{ hostId: number }>;
},
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/connect", {
sessionId,
...config,
});
return response.data;
} catch (error: unknown) {
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.connectionLogs
) {
const data = error.response.data;
const errorWithLogs = new Error(
data.error || data.message || error.message,
);
Object.assign(errorWithLogs, {
connectionLogs: data.connectionLogs,
});
if (data.requires_totp) {
Object.assign(errorWithLogs, {
requires_totp: true,
sessionId: data.sessionId,
prompt: data.prompt,
});
}
if (data.requires_warpgate) {
Object.assign(errorWithLogs, {
requires_warpgate: true,
sessionId: data.sessionId,
url: data.url,
securityKey: data.securityKey,
});
}
if (data.status === "auth_required") {
Object.assign(errorWithLogs, {
status: "auth_required",
reason: data.reason,
});
}
throw errorWithLogs;
}
handleApiError(error, "connect SSH");
}
}
export async function disconnectSSH(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/disconnect", {
sessionId,
});
return response.data;
} catch (error) {
handleApiError(error, "disconnect SSH");
}
}
export async function verifySSHTOTP(
sessionId: string,
totpCode: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
return response.data;
} catch (error) {
handleApiError(error, "verify SSH TOTP");
}
}
export async function verifySSHWarpgate(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/connect-warpgate", {
sessionId,
});
return response.data;
} catch (error) {
handleApiError(error, "verify SSH Warpgate");
}
}
/**
* @openapi
* /ssh/quick-connect:
* post:
* summary: Create a temporary SSH connection without saving to database
* description: Returns a temporary host configuration for immediate use
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - ip
* - port
* - username
* - authType
* properties:
* ip:
* type: string
* description: SSH server IP or hostname
* port:
* type: number
* description: SSH server port
* username:
* type: string
* description: SSH username
* authType:
* type: string
* enum: [password, key, credential]
* description: Authentication method
* password:
* type: string
* description: Password (required if authType is password)
* key:
* type: string
* description: SSH private key (required if authType is key)
* keyPassword:
* type: string
* description: SSH key password (optional)
* keyType:
* type: string
* description: SSH key type
* credentialId:
* type: number
* description: Credential ID (required if authType is credential)
* overrideCredentialUsername:
* type: boolean
* description: Use provided username instead of credential username
* responses:
* 200:
* description: Temporary host configuration created successfully
* content:
* application/json:
* schema:
* type: object
* description: SSHHost object
* 400:
* description: Invalid request data
* 401:
* description: Unauthorized
* 500:
* description: Server error
*/
export async function quickConnect(
data: Record<string, unknown>,
): Promise<SSHHost> {
try {
const response = await authApi.post("/host/quick-connect", data);
return response.data;
} catch (error) {
throw handleApiError(error, "quick connect");
}
}
export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {
try {
const response = await fileManagerApi.get("/ssh/status", {
params: { sessionId },
});
return response.data;
} catch (error) {
handleApiError(error, "get SSH status");
}
}
export async function keepSSHAlive(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/keepalive", {
sessionId,
});
return response.data;
} catch (error) {
handleApiError(error, "SSH keepalive");
}
}
export async function listSSHFiles(
sessionId: string,
path: string,
): Promise<{ files: unknown[]; path: string }> {
try {
const response = await fileManagerApi.get("/ssh/listFiles", {
params: { sessionId, path },
});
return response.data || { files: [], path };
} catch (error) {
handleApiError(error, "list SSH files");
return { files: [], path };
}
}
export async function identifySSHSymlink(
sessionId: string,
path: string,
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
try {
const response = await fileManagerApi.get("/ssh/identifySymlink", {
params: { sessionId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "identify SSH symlink");
}
}
export async function resolveSSHPath(
sessionId: string,
path: string,
): Promise<string> {
try {
const response = await fileManagerApi.get("/ssh/resolvePath", {
params: { sessionId, path },
});
return response.data?.resolvedPath || path;
} catch {
return path;
}
}
export async function readSSHFile(
sessionId: string,
path: string,
): Promise<{
content: string;
path: string;
encoding?: "base64" | "utf8";
}> {
try {
const response = await fileManagerApi.get("/ssh/readFile", {
params: { sessionId, path },
});
return response.data;
} catch (error: unknown) {
if (error.response?.status === 404) {
const customError = new Error("File not found");
(
customError as Error & { response?: unknown; isFileNotFound?: boolean }
).response = error.response;
(
customError as Error & { response?: unknown; isFileNotFound?: boolean }
).isFileNotFound = error.response.data?.fileNotFound || true;
throw customError;
}
handleApiError(error, "read SSH file");
}
}
export async function writeSSHFile(
sessionId: string,
path: string,
content: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/writeFile", {
sessionId,
path,
content,
hostId,
userId,
});
if (
response.data &&
(response.data.message === "File written successfully" ||
response.status === 200)
) {
return response.data;
} else {
throw new Error("File write operation did not return success status");
}
} catch (error) {
handleApiError(error, "write SSH file");
}
}
export async function uploadSSHFile(
sessionId: string,
path: string,
fileName: string,
content: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/uploadFile", {
sessionId,
path,
fileName,
content,
hostId,
userId,
});
return response.data;
} catch (error) {
handleApiError(error, "upload SSH file");
}
}
export async function downloadSSHFile(
sessionId: string,
filePath: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/downloadFile", {
sessionId,
path: filePath,
hostId,
userId,
});
return response.data;
} catch (error) {
handleApiError(error, "download SSH file");
}
}
export async function downloadSSHFileStream(
sessionId: string,
filePath: string,
): Promise<void> {
const response = await fileManagerApi.post(
"/ssh/downloadFileStream",
{ sessionId, path: filePath },
{ responseType: "blob" },
);
const blob = response.data as Blob;
const fileName = filePath.split("/").pop() || "download";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
export async function createSSHFile(
sessionId: string,
path: string,
fileName: string,
content: string = "",
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/createFile", {
sessionId,
path,
fileName,
content,
hostId,
userId,
});
return response.data;
} catch (error) {
handleApiError(error, "create SSH file");
}
}
export async function createSSHFolder(
sessionId: string,
path: string,
folderName: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/ssh/createFolder", {
sessionId,
path,
folderName,
hostId,
userId,
});
return response.data;
} catch (error) {
handleApiError(error, "create SSH folder");
}
}
export async function deleteSSHItem(
sessionId: string,
path: string,
isDirectory: boolean,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/ssh/deleteItem", {
data: {
sessionId,
path,
isDirectory,
hostId,
userId,
},
});
return response.data;
} catch (error) {
handleApiError(error, "delete SSH item");
}
}
export async function setSudoPassword(
sessionId: string,
password: string,
): Promise<void> {
try {
await fileManagerApi.post("/sudo-password", {
sessionId,
password,
});
} catch (error) {
handleApiError(error, "set sudo password");
}
}
export async function copySSHItem(
sessionId: string,
sourcePath: string,
targetDir: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post(
"/ssh/copyItem",
{
sessionId,
sourcePath,
targetDir,
hostId,
userId,
},
{
timeout: 60000,
},
);
return response.data;
} catch (error) {
handleApiError(error, "copy SSH item");
throw error;
}
}
export async function renameSSHItem(
sessionId: string,
oldPath: string,
newName: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.put("/ssh/renameItem", {
sessionId,
oldPath,
newName,
hostId,
userId,
});
return response.data;
} catch (error) {
handleApiError(error, "rename SSH item");
throw error;
}
}
export async function moveSSHItem(
sessionId: string,
oldPath: string,
newPath: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.put(
"/ssh/moveItem",
{
sessionId,
oldPath,
newPath,
hostId,
userId,
},
{
timeout: 60000,
},
);
return response.data;
} catch (error) {
handleApiError(error, "move SSH item");
throw error;
}
}
export async function changeSSHPermissions(
sessionId: string,
path: string,
permissions: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string }> {
try {
fileLogger.info("Changing SSH file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/changePermissions", {
sessionId,
path,
permissions,
hostId,
userId,
});
fileLogger.success("SSH file permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to change SSH file permissions", error, {
operation: "change_permissions",
sessionId,
path,
permissions,
});
handleApiError(error, "change SSH permissions");
throw error;
}
}
export async function extractSSHArchive(
sessionId: string,
archivePath: string,
extractPath?: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string; extractPath: string }> {
try {
fileLogger.info("Extracting archive", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/extractArchive", {
sessionId,
archivePath,
extractPath,
hostId,
userId,
});
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: response.data.extractPath,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to extract archive", error, {
operation: "extract_archive",
sessionId,
archivePath,
extractPath,
});
handleApiError(error, "extract archive");
throw error;
}
}
export async function compressSSHFiles(
sessionId: string,
paths: string[],
archiveName: string,
format?: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string; archivePath: string }> {
try {
fileLogger.info("Compressing files", {
operation: "compress_files",
sessionId,
paths,
archiveName,
format,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/compressFiles", {
sessionId,
paths,
archiveName,
format: format || "zip",
hostId,
userId,
});
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
sessionId,
paths,
archivePath: response.data.archivePath,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to compress files", error, {
operation: "compress_files",
sessionId,
paths,
archiveName,
format,
});
handleApiError(error, "compress files");
throw error;
}
}
// ============================================================================
export type HostConnectionState =
| "disconnected"
| "connecting"
| "ready"
| "auth_required"
| "error";
export interface EnsureSSHSessionResult {
state: HostConnectionState;
sessionId?: string;
error?: string;
}
export async function ensureSSHSessionForHost(
host: SSHHost,
): Promise<EnsureSSHSessionResult> {
const sessionId = host.id.toString();
try {
const status = await getSSHStatus(sessionId);
if (status?.connected) {
return { state: "ready", sessionId };
}
} catch {
// not connected — fall through to connect
}
try {
const result = await connectSSH(sessionId, {
hostId: host.id,
ip: host.ip,
port: host.port,
username: host.username,
password: host.password,
sshKey: host.key,
keyPassword: host.keyPassword,
authType: host.authType,
credentialId: host.credentialId,
userId: host.userId,
forceKeyboardInteractive: host.forceKeyboardInteractive,
jumpHosts: host.jumpHosts,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
});
if (
result?.requires_totp ||
result?.requires_warpgate ||
result?.status === "auth_required"
) {
return { state: "auth_required", sessionId };
}
return { state: "ready", sessionId };
} catch (err) {
const message = err instanceof Error ? err.message : "Connection failed";
return { state: "error", error: message };
}
}
export interface BrowseSSHDirectoryResult {
status: "ok" | "not_found" | "error";
path: string;
files: Array<{ name: string; type: "file" | "directory" | "link" }>;
}
export async function browseSSHDirectory(
sessionId: string,
path: string,
): Promise<BrowseSSHDirectoryResult> {
try {
const result = await listSSHFiles(sessionId, path);
return {
status: "ok",
path: result.path,
files: result.files as Array<{
name: string;
type: "file" | "directory" | "link";
}>,
};
} catch (err) {
const status =
(err as { response?: { status?: number } })?.response?.status === 404
? "not_found"
: "error";
return { status, path, files: [] };
}
}
+235
View File
@@ -0,0 +1,235 @@
import { AxiosError } from "axios";
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
// SSH HOST MANAGEMENT
// ============================================================================
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
try {
const hostsResponse = await sshHostApi.get("/db/host");
const hosts: SSHHost[] = Array.isArray(hostsResponse.data)
? hostsResponse.data
: [];
let statuses: Record<number, ServerStatus> = {};
try {
statuses = (await getAllServerStatuses()) || {};
} catch {
// Status fetch failure should not prevent host list from loading
}
return hosts.map((host) => ({
...host,
status: statuses[host.id]?.status || "unknown",
}));
} catch (error) {
throw handleApiError(error, "fetch SSH hosts");
}
}
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try {
if (hostData.authType === "key" && hostData.key instanceof File) {
const formData = new FormData();
formData.append("key", hostData.key);
const dataWithoutFile = { ...hostData, key: undefined };
formData.append("data", JSON.stringify(dataWithoutFile));
const response = await sshHostApi.post("/db/host", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
}
const response = await sshHostApi.post("/db/host", hostData);
return response.data;
} catch (error) {
throw handleApiError(error, "create SSH host");
}
}
export async function updateSSHHost(
hostId: number,
hostData: SSHHostData,
): Promise<SSHHost> {
try {
if (hostData.authType === "key" && hostData.key instanceof File) {
const formData = new FormData();
formData.append("key", hostData.key);
const dataWithoutFile = { ...hostData, key: undefined };
formData.append("data", JSON.stringify(dataWithoutFile));
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
}
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
return response.data;
} catch (error) {
throw handleApiError(error, "update SSH host");
}
}
export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> {
try {
const response = await sshHostApi.post(`/db/host/${hostId}/wake`);
return response.data;
} catch (error) {
throw handleApiError(error, "wake on LAN");
}
}
export async function bulkImportSSHHosts(
hosts: SSHHostData[],
overwrite = false,
): Promise<{
message: string;
success: number;
updated: number;
skipped: number;
failed: number;
errors: string[];
}> {
try {
const response = await sshHostApi.post("/bulk-import", {
hosts,
overwrite,
});
return response.data;
} catch (error) {
handleApiError(error, "bulk import SSH hosts");
}
}
export async function bulkUpdateSSHHosts(
hostIds: number[],
updates: Record<string, unknown>,
): Promise<{ updated: number; failed: number; errors: string[] }> {
try {
const response = await sshHostApi.patch("/bulk-update", {
hostIds,
updates,
});
return response.data;
} catch (error) {
handleApiError(error, "bulk update SSH hosts");
}
}
export async function deleteSSHHost(
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete(`/db/host/${hostId}`);
return response.data;
} catch (error) {
handleApiError(error, "delete SSH host");
}
}
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
try {
const response = await sshHostApi.get(`/db/host/${hostId}`);
return response.data;
} catch (error) {
handleApiError(error, "fetch SSH host");
}
}
export async function exportSSHHostWithCredentials(
hostId: number,
): Promise<SSHHost> {
try {
const response = await sshHostApi.get(`/db/host/${hostId}/export`);
return response.data;
} catch (error) {
handleApiError(error, "export SSH host with credentials");
}
}
export async function exportAllSSHHosts(): Promise<{
hosts: SSHHost[];
}> {
try {
const response = await sshHostApi.get("/db/hosts/export");
return response.data;
} catch (error) {
handleApiError(error, "export all SSH hosts");
}
}
// ============================================================================
// SSH AUTOSTART MANAGEMENT
// ============================================================================
export async function enableAutoStart(
sshConfigId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/autostart/enable", {
sshConfigId,
});
return response.data;
} catch (error) {
handleApiError(error, "enable autostart");
}
}
export async function disableAutoStart(
sshConfigId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/autostart/disable", {
data: { sshConfigId },
});
return response.data;
} catch (error) {
handleApiError(error, "disable autostart");
}
}
export async function getAutoStartStatus(): Promise<{
autostart_configs: Array<{
sshConfigId: number;
host: string;
port: number;
username: string;
authType: string;
}>;
total_count: number;
}> {
try {
const response = await sshHostApi.get("/autostart/status");
return response.data;
} catch (error) {
handleApiError(error, "fetch autostart status");
}
}
// ============================================================================
// PROXY CONNECTIVITY TEST
// ============================================================================
export async function testProxyConnection(options: {
singleProxy?: {
host: string;
port: number;
type?: 4 | 5 | "http";
username?: string;
password?: string;
};
proxyChain?: ProxyNode[];
testTarget?: { host: string; port: number };
}): Promise<{ success: boolean; latencyMs?: number; error?: string }> {
try {
const response = await sshHostApi.post("/db/proxy/test", options);
return response.data;
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.error) {
return { success: false, error: error.response.data.error };
}
handleApiError(error, "test proxy connection");
}
}
// ============================================================================
+177
View File
@@ -0,0 +1,177 @@
import { AxiosError } from "axios";
import {
authApi,
handleApiError,
isElectron,
markUserAuthenticated,
} from "@/main-axios";
import type { AuthResponse } from "@/main-axios";
// ALERTS
// ============================================================================
export async function setupTOTP(): Promise<{
secret: string;
qr_code: string;
}> {
try {
const response = await authApi.post("/users/totp/setup");
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "setup TOTP");
throw error;
}
}
export async function enableTOTP(
totp_code: string,
): Promise<{ message: string; backup_codes: string[] }> {
try {
const response = await authApi.post("/users/totp/enable", { totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "enable TOTP");
throw error;
}
}
export async function disableTOTP(
password?: string,
totp_code?: string,
): Promise<{ message: string }> {
try {
const response = await authApi.post("/users/totp/disable", {
password,
totp_code,
});
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "disable TOTP");
throw error;
}
}
export async function verifyTOTPLogin(
temp_token: string,
totp_code: string,
rememberMe: boolean = false,
): Promise<AuthResponse> {
try {
const response = await authApi.post("/users/totp/verify-login", {
temp_token,
totp_code,
rememberMe,
});
const isInIframe =
typeof window !== "undefined" && window.self !== window.top;
if (isInIframe && isElectron() && response.data.success) {
try {
window.parent.postMessage(
{
type: "AUTH_SUCCESS",
source: "totp_verify",
platform: "desktop",
timestamp: Date.now(),
},
window.location.origin,
);
} catch (e) {
console.error("[main-axios] Error posting message to parent:", e);
}
}
if (response.data.success) {
markUserAuthenticated();
}
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "verify TOTP login");
throw error;
}
}
export async function generateBackupCodes(
password?: string,
totp_code?: string,
): Promise<{ backup_codes: string[] }> {
try {
const response = await authApi.post("/users/totp/backup-codes", {
password,
totp_code,
});
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "generate backup codes");
throw error;
}
}
export async function getUserAlerts(): Promise<{
alerts: Array<Record<string, unknown>>;
}> {
try {
const response = await authApi.get(`/alerts`);
return response.data;
} catch (error) {
handleApiError(error, "fetch user alerts");
throw error;
}
}
export async function dismissAlert(
alertId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/alerts/dismiss", { alertId });
return response.data;
} catch (error) {
handleApiError(error, "dismiss alert");
throw error;
}
}
// ============================================================================
// UPDATES & RELEASES
// ============================================================================
export async function getReleasesRSS(
perPage: number = 100,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get(`/releases/rss?per_page=${perPage}`);
return response.data;
} catch (error) {
handleApiError(error, "fetch releases RSS");
}
}
export async function getVersionInfo(
checkRemote = true,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get(
`/version${checkRemote ? "" : "?checkRemote=false"}`,
);
return response.data;
} catch (error) {
handleApiError(error, "fetch version info");
}
}
// ============================================================================
// DATABASE HEALTH
// ============================================================================
export async function getDatabaseHealth(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/health");
return response.data;
} catch (error) {
handleApiError(error, "check database health");
}
}
// ============================================================================
+142
View File
@@ -0,0 +1,142 @@
import axios from "axios";
import { authApi, handleApiError, tunnelApi } from "@/main-axios";
import type {
C2STunnelPreset,
TunnelConfig,
TunnelConnection,
TunnelStatus,
} from "@/types/index";
// TUNNEL MANAGEMENT
// ============================================================================
export async function getTunnelStatuses(): Promise<
Record<string, TunnelStatus>
> {
try {
const response = await tunnelApi.get("/tunnel/status");
return response.data || {};
} catch (error) {
handleApiError(error, "fetch tunnel statuses");
}
}
export function subscribeTunnelStatuses(
onStatuses: (statuses: Record<string, TunnelStatus>) => void,
onError?: () => void,
): () => void {
const baseURL = (tunnelApi.defaults.baseURL || "").replace(/\/$/, "");
const source = new EventSource(`${baseURL}/tunnel/status/stream`, {
withCredentials: true,
});
source.addEventListener("statuses", (event) => {
try {
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>);
} catch {
onError?.();
}
});
source.onerror = () => {
onError?.();
};
return () => source.close();
}
export async function getTunnelStatusByName(
tunnelName: string,
): Promise<TunnelStatus | undefined> {
const statuses = await getTunnelStatuses();
return statuses[tunnelName];
}
export async function connectTunnel(
tunnelConfig: TunnelConfig,
): Promise<Record<string, unknown>> {
try {
const response = await tunnelApi.post("/tunnel/connect", tunnelConfig);
return response.data;
} catch (error) {
handleApiError(error, "connect tunnel");
}
}
export async function disconnectTunnel(
tunnelName: string,
): Promise<Record<string, unknown>> {
try {
const response = await tunnelApi.post("/tunnel/disconnect", { tunnelName });
return response.data;
} catch (error) {
handleApiError(error, "disconnect tunnel");
}
}
export async function cancelTunnel(
tunnelName: string,
): Promise<Record<string, unknown>> {
try {
const response = await tunnelApi.post("/tunnel/cancel", { tunnelName });
return response.data;
} catch (error) {
handleApiError(error, "cancel tunnel");
}
}
export async function getC2STunnelPresets(): Promise<C2STunnelPreset[]> {
try {
const response = await authApi.get("/c2s-tunnel-presets");
return response.data || [];
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return [];
}
handleApiError(error, "fetch client tunnel presets");
}
}
export async function createC2STunnelPreset(data: {
name: string;
config: TunnelConnection[];
platform?: string;
computerName?: string;
}): Promise<C2STunnelPreset> {
try {
const response = await authApi.post("/c2s-tunnel-presets", data);
return response.data;
} catch (error) {
handleApiError(error, "create client tunnel preset");
}
}
export async function updateC2STunnelPreset(
id: number,
data: Partial<{
name: string;
config: TunnelConnection[];
platform: string;
computerName: string;
}>,
): Promise<C2STunnelPreset> {
try {
const response = await authApi.put(`/c2s-tunnel-presets/${id}`, data);
return response.data;
} catch (error) {
handleApiError(error, "update client tunnel preset");
}
}
export async function deleteC2STunnelPreset(
id: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/c2s-tunnel-presets/${id}`);
return response.data;
} catch (error) {
handleApiError(error, "delete client tunnel preset");
}
}
// ============================================================================
+254
View File
@@ -0,0 +1,254 @@
import { authApi, handleApiError } from "@/main-axios";
import type { UserInfo } from "@/main-axios";
// USER MANAGEMENT
// ============================================================================
export async function getUserList(): Promise<{ users: UserInfo[] }> {
try {
const response = await authApi.get("/users/list");
return response.data;
} catch (error) {
handleApiError(error, "fetch user list");
}
}
export async function getSessions(): Promise<{
sessions: {
id: string;
userId: string;
username?: string;
deviceType: string;
deviceInfo: string;
createdAt: string;
expiresAt: string;
lastActiveAt: string;
isRevoked?: boolean;
isCurrentSession?: boolean;
}[];
}> {
try {
const response = await authApi.get("/users/sessions");
return response.data;
} catch (error) {
handleApiError(error, "fetch sessions");
}
}
export async function revokeSession(
sessionId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.delete(`/users/sessions/${sessionId}`);
return response.data;
} catch (error) {
handleApiError(error, "revoke session");
}
}
export async function revokeAllUserSessions(
userId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/sessions/revoke-all", {
targetUserId: userId,
exceptCurrent: false,
});
return response.data;
} catch (error) {
handleApiError(error, "revoke all user sessions");
}
}
export interface ApiKey {
id: string;
name: string;
userId: string;
username: string | null;
tokenPrefix: string;
createdAt: string;
expiresAt: string | null;
lastUsedAt: string | null;
isActive: boolean;
}
export interface CreatedApiKey extends ApiKey {
token: string;
}
export async function createApiKey(
name: string,
userId: string,
expiresAt?: string,
): Promise<CreatedApiKey> {
try {
const response = await authApi.post("/users/api-keys", {
name,
userId,
expiresAt: expiresAt ?? null,
});
return response.data;
} catch (error) {
handleApiError(error, "create API key");
}
}
export async function getApiKeys(): Promise<{ apiKeys: ApiKey[] }> {
try {
const response = await authApi.get("/users/api-keys");
return response.data;
} catch (error) {
handleApiError(error, "fetch API keys");
}
}
export async function deleteApiKey(
keyId: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/users/api-keys/${keyId}`);
return response.data;
} catch (error) {
handleApiError(error, "delete API key");
}
}
export async function makeUserAdmin(
userId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/users/make-admin", { userId });
return response.data;
} catch (error) {
handleApiError(error, "make user admin");
}
}
export async function removeAdminStatus(
userId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/users/remove-admin", { userId });
return response.data;
} catch (error) {
handleApiError(error, "remove admin status");
}
}
export async function deleteUser(
username: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/users/delete-user", {
data: { username },
});
return response.data;
} catch (error) {
handleApiError(error, "delete user");
}
}
export async function deleteAccount(
password: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/users/delete-account", {
data: { password },
});
return response.data;
} catch (error) {
handleApiError(error, "delete account");
}
}
export async function updateRegistrationAllowed(
allowed: boolean,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.patch("/users/registration-allowed", {
allowed,
});
return response.data;
} catch (error) {
handleApiError(error, "update registration allowed");
}
}
export async function getOidcAutoProvision(): Promise<{ enabled: boolean }> {
try {
const response = await authApi.get("/users/oidc-auto-provision");
return response.data;
} catch (error) {
handleApiError(error, "check OIDC auto-provision status");
}
}
export async function updateOidcAutoProvision(
enabled: boolean,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.patch("/users/oidc-auto-provision", {
enabled,
});
return response.data;
} catch (error) {
handleApiError(error, "update OIDC auto-provision");
}
}
export async function updatePasswordLoginAllowed(
allowed: boolean,
): Promise<{ allowed: boolean }> {
try {
const response = await authApi.patch("/users/password-login-allowed", {
allowed,
});
return response.data;
} catch (error) {
handleApiError(error, "update password login allowed");
}
}
export async function getPasswordResetAllowed(): Promise<boolean> {
try {
const response = await authApi.get("/users/password-reset-allowed");
return response.data.allowed;
} catch (error) {
handleApiError(error, "get password reset allowed");
}
}
export async function updatePasswordResetAllowed(
allowed: boolean,
): Promise<{ allowed: boolean }> {
try {
const response = await authApi.patch("/users/password-reset-allowed", {
allowed,
});
return response.data;
} catch (error) {
handleApiError(error, "update password reset allowed");
}
}
export async function updateOIDCConfig(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/users/oidc-config", config);
return response.data;
} catch (error) {
handleApiError(error, "update OIDC config");
}
}
export async function disableOIDCConfig(): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/users/oidc-config");
return response.data;
} catch (error) {
handleApiError(error, "disable OIDC config");
}
}
// ============================================================================
+56 -2
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -31,6 +32,7 @@ import {
saveServerConfig,
isElectron,
getEmbeddedServerStatus,
getCurrentToken,
} from "@/main-axios";
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
@@ -114,9 +116,15 @@ interface AuthProps {
interface ExtendedWindow extends Window {
IS_ELECTRON_WEBVIEW?: boolean;
ReactNativeWebView?: { postMessage: (msg: string) => void };
}
const isInMobileWebView = () =>
/Termix-Mobile\/(Android|iOS)/.test(navigator.userAgent) ||
!!(window as ExtendedWindow).ReactNativeWebView;
const isInElectronWebView = () => {
if (isInMobileWebView()) return false;
if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) return true;
try {
if (window.self !== window.top) return true;
@@ -253,7 +261,9 @@ export function Auth({ onLogin }: AuthProps) {
useEffect(() => {
try {
localStorage.setItem("rememberMe", rememberMe.toString());
} catch {}
} catch {
// Ignore storage failures; auth state still works for the current session.
}
}, [rememberMe]);
useEffect(() => {
@@ -339,6 +349,32 @@ export function Auth({ onLogin }: AuthProps) {
return;
}
if (success) {
if (isInMobileWebView()) {
// The OIDC callback authenticated via an HttpOnly cookie on this origin,
// so the token isn't in localStorage. Prefer a token passed in the URL
// (termix-mobile:-origin callbacks include one), otherwise read it back
// from the cookie via /users/me/token before handing it to the app.
const postToken = (token: string) => {
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
JSON.stringify({ type: "AUTH_SUCCESS", token }),
);
setWebviewAuthSuccess(true);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
};
const urlToken = urlParams.get("token");
if (urlToken) {
postToken(urlToken);
} else {
getCurrentToken()
.then((token) => postToken(token ?? ""))
.catch(() => postToken(""));
}
return;
}
if (isInElectronWebView()) {
window.parent.postMessage(
{
@@ -442,6 +478,15 @@ export function Auth({ onLogin }: AuthProps) {
return;
}
if (!res?.success) throw new Error(t("errors.loginFailed"));
if (isInMobileWebView()) {
// Native-app requests get the JWT in the login response body.
const token = res?.token ?? "";
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
JSON.stringify({ type: "AUTH_SUCCESS", token }),
);
setWebviewAuthSuccess(true);
return;
}
if (isInElectronWebView()) {
window.parent.postMessage(
{
@@ -534,6 +579,15 @@ export function Auth({ onLogin }: AuthProps) {
try {
const res = await verifyTOTPLogin(totpTempToken, totpCode, rememberMe);
if (!res?.success) throw new Error(t("errors.loginFailed"));
if (isInMobileWebView()) {
// Native-app requests get the JWT in the login response body.
const token = res?.token ?? "";
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
JSON.stringify({ type: "AUTH_SUCCESS", token }),
);
setWebviewAuthSuccess(true);
return;
}
if (isInElectronWebView()) {
window.parent.postMessage(
{
@@ -684,7 +738,7 @@ export function Auth({ onLogin }: AuthProps) {
callbackPort,
);
if (result.success && result.token) {
localStorage.setItem("jwt_token", result.token);
localStorage.setItem("jwt", result.token);
window.location.reload();
return;
}
+1 -1
View File
@@ -46,7 +46,7 @@ export function ElectronLoginForm({
localStorage.setItem("jwt", token);
}
await onAuthSuccessRef.current(token);
} catch (_err) {
} catch {
setError(t("errors.authTokenSaveFailed"));
isAuthenticatingRef.current = false;
setIsAuthenticating(false);
+24
View File
@@ -3,6 +3,7 @@ import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Label } from "@/components/label.tsx";
import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
import { Switch } from "@/components/switch.tsx";
import { useTranslation } from "react-i18next";
import {
getServerConfig,
@@ -28,6 +29,7 @@ export function ElectronServerConfig({
}: ServerConfigProps) {
const { t } = useTranslation();
const [serverUrl, setServerUrl] = useState("");
const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false);
const [loading, setLoading] = useState(false);
const [embeddedLoading, setEmbeddedLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,6 +48,7 @@ export function ElectronServerConfig({
if (config?.serverUrl) {
setServerUrl(config.serverUrl);
}
setAllowInvalidCertificate(!!config?.allowInvalidCertificate);
} catch (error) {
console.error("Server config operation failed:", error);
}
@@ -141,6 +144,8 @@ export function ElectronServerConfig({
const config: ServerConfig = {
serverUrl: normalizedUrl,
lastUpdated: new Date().toISOString(),
allowInvalidCertificate:
normalizedUrl.startsWith("https://") && allowInvalidCertificate,
};
const success = await saveServerConfig(config);
@@ -225,6 +230,25 @@ export function ElectronServerConfig({
/>
</div>
{serverUrl.trim().startsWith("https://") && (
<div className="flex items-start justify-between gap-3 border border-border bg-muted/20 p-3">
<div className="flex flex-col gap-1">
<Label htmlFor="allow-invalid-certificate">
{t("serverConfig.allowInvalidCertificate")}
</Label>
<p className="text-xs text-muted-foreground">
{t("serverConfig.allowInvalidCertificateDesc")}
</p>
</div>
<Switch
id="allow-invalid-certificate"
checked={allowInvalidCertificate}
onCheckedChange={setAllowInvalidCertificate}
disabled={loading || embeddedLoading}
/>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
+111 -70
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
@@ -28,6 +29,7 @@ import {
saveServerConfig,
isElectron,
getEmbeddedServerStatus,
getCurrentToken,
} from "@/main-axios";
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
@@ -36,21 +38,15 @@ import {
shouldTriggerSilentSignin,
} from "./silent-signin";
function isMissingServerConfigError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return (
(error as Error & { code?: string }).code === "NO_SERVER_CONFIGURED" ||
error.message.includes("no-server-configured")
);
}
interface ExtendedWindow extends Window {
IS_ELECTRON_WEBVIEW?: boolean;
ReactNativeWebView?: { postMessage: (msg: string) => void };
}
const isInMobileWebView = () =>
/Termix-Mobile\/(Android|iOS)/.test(navigator.userAgent) ||
!!(window as ExtendedWindow).ReactNativeWebView;
interface AuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void;
@@ -91,7 +87,8 @@ export function Auth({
window.matchMedia("(prefers-color-scheme: dark)").matches);
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
const isInElectronWebView = () => {
const isInElectronWebView = useCallback(() => {
if (isInMobileWebView()) return false;
if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) {
return true;
}
@@ -103,7 +100,7 @@ export function Auth({
return true;
}
return false;
};
}, []);
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">(
"login",
@@ -147,6 +144,15 @@ export function Auth({
const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
const totpInputRef = React.useRef<HTMLInputElement>(null);
// Hand the JWT to the native app embedding this page in a React Native WebView.
// The mobile onMessage handler only reads { type, token }.
const postMobileAuthSuccess = useCallback((token: string) => {
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
JSON.stringify({ type: "AUTH_SUCCESS", token }),
);
setWebviewAuthSuccess(true);
}, []);
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(
null,
);
@@ -154,54 +160,51 @@ export function Auth({
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
const [dbHealthChecking, setDbHealthChecking] = useState(false);
const handleElectronAuthSuccess = useCallback(
async (token: string | null) => {
try {
// token was stored in localStorage by ElectronLoginForm before this runs,
// so getUserInfo() can authenticate via the cookie interceptor or localStorage jwt.
let retries = 5;
let meRes = null;
while (retries-- > 0) {
try {
meRes = await getUserInfo();
break;
} catch (err: unknown) {
const isNoServer =
(err as { code?: string })?.code === "NO_SERVER_CONFIGURED" ||
(err as Error)?.message?.includes("no-server-configured");
if (isNoServer && retries > 0) {
await new Promise((r) => setTimeout(r, 500));
} else {
throw err;
}
const handleElectronAuthSuccess = useCallback(async () => {
try {
// token was stored in localStorage by ElectronLoginForm before this runs,
// so getUserInfo() can authenticate via the cookie interceptor or localStorage jwt.
let retries = 5;
let meRes = null;
while (retries-- > 0) {
try {
meRes = await getUserInfo();
break;
} catch (err: unknown) {
const isNoServer =
(err as { code?: string })?.code === "NO_SERVER_CONFIGURED" ||
(err as Error)?.message?.includes("no-server-configured");
if (isNoServer && retries > 0) {
await new Promise((r) => setTimeout(r, 500));
} else {
throw err;
}
}
if (!meRes) throw new Error("Failed to get user info");
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null,
});
toast.success(t("messages.loginSuccess"));
} catch {
toast.error(t("errors.failedUserInfo"));
}
},
[
onAuthSuccess,
setLoggedIn,
setIsAdmin,
setUsername,
setUserId,
t,
setInternalLoggedIn,
],
);
if (!meRes) throw new Error("Failed to get user info");
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null,
});
toast.success(t("messages.loginSuccess"));
} catch {
toast.error(t("errors.failedUserInfo"));
}
}, [
onAuthSuccess,
setLoggedIn,
setIsAdmin,
setUsername,
setUserId,
t,
setInternalLoggedIn,
]);
useEffect(() => {
setInternalLoggedIn(loggedIn);
@@ -225,7 +228,7 @@ export function Auth({
getRegistrationAllowed().then((res) => {
setRegistrationAllowed(res.allowed);
});
}, []);
}, [isInElectronWebView]);
useEffect(() => {
getPasswordLoginAllowed()
@@ -342,6 +345,12 @@ export function Auth({
throw new Error(t("errors.loginFailed"));
}
if (isInMobileWebView()) {
// Native-app requests get the JWT in the login response body.
postMobileAuthSuccess(res.token || "");
return;
}
if (isInElectronWebView()) {
try {
window.parent.postMessage(
@@ -546,6 +555,13 @@ export function Auth({
throw new Error(t("errors.loginFailed"));
}
if (isInMobileWebView()) {
// Native-app requests get the JWT in the verify response body.
postMobileAuthSuccess(res.token || "");
setTotpLoading(false);
return;
}
if (isInElectronWebView()) {
try {
window.parent.postMessage(
@@ -700,6 +716,30 @@ export function Auth({
if (success) {
setOidcLoading(true);
if (isInMobileWebView()) {
// The OIDC callback authenticated via an HttpOnly cookie on this origin,
// so prefer a token in the URL (termix-mobile:-origin callbacks include
// one), otherwise read it back from the cookie via /users/me/token.
const finish = (token: string) => {
postMobileAuthSuccess(token);
setOidcLoading(false);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
};
const urlToken = urlParams.get("token");
if (urlToken) {
finish(urlToken);
} else {
getCurrentToken()
.then((token) => finish(token ?? ""))
.catch(() => finish(""));
}
return;
}
if (isInElectronWebView()) {
try {
const urlToken = urlParams.get("token");
@@ -1107,18 +1147,19 @@ export function Auth({
</AlertDescription>
</Alert>
)}
{isInElectronWebView() && webviewAuthSuccess && (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-center">
<h2 className="text-xl font-bold mb-2">
{t("messages.loginSuccess")}
</h2>
<p className="text-muted-foreground">
{t("auth.redirectingToApp")}
</p>
{(isInElectronWebView() || isInMobileWebView()) &&
webviewAuthSuccess && (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-center">
<h2 className="text-xl font-bold mb-2">
{t("messages.loginSuccess")}
</h2>
<p className="text-muted-foreground">
{t("auth.redirectingToApp")}
</p>
</div>
</div>
</div>
)}
)}
{!webviewAuthSuccess && totpRequired && (
<form
className="flex flex-col gap-5"
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
-9
View File
@@ -221,15 +221,6 @@ const Root: React.FC<RootProps> = ({
.filter(Boolean) as string[];
}, []);
const getAllItemIds = useCallback(() => {
const items = Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
);
return items
.map((item) => item.getAttribute("data-id"))
.filter(Boolean) as string[];
}, []);
const [treeHasFocus, setTreeHasFocus] = useState(false);
const handleTreeFocus = useCallback(() => {
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
"use client";
import * as React from "react";
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useState } from "react";
import type { ThemeId } from "@/types/ui-types";
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Auth } from "@/auth/LoginPage.tsx";
import { AlertManager } from "@/dashboard/panels/alerts/AlertManager.tsx";
+2 -44
View File
@@ -122,48 +122,6 @@ const DEFAULT_SLOTS: CardSlot[] = [
{ id: "recent_activity", panel: "side", order: 0, height: null },
];
// ─── useColumnResize ──────────────────────────────────────────────────────────
function useColumnResize(
containerRef: React.RefObject<HTMLDivElement | null>,
mainWidthPct: number,
setMainWidthPct: (v: number) => void,
) {
const dragging = useRef(false);
const startX = useRef(0);
const startPct = useRef(0);
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startPct.current = mainWidthPct;
const onMove = (ev: MouseEvent) => {
if (!dragging.current || !containerRef.current) return;
const totalW = containerRef.current.getBoundingClientRect().width;
const delta = ev.clientX - startX.current;
const newPct = Math.min(
85,
Math.max(30, startPct.current + (delta / totalW) * 100),
);
setMainWidthPct(newPct);
};
const onUp = () => {
dragging.current = false;
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[containerRef, mainWidthPct, setMainWidthPct],
);
return onMouseDown;
}
// ─── Card components ──────────────────────────────────────────────────────────
function StatsBarCard({
@@ -1160,7 +1118,7 @@ export function DashboardTab({
.then(setActivity)
.catch(() => {});
getCredentials()
.then((res: any) =>
.then((res) =>
setCredentialCount(
Array.isArray(res?.credentials) ? res.credentials.length : 0,
),
@@ -1169,7 +1127,7 @@ export function DashboardTab({
getTunnelStatuses()
.then((statuses) => {
const active = Object.values(statuses ?? {}).filter(
(s: any) => s?.status === "CONNECTED",
(s) => s?.status === "CONNECTED",
).length;
setActiveTunnelCount(active);
})
+3 -2
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useEffect,
useState,
@@ -405,7 +406,7 @@ export function NetworkGraphCard({
randomize: true,
componentSpacing: 100,
nodeOverlap: 20,
} as any)
})
.run();
} else {
cyRef.current.fit();
@@ -719,7 +720,7 @@ export function NetworkGraphCard({
onOpenInNewTab();
} else {
addTab({
type: "network_graph" as any,
type: "network_graph",
title: t("dashboard.networkGraph"),
});
}
+1 -1
View File
@@ -75,7 +75,7 @@ export function UpdateLog({ loggedIn }: UpdateLogProps) {
})
.finally(() => setLoading(false));
}
}, [loggedIn, isOpen]);
}, [loggedIn, isOpen, t]);
if (!loggedIn) {
return null;
+8 -2
View File
@@ -87,7 +87,11 @@ export function AlertCard({
return (
<div className="w-full border border-edge rounded-md !bg-elevated overflow-hidden">
<div className={"h-1 w-full " + getAccentBarClass(alert.priority, alert.type)} />
<div
className={
"h-1 w-full " + getAccentBarClass(alert.priority, alert.type)
}
/>
<div className="flex items-start justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-3">
@@ -109,7 +113,9 @@ export function AlertCard({
{alert.type && (
<Badge
variant={getTypeBadgeVariant(alert.type)}
className={alert.type === "success" ? "text-green-400" : undefined}
className={
alert.type === "success" ? "text-green-400" : undefined
}
>
{alert.type}
</Badge>
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useState } from "react";
import { AlertCard } from "./AlertCard.tsx";
import { Button } from "@/components/button.tsx";
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Separator } from "@/components/separator.tsx";
import { Alert, AlertDescription } from "@/components/alert.tsx";
@@ -46,7 +46,7 @@ export function ContainerStats({
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, containerState]);
}, [sessionId, containerId, containerState, t]);
React.useEffect(() => {
fetchStats();
+185 -416
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useState,
useEffect,
@@ -20,43 +21,11 @@ import { DiffWindow } from "./components/DiffWindow.tsx";
import { useDragToDesktop } from "@/features/file-manager/hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "@/features/file-manager/hooks/useDragToSystemDesktop";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
import { CompressDialog } from "./components/CompressDialog.tsx";
import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx";
import {
Upload,
FolderPlus,
FilePlus,
RefreshCw,
Search,
Grid3X3,
List,
ChevronLeft,
ChevronRight,
ArrowUp,
Plus,
Folder,
Trash2,
Copy,
Layout,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/dropdown-menu.tsx";
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
import { TerminalWindow } from "./components/TerminalWindow.tsx";
import type { SSHHost, FileItem } from "@/types/index";
import {
@@ -64,7 +33,6 @@ import {
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import type { LogEntry } from "@/types/connection-log.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import {
listSSHFiles,
@@ -94,56 +62,28 @@ import {
compressSSHFiles,
setSudoPassword,
getServerMetricsById,
transferToHost,
addTransferRecent,
type TransferMethodPreference,
} from "@/main-axios.ts";
import { beginTransferProgressMonitoring } from "./transferProgressMonitor.tsx";
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import type { SidebarItem } from "./FileManagerSidebar.tsx";
interface FileManagerProps {
initialHost?: SSHHost | null;
onClose?: () => void;
}
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
type SSHConnectionError = Error & {
connectionLogs?: ConnectionLogPayload[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string;
reason?: "no_keyboard" | "auth_failed" | "timeout";
};
interface CreateIntent {
id: string;
type: "file" | "directory";
defaultName: string;
currentName: string;
}
function formatFileSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
import type {
CreateIntent,
FileManagerProps,
PendingSudoOperation,
SSHConnectionError,
} from "./file-manager-types.ts";
import { formatFileSize } from "./file-manager-utils.ts";
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const { openWindow } = useWindowManager();
const { t } = useTranslation();
const formatTransferMetrics = useMemo(
() => createFormatTransferMetrics(t),
[t],
);
const { confirmWithToast } = useConfirmation();
const {
addLog,
@@ -244,13 +184,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [compressDialogFiles, setCompressDialogFiles] = useState<FileItem[]>(
[],
);
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
const [transferFiles, setTransferFiles] = useState<FileItem[]>([]);
const [transferMove, setTransferMove] = useState(false);
const [sudoDialogOpen, setSudoDialogOpen] = useState(false);
const [pendingSudoOperation, setPendingSudoOperation] = useState<
| { type: "delete"; files: FileItem[] }
| { type: "navigate"; path: string }
| null
>(null);
const [pendingSudoOperation, setPendingSudoOperation] =
useState<PendingSudoOperation | null>(null);
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
@@ -776,6 +716,19 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
loadDirectory(currentPath);
}, [currentPath, lastRefreshTime, loadDirectory]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ hostId: number; path: string }>)
.detail;
if (!detail || !currentHost?.id) return;
if (detail.hostId === currentHost.id) {
handleRefreshDirectory();
}
};
window.addEventListener("file-manager:refresh", handler);
return () => window.removeEventListener("file-manager:refresh", handler);
}, [currentHost?.id, handleRefreshDirectory]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement;
@@ -1358,6 +1311,19 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
});
}
function handleSidebarItemContextMenu(
event: React.MouseEvent,
item: SidebarItem,
) {
const file: FileItem = {
name: item.name,
path: item.path,
type:
item.type === "recent" || item.type === "pinned" ? "file" : "directory",
};
handleContextMenu(event, file);
}
function handleCopyFiles(files: FileItem[]) {
setClipboard({ files, operation: "copy" });
toast.success(
@@ -1623,6 +1589,76 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
}
function handleOpenTransferDialog(files: FileItem[], move: boolean) {
setTransferFiles(files);
setTransferMove(move);
setTransferDialogOpen(true);
}
async function handleTransferConfirm(
destSessionId: string,
destHostId: number,
destPath: string,
destPathLabel: string,
methodPreference: TransferMethodPreference,
parallelSegmentCount: number,
) {
if (!sshSessionId || !currentHost?.id || transferFiles.length === 0) return;
const sourcePaths = transferFiles.map((f) => f.path);
try {
await ensureSSHConnection();
const { transferId } = await transferToHost(
sshSessionId,
sourcePaths,
destSessionId,
destPath,
transferMove,
methodPreference,
parallelSegmentCount,
);
const monitorHandle = beginTransferProgressMonitoring(transferId, t, {
formatTransferMetrics,
});
if (!monitorHandle) return;
const finalStatus = await monitorHandle.waitForCompletion;
if (
finalStatus.status !== "success" &&
finalStatus.status !== "partial"
) {
return;
}
void addTransferRecent(
currentHost.id,
destHostId,
destPathLabel,
destPathLabel,
);
window.dispatchEvent(
new CustomEvent("file-manager:refresh", {
detail: { hostId: destHostId, path: destPathLabel },
}),
);
if (transferMove) {
handleRefreshDirectory();
clearSelection();
}
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("transfer.transferError")}: ${err.message || t("fileManager.unknownError")}`,
);
}
}
async function handleUndo() {
if (undoHistory.length === 0) {
toast.info(t("fileManager.noUndoableActions"));
@@ -2556,281 +2592,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
visibility: isConnectionLogExpanded ? "hidden" : "visible",
}}
>
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileSidebarOpen((o) => !o)}
className="md:hidden size-8 rounded-none"
title={t("fileManager.toggleSidebar")}
>
<Layout className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goBack}
disabled={navIndex <= 0}
className="size-8 rounded-none"
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goForward}
disabled={navIndex >= navHistory.length - 1}
className="size-8 rounded-none"
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goUp}
disabled={currentPath === "/"}
className="size-8 rounded-none"
>
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshDirectory}
className="size-8 rounded-none"
>
<RefreshCw
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
/>
</Button>
</div>
<div className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden">
<Folder className="size-3.5 text-accent-brand shrink-0" />
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
{currentPath.split("/").map((part, i, arr) => (
<React.Fragment key={i}>
{part === "" && i === 0 ? (
<button
onClick={() => navigateTo("/")}
className="hover:text-accent-brand transition-colors"
>
{t("fileManager.root")}
</button>
) : part !== "" ? (
<button
onClick={() =>
navigateTo(arr.slice(0, i + 1).join("/") || "/")
}
className="hover:text-accent-brand transition-colors"
>
{part}
</button>
) : null}
{i < arr.length - 1 && part !== "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
{i === 0 && arr.length > 1 && part === "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
</React.Fragment>
))}
</div>
</div>
<div className="flex items-center gap-2">
{selectedFiles.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 bg-accent-brand/10 border border-accent-brand/20 text-accent-brand text-[10px] font-black uppercase tracking-tighter">
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleDeleteFiles(selectedFiles)}
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleCopyFiles(selectedFiles)}
>
<Copy className="size-3.5" />
</Button>
</div>
)}
<div className="relative w-28 md:w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder={t("fileManager.searchFiles")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs bg-muted/50 border-border rounded-none focus:ring-1 focus:ring-accent-brand/50"
/>
</div>
<div className="flex items-center border border-border rounded-none overflow-hidden">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("grid")}
className={`size-8 rounded-none border-y-0 border-l-0 border-r border-border ${viewMode === "grid" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<Grid3X3 className="size-4" />
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("list")}
className={`size-8 rounded-none border-y-0 border-r-0 border-border ${viewMode === "list" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<List className="size-4" />
</Button>
</div>
<label
className="hidden md:block cursor-pointer"
title={t("fileManager.upload")}
>
<input
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (files) handleFilesDropped(files);
}}
/>
<div className="h-8 px-3 flex items-center gap-1.5 border border-border hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-[10px] font-bold uppercase tracking-widest">
<Upload className="size-3.5" /> {t("fileManager.upload")}
</div>
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none font-bold uppercase tracking-widest text-[10px]"
>
<Plus className="size-3.5" />
{t("fileManager.new")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44 rounded-none border-border bg-card"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFolder(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FolderPlus className="size-4 text-accent-brand" />
{t("fileManager.newFolder")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFile(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FilePlus className="size-4 text-muted-foreground" />
{t("fileManager.newFile")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-muted-foreground py-1">
{t("fileManager.sortBy")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(v) =>
setSortBy(v as "name" | "modified" | "size")
}
>
<DropdownMenuRadioItem
value="name"
className="rounded-none text-xs"
>
{t("fileManager.sortByName")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="modified"
className="rounded-none text-xs"
>
{t("fileManager.sortByDate")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="size"
className="rounded-none text-xs"
>
{t("fileManager.sortBySize")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sortOrder}
onValueChange={(v) => setSortOrder(v as "asc" | "desc")}
>
<DropdownMenuRadioItem
value="asc"
className="rounded-none text-xs"
>
{t("fileManager.ascending")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="desc"
className="rounded-none text-xs"
>
{t("fileManager.descending")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Mobile breadcrumb row */}
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
<div className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden">
<Folder className="size-3.5 text-accent-brand shrink-0" />
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
{currentPath.split("/").map((part, i, arr) => (
<React.Fragment key={i}>
{part === "" && i === 0 ? (
<button
onClick={() => navigateTo("/")}
className="hover:text-accent-brand transition-colors"
>
{t("fileManager.root")}
</button>
) : part !== "" ? (
<button
onClick={() =>
navigateTo(arr.slice(0, i + 1).join("/") || "/")
}
className="hover:text-accent-brand transition-colors"
>
{part}
</button>
) : null}
{i < arr.length - 1 && part !== "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
{i === 0 && arr.length > 1 && part === "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
</React.Fragment>
))}
</div>
</div>
</div>
</div>
<FileManagerToolbar
t={t}
currentPath={currentPath}
navIndex={navIndex}
navHistoryLength={navHistory.length}
isLoading={isLoading}
sshSessionId={sshSessionId}
selectedFiles={selectedFiles}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
viewMode={viewMode}
setViewMode={setViewMode}
sortBy={sortBy}
setSortBy={setSortBy}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
setMobileSidebarOpen={setMobileSidebarOpen}
goBack={goBack}
goForward={goForward}
goUp={goUp}
navigateTo={navigateTo}
handleRefreshDirectory={handleRefreshDirectory}
handleDeleteFiles={handleDeleteFiles}
handleCopyFiles={handleCopyFiles}
handleFilesDropped={handleFilesDropped}
handleCreateNewFolder={handleCreateNewFolder}
handleCreateNewFile={handleCreateNewFile}
/>
<div
className="flex-1 flex px-3 pb-3 pt-2 gap-3 min-h-0 relative"
@@ -2861,6 +2650,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onPathChange={navigateTo}
onLoadDirectory={loadDirectory}
onFileOpen={handleSidebarFileOpen}
onItemContextMenu={handleSidebarItemContextMenu}
sshSessionId={sshSessionId}
refreshTrigger={sidebarRefreshTrigger}
diskInfo={diskInfo ?? undefined}
@@ -2873,11 +2663,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
<FileManagerGrid
files={filteredFiles}
selectedFiles={selectedFiles}
onFileSelect={() => {}}
onFileOpen={handleFileOpen}
onSelectionChange={setSelection}
currentPath={currentPath}
onPathChange={navigateTo}
onRefresh={handleRefreshDirectory}
onUpload={handleFilesDropped}
sortBy={sortBy}
@@ -2963,69 +2750,51 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onExtractArchive={handleExtractArchive}
onCompress={handleOpenCompressDialog}
onCopyPath={handleCopyPath}
onTransferToHost={handleOpenTransferDialog}
/>
</div>
</div>
</div>
</div>
<CompressDialog
open={compressDialogFiles.length > 0}
onOpenChange={(open) => !open && setCompressDialogFiles([])}
fileNames={compressDialogFiles.map((f) => f.name)}
onCompress={handleCompress}
/>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
backgroundColor="var(--bg-canvas)"
/>
<WarpgateDialog
isOpen={warpgateRequired}
url={warpgateUrl}
securityKey={warpgateSecurityKey}
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
backgroundColor="var(--bg-canvas)"
/>
{currentHost && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authDialogReason}
onSubmit={handleAuthDialogSubmit}
onCancel={handleAuthDialogCancel}
hostInfo={{
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
name: currentHost.name,
}}
backgroundColor="var(--bg-canvas)"
<TransferToHostDialog
open={transferDialogOpen}
onOpenChange={setTransferDialogOpen}
files={transferFiles}
move={transferMove}
sourceHost={currentHost}
sourceSessionId={sshSessionId}
onConfirm={handleTransferConfirm}
/>
)}
<PermissionsDialog
file={permissionsDialogFile}
open={permissionsDialogFile !== null}
onOpenChange={(open) => {
if (!open) setPermissionsDialogFile(null);
}}
onSave={handleSavePermissions}
/>
<SudoPasswordDialog
open={sudoDialogOpen}
onOpenChange={(open) => {
setSudoDialogOpen(open);
if (!open) setPendingSudoOperation(null);
}}
onSubmit={handleSudoPasswordSubmit}
<FileManagerDialogs
compressDialogFiles={compressDialogFiles}
setCompressDialogFiles={setCompressDialogFiles}
handleCompress={handleCompress}
totpRequired={totpRequired}
totpPrompt={totpPrompt}
handleTotpSubmit={handleTotpSubmit}
handleTotpCancel={handleTotpCancel}
warpgateRequired={warpgateRequired}
warpgateUrl={warpgateUrl}
warpgateSecurityKey={warpgateSecurityKey}
handleWarpgateContinue={handleWarpgateContinue}
handleWarpgateCancel={handleWarpgateCancel}
handleWarpgateOpenUrl={handleWarpgateOpenUrl}
currentHost={currentHost}
showAuthDialog={showAuthDialog}
authDialogReason={authDialogReason}
handleAuthDialogSubmit={handleAuthDialogSubmit}
handleAuthDialogCancel={handleAuthDialogCancel}
permissionsDialogFile={permissionsDialogFile}
setPermissionsDialogFile={setPermissionsDialogFile}
handleSavePermissions={handleSavePermissions}
sudoDialogOpen={sudoDialogOpen}
setSudoDialogOpen={setSudoDialogOpen}
setPendingSudoOperation={setPendingSudoOperation}
handleSudoPasswordSubmit={handleSudoPasswordSubmit}
/>
<ConnectionLog
isConnecting={isReconnecting || isLoading}
@@ -18,10 +18,13 @@ import {
Star,
Bookmark,
FileArchive,
ArrowRightLeft,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
const VIEWPORT_PADDING = 16;
interface FileItem {
name: string;
type: "file" | "directory" | "link";
@@ -64,6 +67,7 @@ interface ContextMenuProps {
onExtractArchive?: (file: FileItem) => void;
onCompress?: (files: FileItem[]) => void;
onCopyPath?: (files: FileItem[]) => void;
onTransferToHost?: (files: FileItem[], move: boolean) => void;
}
interface MenuItem {
@@ -76,29 +80,6 @@ interface MenuItem {
danger?: boolean;
}
const VIEWPORT_PADDING = 10;
function getClampedMenuPosition(
x: number,
y: number,
menuWidth: number,
menuHeight: number,
) {
const maxX = Math.max(
VIEWPORT_PADDING,
window.innerWidth - menuWidth - VIEWPORT_PADDING,
);
const maxY = Math.max(
VIEWPORT_PADDING,
window.innerHeight - menuHeight - VIEWPORT_PADDING,
);
return {
x: Math.min(Math.max(VIEWPORT_PADDING, x), maxX),
y: Math.min(Math.max(VIEWPORT_PADDING, y), maxY),
};
}
export function FileManagerContextMenu({
x,
y,
@@ -129,6 +110,7 @@ export function FileManagerContextMenu({
onExtractArchive,
onCompress,
onCopyPath,
onTransferToHost,
}: ContextMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
@@ -278,6 +260,29 @@ export function FileManagerContextMenu({
});
}
if (isFileContext && onTransferToHost) {
const isOnlyDirectories =
files.length > 0 && files.every((f) => f.type === "directory");
menuItems.push({
icon: <ArrowRightLeft className="size-3.5" />,
label: isMultipleFiles
? t("transfer.copyItemsToHost", { count: files.length })
: isOnlyDirectories && isSingleFile
? t("transfer.copyFolderToHost")
: t("transfer.copyToHost"),
action: () => onTransferToHost(files, false),
});
menuItems.push({
icon: <ArrowRightLeft className="size-3.5" />,
label: isMultipleFiles
? t("transfer.moveItemsToHost", { count: files.length })
: isOnlyDirectories && isSingleFile
? t("transfer.moveFolderToHost")
: t("transfer.moveToHost"),
action: () => onTransferToHost(files, true),
});
}
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
const fileName = files[0].name.toLowerCase();
const isArchive =
@@ -0,0 +1,134 @@
import type { FileItem, SSHHost } from "@/types/index";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
import { CompressDialog } from "./components/CompressDialog.tsx";
import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx";
import type { PendingSudoOperation } from "./file-manager-types.ts";
type FileManagerDialogsProps = {
compressDialogFiles: FileItem[];
setCompressDialogFiles: (files: FileItem[]) => void;
handleCompress: (archiveName: string, format: string) => void | Promise<void>;
totpRequired: boolean;
totpPrompt: string;
handleTotpSubmit: (code: string) => void | Promise<void>;
handleTotpCancel: () => void;
warpgateRequired: boolean;
warpgateUrl: string;
warpgateSecurityKey: string;
handleWarpgateContinue: () => void | Promise<void>;
handleWarpgateCancel: () => void;
handleWarpgateOpenUrl: () => void;
currentHost: SSHHost | null;
showAuthDialog: boolean;
authDialogReason: "no_keyboard" | "auth_failed" | "timeout";
handleAuthDialogSubmit: (credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) => void | Promise<void>;
handleAuthDialogCancel: () => void;
permissionsDialogFile: FileItem | null;
setPermissionsDialogFile: (file: FileItem | null) => void;
handleSavePermissions: (
file: FileItem,
permissions: string,
) => void | Promise<void>;
sudoDialogOpen: boolean;
setSudoDialogOpen: (open: boolean) => void;
setPendingSudoOperation: (operation: PendingSudoOperation | null) => void;
handleSudoPasswordSubmit: (password: string) => void | Promise<void>;
};
export function FileManagerDialogs({
compressDialogFiles,
setCompressDialogFiles,
handleCompress,
totpRequired,
totpPrompt,
handleTotpSubmit,
handleTotpCancel,
warpgateRequired,
warpgateUrl,
warpgateSecurityKey,
handleWarpgateContinue,
handleWarpgateCancel,
handleWarpgateOpenUrl,
currentHost,
showAuthDialog,
authDialogReason,
handleAuthDialogSubmit,
handleAuthDialogCancel,
permissionsDialogFile,
setPermissionsDialogFile,
handleSavePermissions,
sudoDialogOpen,
setSudoDialogOpen,
setPendingSudoOperation,
handleSudoPasswordSubmit,
}: FileManagerDialogsProps) {
return (
<>
<CompressDialog
open={compressDialogFiles.length > 0}
onOpenChange={(open) => !open && setCompressDialogFiles([])}
fileNames={compressDialogFiles.map((f) => f.name)}
onCompress={handleCompress}
/>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
backgroundColor="var(--bg-canvas)"
/>
<WarpgateDialog
isOpen={warpgateRequired}
url={warpgateUrl}
securityKey={warpgateSecurityKey}
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
backgroundColor="var(--bg-canvas)"
/>
{currentHost && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authDialogReason}
onSubmit={handleAuthDialogSubmit}
onCancel={handleAuthDialogCancel}
hostInfo={{
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
name: currentHost.name,
}}
backgroundColor="var(--bg-canvas)"
/>
)}
<PermissionsDialog
file={permissionsDialogFile}
open={permissionsDialogFile !== null}
onOpenChange={(open) => {
if (!open) setPermissionsDialogFile(null);
}}
onSave={handleSavePermissions}
/>
<SudoPasswordDialog
open={sudoDialogOpen}
onOpenChange={(open) => {
setSudoDialogOpen(open);
if (!open) setPendingSudoOperation(null);
}}
onSubmit={handleSudoPasswordSubmit}
/>
</>
);
}
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils.ts";
@@ -21,33 +22,8 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
interface CreateIntent {
id: string;
type: "file" | "directory";
defaultName: string;
currentName: string;
}
function formatFileSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
import type { CreateIntent } from "./file-manager-types.ts";
import { formatFileSize } from "./file-manager-utils.ts";
interface DragState {
type: "none" | "internal" | "external";
@@ -61,11 +37,8 @@ interface DragState {
interface FileManagerGridProps {
files: FileItem[];
selectedFiles: FileItem[];
onFileSelect: (file: FileItem, multiSelect?: boolean) => void;
onFileOpen: (file: FileItem) => void;
onSelectionChange: (files: FileItem[]) => void;
currentPath: string;
onPathChange: (path: string) => void;
onRefresh: () => void;
onUpload?: (files: FileList) => void;
onDownload?: (files: FileItem[]) => void;
@@ -182,8 +155,6 @@ export function FileManagerGrid({
selectedFiles,
onFileOpen,
onSelectionChange,
currentPath,
onPathChange,
onRefresh,
onUpload,
onDownload,
@@ -368,92 +339,6 @@ export function FileManagerGrid({
} | null>(null);
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
const [navigationHistory, setNavigationHistory] = useState<string[]>([
currentPath,
]);
const [historyIndex, setHistoryIndex] = useState(0);
const [isEditingPath, setIsEditingPath] = useState(false);
const [editPathValue, setEditPathValue] = useState(currentPath);
useEffect(() => {
const lastPath = navigationHistory[historyIndex];
if (currentPath !== lastPath) {
const newHistory = navigationHistory.slice(0, historyIndex + 1);
newHistory.push(currentPath);
setNavigationHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
}
}, [currentPath]);
const goBack = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
onPathChange(navigationHistory[newIndex]);
}
};
const goForward = () => {
if (historyIndex < navigationHistory.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
onPathChange(navigationHistory[newIndex]);
}
};
const goUp = () => {
const parts = currentPath.split("/").filter(Boolean);
if (parts.length > 0) {
parts.pop();
const parentPath = "/" + parts.join("/");
onPathChange(parentPath);
} else if (currentPath !== "/") {
onPathChange("/");
}
};
const pathParts = currentPath.split("/").filter(Boolean);
const navigateToPath = (index: number) => {
if (index === -1) {
onPathChange("/");
} else {
const newPath = "/" + pathParts.slice(0, index + 1).join("/");
onPathChange(newPath);
}
};
const startEditingPath = () => {
setEditPathValue(currentPath);
setIsEditingPath(true);
};
const cancelEditingPath = () => {
setIsEditingPath(false);
setEditPathValue(currentPath);
};
const confirmEditingPath = () => {
const trimmedPath = editPathValue.trim();
if (trimmedPath) {
const needsExpansion =
trimmedPath.startsWith("~") || trimmedPath.includes("$");
const normalizedPath = needsExpansion
? trimmedPath
: trimmedPath.startsWith("/")
? trimmedPath
: "/" + trimmedPath;
onPathChange(normalizedPath);
}
setIsEditingPath(false);
};
useEffect(() => {
if (!isEditingPath) {
setEditPathValue(currentPath);
}
}, [currentPath, isEditingPath]);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
@@ -490,7 +375,7 @@ export function FileManagerGrid({
});
}
},
[dragState.type, dragState.counter],
[dragState.type],
);
const handleDragOver = useCallback(
@@ -691,7 +576,7 @@ export function FileManagerGrid({
setDragState({ type: "none", files: [], counter: 0 });
},
[onUpload, onDownload, dragState],
[onUpload, dragState],
);
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
@@ -67,6 +67,8 @@ interface FileManagerSidebarProps {
currentPath: string;
onPathChange: (path: string) => void;
onFileOpen?: (file: SidebarItem) => void;
/** Full file-manager context menu (same as main grid). */
onItemContextMenu?: (event: React.MouseEvent, item: SidebarItem) => void;
sshSessionId?: string;
refreshTrigger?: number;
diskInfo?: { usedHuman: string; totalHuman: string; percent: number };
@@ -79,6 +81,7 @@ export function FileManagerSidebar({
currentPath,
onPathChange,
onFileOpen,
onItemContextMenu,
sshSessionId,
refreshTrigger,
diskInfo,
@@ -114,46 +117,9 @@ export function FileManagerSidebar({
// ─── Effects ──────────────────────────────────────────────────────────────────
useEffect(() => {
loadQuickAccessData();
}, [currentHost, refreshTrigger]);
useEffect(() => {
if (sshSessionId) {
loadedFoldersRef.current = new Set(["/"]);
loadDirectoryTree();
}
}, [sshSessionId]);
// When currentPath changes externally (grid navigation), ensure the parent
// directory is loaded in the tree so the selection highlight can appear.
useEffect(() => {
if (!sshSessionId || currentPath === "/") return;
const parentPath =
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
const findByPath = (items: SidebarItem[]): SidebarItem | null => {
for (const item of items) {
if (item.path === parentPath) return item;
if (item.children) {
const found = findByPath(item.children);
if (found) return found;
}
}
return null;
};
const parent = findByPath(directoryTree);
if (parent && !loadedFoldersRef.current.has(parent.path)) {
loadedFoldersRef.current.add(parent.path);
loadSubdirectory(parent.id, parent.path);
}
}, [currentPath, sshSessionId]);
// ─── API: Quick access ────────────────────────────────────────────────────────
const loadQuickAccessData = async () => {
const loadQuickAccessData = useCallback(async () => {
if (!currentHost?.id) return;
try {
@@ -196,61 +162,64 @@ export function FileManagerSidebar({
setPinnedItems([]);
setShortcuts([]);
}
};
}, [currentHost?.id]);
// ─── API: Directory tree ──────────────────────────────────────────────────────
const loadDirectoryTree = async (attempt = 0) => {
if (!sshSessionId) return;
const loadDirectoryTree = useCallback(
async (attempt = 0) => {
if (!sshSessionId) return;
try {
const response = await listSSHFiles(sshSessionId, "/");
const rootFiles = (response.files || []) as DirectoryItemData[];
const rootFolders = rootFiles.filter(
(item: DirectoryItemData) => item.type === "directory",
);
try {
const response = await listSSHFiles(sshSessionId, "/");
const rootFiles = (response.files || []) as DirectoryItemData[];
const rootFolders = rootFiles.filter(
(item: DirectoryItemData) => item.type === "directory",
);
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.name}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: true,
children: rootTreeItems,
},
]);
} catch (error: unknown) {
const status =
(error as { status?: number })?.status ||
(error as { response?: { status?: number } })?.response?.status;
if (status === 409 && attempt < 3) {
// Another request was already listing "/" — retry after a short delay
setTimeout(() => loadDirectoryTree(attempt + 1), 600);
return;
}
console.error("Failed to load directory tree:", error);
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.name}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
},
]);
}
};
}));
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: true,
children: rootTreeItems,
},
]);
} catch (error: unknown) {
const status =
(error as { status?: number })?.status ||
(error as { response?: { status?: number } })?.response?.status;
if (status === 409 && attempt < 3) {
// Another request was already listing "/" — retry after a short delay
setTimeout(() => loadDirectoryTree(attempt + 1), 600);
return;
}
console.error("Failed to load directory tree:", error);
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: false,
children: [],
},
]);
}
},
[sshSessionId],
);
/**
* Lazily fetches subdirectory contents and patches them into the tree state.
@@ -304,6 +273,43 @@ export function FileManagerSidebar({
[sshSessionId],
);
useEffect(() => {
loadQuickAccessData();
}, [loadQuickAccessData, refreshTrigger]);
useEffect(() => {
if (sshSessionId) {
loadedFoldersRef.current = new Set(["/"]);
loadDirectoryTree();
}
}, [loadDirectoryTree, sshSessionId]);
// When currentPath changes externally (grid navigation), ensure the parent
// directory is loaded in the tree so the selection highlight can appear.
useEffect(() => {
if (!sshSessionId || currentPath === "/") return;
const parentPath =
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
const findByPath = (items: SidebarItem[]): SidebarItem | null => {
for (const item of items) {
if (item.path === parentPath) return item;
if (item.children) {
const found = findByPath(item.children);
if (found) return found;
}
}
return null;
};
const parent = findByPath(directoryTree);
if (parent && !loadedFoldersRef.current.has(parent.path)) {
loadedFoldersRef.current.add(parent.path);
loadSubdirectory(parent.id, parent.path);
}
}, [currentPath, directoryTree, loadSubdirectory, sshSessionId]);
// ─── Quick-access mutation handlers ──────────────────────────────────────────
const handleRemoveRecentFile = async (item: SidebarItem) => {
@@ -415,18 +421,47 @@ export function FileManagerSidebar({
// ─── Context menu ─────────────────────────────────────────────────────────────
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
const findTreeItemById = useCallback(
(items: SidebarItem[], id: string): SidebarItem | null => {
for (const item of items) {
if (item.id === id) return item;
if (item.children) {
const found = findTreeItemById(item.children, id);
if (found) return found;
}
}
return null;
},
[],
);
const handleItemContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
if (onItemContextMenu) {
onItemContextMenu(e, item);
return;
}
setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item });
};
const handleTreeContextMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const row = target.closest<HTMLElement>("[data-id]");
if (!row) return;
const id = row.getAttribute("data-id");
if (!id) return;
const item = findTreeItemById(directoryTree, id);
if (!item || item.type !== "folder") return;
handleItemContextMenu(e, item);
};
const closeContextMenu = () => {
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
useEffect(() => {
if (!contextMenu.isVisible) return;
if (!contextMenu.isVisible || onItemContextMenu) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
@@ -448,7 +483,7 @@ export function FileManagerSidebar({
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu.isVisible]);
}, [contextMenu.isVisible, onItemContextMenu]);
// ─── Derive selected tree node + ancestors from currentPath ──────────────────
@@ -515,7 +550,7 @@ export function FileManagerSidebar({
: "text-muted-foreground hover:text-foreground hover:bg-muted border-transparent",
)}
onClick={() => handleQuickAccessClick(item)}
onContextMenu={(e) => handleContextMenu(e, item)}
onContextMenu={(e) => handleItemContextMenu(e, item)}
title={item.path}
>
<div className="shrink-0">{icon}</div>
@@ -612,16 +647,18 @@ export function FileManagerSidebar({
</span>
</div>
<div className="px-1">
<FolderTree.Root
id="sidebar-directory-tree"
defaultExpanded={["root"]}
selectedId={selectedTreeId}
expandedIds={ancestorIds}
onSelect={(id) => handleDirectorySelect(id)}
className="bg-transparent border-0 rounded-none shadow-none"
>
{directoryTree.map((item) => renderFolderTreeItem(item))}
</FolderTree.Root>
<div onContextMenu={handleTreeContextMenu}>
<FolderTree.Root
id="sidebar-directory-tree"
defaultExpanded={["root"]}
selectedId={selectedTreeId}
expandedIds={ancestorIds}
onSelect={(id) => handleDirectorySelect(id)}
className="bg-transparent border-0 rounded-none shadow-none"
>
{directoryTree.map((item) => renderFolderTreeItem(item))}
</FolderTree.Root>
</div>
</div>
</div>
@@ -679,8 +716,8 @@ export function FileManagerSidebar({
)}
</div>
{/* ── Context menu ─────────────────────────────────────────────── */}
{contextMenu.isVisible && contextMenu.item && (
{/* ── Context menu (fallback when parent does not supply onItemContextMenu) */}
{!onItemContextMenu && contextMenu.isVisible && contextMenu.item && (
<>
<div className="fixed inset-0 z-40" />
@@ -0,0 +1,349 @@
import React from "react";
import {
ArrowUp,
ChevronLeft,
ChevronRight,
Copy,
FilePlus,
Folder,
FolderPlus,
Grid3X3,
Layout,
List,
Plus,
RefreshCw,
Search,
Trash2,
Upload,
} from "lucide-react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/dropdown-menu.tsx";
import type { FileItem } from "@/types/index";
type SortBy = "name" | "modified" | "size";
type SortOrder = "asc" | "desc";
type ViewMode = "grid" | "list";
type FileManagerToolbarProps = {
t: (key: string) => string;
currentPath: string;
navIndex: number;
navHistoryLength: number;
isLoading: boolean;
sshSessionId: string | null;
selectedFiles: FileItem[];
searchQuery: string;
setSearchQuery: (query: string) => void;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
sortBy: SortBy;
setSortBy: (sortBy: SortBy) => void;
sortOrder: SortOrder;
setSortOrder: (sortOrder: SortOrder) => void;
setMobileSidebarOpen: (updater: (open: boolean) => boolean) => void;
goBack: () => void;
goForward: () => void;
goUp: () => void;
navigateTo: (path: string) => void;
handleRefreshDirectory: () => void;
handleDeleteFiles: (files: FileItem[]) => void;
handleCopyFiles: (files: FileItem[]) => void;
handleFilesDropped: (fileList: FileList) => void;
handleCreateNewFolder: () => void;
handleCreateNewFile: () => void;
};
function Breadcrumb({
currentPath,
navigateTo,
t,
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t">) {
return (
<>
<Folder className="size-3.5 text-accent-brand shrink-0" />
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
{currentPath.split("/").map((part, i, arr) => (
<React.Fragment key={i}>
{part === "" && i === 0 ? (
<button
onClick={() => navigateTo("/")}
className="hover:text-accent-brand transition-colors"
>
{t("fileManager.root")}
</button>
) : part !== "" ? (
<button
onClick={() => navigateTo(arr.slice(0, i + 1).join("/") || "/")}
className="hover:text-accent-brand transition-colors"
>
{part}
</button>
) : null}
{i < arr.length - 1 && part !== "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
{i === 0 && arr.length > 1 && part === "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
</React.Fragment>
))}
</div>
</>
);
}
export function FileManagerToolbar({
t,
currentPath,
navIndex,
navHistoryLength,
isLoading,
sshSessionId,
selectedFiles,
searchQuery,
setSearchQuery,
viewMode,
setViewMode,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
setMobileSidebarOpen,
goBack,
goForward,
goUp,
navigateTo,
handleRefreshDirectory,
handleDeleteFiles,
handleCopyFiles,
handleFilesDropped,
handleCreateNewFolder,
handleCreateNewFile,
}: FileManagerToolbarProps) {
return (
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileSidebarOpen((open) => !open)}
className="md:hidden size-8 rounded-none"
title={t("fileManager.toggleSidebar")}
>
<Layout className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goBack}
disabled={navIndex <= 0}
className="size-8 rounded-none"
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goForward}
disabled={navIndex >= navHistoryLength - 1}
className="size-8 rounded-none"
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goUp}
disabled={currentPath === "/"}
className="size-8 rounded-none"
>
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshDirectory}
className="size-8 rounded-none"
>
<RefreshCw
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
/>
</Button>
</div>
<div className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden">
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
</div>
<div className="flex items-center gap-2">
{selectedFiles.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 bg-accent-brand/10 border border-accent-brand/20 text-accent-brand text-[10px] font-black uppercase tracking-tighter">
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleDeleteFiles(selectedFiles)}
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleCopyFiles(selectedFiles)}
>
<Copy className="size-3.5" />
</Button>
</div>
)}
<div className="relative w-28 md:w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder={t("fileManager.searchFiles")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs bg-muted/50 border-border rounded-none focus:ring-1 focus:ring-accent-brand/50"
/>
</div>
<div className="flex items-center border border-border rounded-none overflow-hidden">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("grid")}
className={`size-8 rounded-none border-y-0 border-l-0 border-r border-border ${viewMode === "grid" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<Grid3X3 className="size-4" />
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("list")}
className={`size-8 rounded-none border-y-0 border-r-0 border-border ${viewMode === "list" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<List className="size-4" />
</Button>
</div>
<label
className="hidden md:block cursor-pointer"
title={t("fileManager.upload")}
>
<input
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (files) handleFilesDropped(files);
}}
/>
<div className="h-8 px-3 flex items-center gap-1.5 border border-border hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-[10px] font-bold uppercase tracking-widest">
<Upload className="size-3.5" /> {t("fileManager.upload")}
</div>
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none font-bold uppercase tracking-widest text-[10px]"
>
<Plus className="size-3.5" />
{t("fileManager.new")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44 rounded-none border-border bg-card"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFolder(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FolderPlus className="size-4 text-accent-brand" />
{t("fileManager.newFolder")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFile(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FilePlus className="size-4 text-muted-foreground" />
{t("fileManager.newFile")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-muted-foreground py-1">
{t("fileManager.sortBy")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(value) => setSortBy(value as SortBy)}
>
<DropdownMenuRadioItem
value="name"
className="rounded-none text-xs"
>
{t("fileManager.sortByName")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="modified"
className="rounded-none text-xs"
>
{t("fileManager.sortByDate")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="size"
className="rounded-none text-xs"
>
{t("fileManager.sortBySize")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sortOrder}
onValueChange={(value) => setSortOrder(value as SortOrder)}
>
<DropdownMenuRadioItem
value="asc"
className="rounded-none text-xs"
>
{t("fileManager.ascending")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="desc"
className="rounded-none text-xs"
>
{t("fileManager.descending")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
<div className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden">
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { getTransferStatus, listActiveTransfers } from "@/main-axios.ts";
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import {
beginTransferProgressMonitoring,
isTransferBeingMonitored,
showTransferCompletionToast,
} from "./transferProgressMonitor.tsx";
import {
clearStalePendingTransfer,
getPendingTransferIds,
isTransferNotified,
} from "./transferNotificationStore.ts";
const POLL_INTERVAL_MS = 2000;
export function TransferMonitor() {
const { t } = useTranslation();
const formatTransferMetrics = useMemo(
() => createFormatTransferMetrics(t),
[t],
);
useEffect(() => {
const reconcileTransfers = async () => {
try {
const { transfers } = await listActiveTransfers();
for (const transfer of transfers) {
if (isTransferBeingMonitored(transfer.transferId)) continue;
beginTransferProgressMonitoring(transfer.transferId, t, {
resumed: true,
initialStatus: transfer,
formatTransferMetrics,
});
}
} catch {
// Non-fatal: file-manager service may be unavailable briefly
}
for (const transferId of getPendingTransferIds()) {
if (
isTransferBeingMonitored(transferId) ||
isTransferNotified(transferId)
) {
continue;
}
try {
const status = await getTransferStatus(transferId);
if (status.status === "running") {
if (!isTransferBeingMonitored(transferId)) {
beginTransferProgressMonitoring(transferId, t, {
resumed: true,
initialStatus: status,
formatTransferMetrics,
});
}
continue;
}
showTransferCompletionToast(
status,
t,
undefined,
formatTransferMetrics,
);
} catch {
clearStalePendingTransfer(transferId);
}
}
};
void reconcileTransfers();
const interval = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [t, formatTransferMetrics]);
return null;
}
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@/components/button.tsx";
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
@@ -38,6 +39,7 @@ import {
SiDocker,
} from "react-icons/si";
import { Button } from "@/components/button.tsx";
import { Kbd, KbdKey } from "@/components/kbd.tsx";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
const CodeEditor = lazy(() =>
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { FileViewer } from "./FileViewer.tsx";
@@ -126,7 +127,10 @@ export function FileWindow({
if (response.encoding === "base64") {
try {
const decoded = atob(fileContent);
const bytes = Uint8Array.from(atob(fileContent), (c) =>
c.charCodeAt(0),
);
const decoded = new TextDecoder("utf-8").decode(bytes);
if (isDisplayableText(decoded)) {
fileContent = decoded;
}
@@ -1,6 +1,10 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { Terminal } from "@/features/terminal/Terminal.tsx";
import {
Terminal,
type TerminalHandle,
type TerminalHostConfig,
} from "@/features/terminal/Terminal.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
@@ -26,7 +30,7 @@ export function TerminalWindow({
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const terminalRef = React.useRef<{ fit?: () => void } | null>(null);
const terminalRef = React.useRef<TerminalHandle | null>(null);
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
@@ -101,8 +105,8 @@ export function TerminalWindow({
zIndex={currentWindow.zIndex}
>
<Terminal
ref={terminalRef as any}
hostConfig={hostConfig as any}
ref={terminalRef}
hostConfig={hostConfig as TerminalHostConfig}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
@@ -0,0 +1,138 @@
import { Button } from "@/components/button.tsx";
import {
formatTransferMbPerSec,
getTransferProgressPercent,
type TransferProgressResponse,
} from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
interface TransferProgressToastProps {
status: TransferProgressResponse;
liveMbPerSec?: number;
stalled?: boolean;
formatSize: (bytes?: number) => string;
onCancel?: () => void;
cancelling?: boolean;
}
function IndeterminateProgressBar() {
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
</div>
);
}
function DeterminateProgressBar({ value }: { value: number }) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width]"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
export function TransferProgressToast({
status,
liveMbPerSec,
stalled = false,
formatSize,
onCancel,
cancelling = false,
}: TransferProgressToastProps) {
const { t } = useTranslation();
const percent = getTransferProgressPercent(status);
let title = t("transfer.progressTransferring");
if (status.phase === "reconnecting") {
title = t("transfer.progressReconnecting");
} else if (status.phase === "compressing") {
title = t("transfer.progressCompressing");
} else if (status.phase === "extracting") {
title = t("transfer.progressExtracting");
} else if (
status.method === "item_sftp" &&
status.totalItems !== undefined &&
status.itemsCompleted !== undefined
) {
title = t("transfer.progressTransferringItems", {
current: status.itemsCompleted,
total: status.totalItems,
});
}
const hasByteProgress =
status.bytesTransferred !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0;
const detailLeft = hasByteProgress
? t("transfer.progressBytes", {
transferred: formatSize(status.bytesTransferred),
total: formatSize(status.totalBytes),
})
: status.method === "item_sftp" &&
status.totalItems !== undefined &&
status.itemsCompleted !== undefined
? t("transfer.progressItems", {
current: status.itemsCompleted,
total: status.totalItems,
})
: null;
const liveRate =
status.phase === "reconnecting"
? undefined
: stalled
? t("transfer.progressStalled")
: liveMbPerSec !== undefined
? status.parallelSegmentCount && status.parallelSegmentCount > 1
? t("transfer.progressTotalSpeed", {
speed: formatTransferMbPerSec(liveMbPerSec),
lanes: status.parallelSegmentCount,
})
: formatTransferMbPerSec(liveMbPerSec)
: undefined;
const showIndeterminate =
status.phase === "reconnecting" || percent === undefined;
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-tight">{title}</p>
{onCancel && status.status === "running" && status.transferId && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={cancelling}
onClick={onCancel}
>
{cancelling
? t("transfer.progressCancelling")
: t("transfer.progressCancel")}
</Button>
)}
</div>
{showIndeterminate ? (
<IndeterminateProgressBar />
) : (
<DeterminateProgressBar value={percent} />
)}
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{detailLeft ?? ""}</span>
<span
className={`shrink-0 tabular-nums ${stalled ? "text-amber-500" : liveRate ? "font-medium text-foreground" : "invisible"}`}
aria-hidden={!liveRate}
>
{liveRate ?? "0 MB/s"}
</span>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import React, { useState, useCallback, useRef } from "react";
export interface WindowInstance {
@@ -0,0 +1,32 @@
import type { FileItem, SSHHost } from "@/types/index";
import type { LogEntry } from "@/types/connection-log.ts";
export interface FileManagerProps {
initialHost?: SSHHost | null;
onClose?: () => void;
}
export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
export type SSHConnectionError = Error & {
connectionLogs?: ConnectionLogPayload[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string;
reason?: "no_keyboard" | "auth_failed" | "timeout";
};
export interface CreateIntent {
id: string;
type: "file" | "directory";
defaultName: string;
currentName: string;
}
export type PendingSudoOperation =
| { type: "delete"; files: FileItem[] }
| { type: "navigate"; path: string };
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { formatFileSize } from "./file-manager-utils.js";
describe("formatFileSize", () => {
it("returns a dash for undefined or null", () => {
expect(formatFileSize(undefined)).toBe("-");
expect(formatFileSize(null as unknown as number)).toBe("-");
});
it("returns 0 B for zero", () => {
expect(formatFileSize(0)).toBe("0 B");
});
it("formats bytes without decimals", () => {
expect(formatFileSize(512)).toBe("512 B");
expect(formatFileSize(1023)).toBe("1023 B");
});
it("formats kilobytes with one decimal under 10", () => {
expect(formatFileSize(1024)).toBe("1.0 KB");
expect(formatFileSize(1536)).toBe("1.5 KB");
});
it("rounds to whole numbers at or above 10 units", () => {
expect(formatFileSize(10 * 1024)).toBe("10 KB");
expect(formatFileSize(1024 * 1024)).toBe("1.0 MB");
});
it("scales up to larger units", () => {
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.0 GB");
expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
});
});
@@ -0,0 +1,17 @@
export function formatFileSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "@/main-axios";
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useFileSelection } from "./useFileSelection.js";
type FileItem = {
name: string;
type: "file" | "directory" | "link";
path: string;
};
const f = (name: string): FileItem => ({
name,
type: "file",
path: `/dir/${name}`,
});
describe("useFileSelection", () => {
it("single-selects, replacing prior selection", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.selectFile(f("a")));
act(() => result.current.selectFile(f("b")));
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
expect(result.current.getSelectedCount()).toBe(1);
});
it("multi-selects and toggles off on repeat", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.selectFile(f("a"), true));
act(() => result.current.selectFile(f("b"), true));
expect(result.current.getSelectedCount()).toBe(2);
act(() => result.current.selectFile(f("a"), true));
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
});
it("reports isSelected by path", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.selectFile(f("a")));
expect(result.current.isSelected(f("a"))).toBe(true);
expect(result.current.isSelected(f("z"))).toBe(false);
});
it("selects a contiguous range regardless of direction", () => {
const { result } = renderHook(() => useFileSelection());
const files = [f("a"), f("b"), f("c"), f("d")];
act(() => result.current.selectRange(files, files[3], files[1]));
expect(result.current.selectedFiles.map((x) => x.name)).toEqual([
"b",
"c",
"d",
]);
});
it("selects all and clears", () => {
const { result } = renderHook(() => useFileSelection());
const files = [f("a"), f("b")];
act(() => result.current.selectAll(files));
expect(result.current.getSelectedCount()).toBe(2);
act(() => result.current.clearSelection());
expect(result.current.getSelectedCount()).toBe(0);
});
it("toggleSelection adds then removes", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.toggleSelection(f("a")));
expect(result.current.isSelected(f("a"))).toBe(true);
act(() => result.current.toggleSelection(f("a")));
expect(result.current.isSelected(f("a"))).toBe(false);
});
});
@@ -0,0 +1,74 @@
import type { TFunction } from "i18next";
import {
formatDurationMs,
formatTransferMbPerSec,
type TransferTimings,
} from "@/main-axios.ts";
export function createFormatTransferMetrics(t: TFunction) {
return (timings?: TransferTimings): string => {
if (!timings) return "";
const parts: string[] = [];
if (timings.prepareDestMs !== undefined) {
parts.push(
t("transfer.metricsPrepare", {
duration: formatDurationMs(timings.prepareDestMs),
}),
);
}
if (timings.compressMs !== undefined) {
parts.push(
t("transfer.metricsCompress", {
duration: formatDurationMs(timings.compressMs),
}),
);
}
for (const hop of timings.hops ?? []) {
const hopKey =
hop.id === "source_read"
? "transfer.metricsHopSourceRead"
: hop.id === "dest_local_write"
? "transfer.metricsHopDestLocalWrite"
: "transfer.metricsHopDestSftpWrite";
parts.push(
t(hopKey, {
throughput: formatTransferMbPerSec(hop.mbPerSec),
}),
);
}
if (timings.transferMs !== undefined) {
parts.push(
t("transfer.metricsTransfer", {
duration: formatDurationMs(timings.transferMs),
throughput: formatTransferMbPerSec(
timings.endToEndMbPerSec,
timings.transferBytes,
timings.transferMs,
),
}),
);
}
if (timings.extractMs !== undefined) {
parts.push(
t("transfer.metricsExtract", {
duration: formatDurationMs(timings.extractMs),
}),
);
}
if (timings.sourceDeleteMs !== undefined) {
parts.push(
t("transfer.metricsSourceDelete", {
duration: formatDurationMs(timings.sourceDeleteMs),
}),
);
}
if (timings.totalMs !== undefined) {
parts.push(
t("transfer.metricsTotal", {
duration: formatDurationMs(timings.totalMs),
}),
);
}
return parts.join(" · ");
};
}
@@ -0,0 +1,52 @@
const PENDING_KEY = "termix_pending_transfers";
const NOTIFIED_KEY = "termix_notified_transfers";
function readJsonArray(key: string): string[] {
try {
const raw = localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === "string")
: [];
} catch {
return [];
}
}
function writeJsonArray(key: string, values: string[]): void {
localStorage.setItem(key, JSON.stringify(values));
}
export function registerPendingTransfer(transferId: string): void {
const pending = readJsonArray(PENDING_KEY);
if (!pending.includes(transferId)) {
writeJsonArray(PENDING_KEY, [...pending, transferId]);
}
}
export function markTransferNotified(transferId: string): void {
const notified = readJsonArray(NOTIFIED_KEY);
if (!notified.includes(transferId)) {
writeJsonArray(NOTIFIED_KEY, [...notified, transferId].slice(-200));
}
writeJsonArray(
PENDING_KEY,
readJsonArray(PENDING_KEY).filter((id) => id !== transferId),
);
}
export function isTransferNotified(transferId: string): boolean {
return readJsonArray(NOTIFIED_KEY).includes(transferId);
}
export function getPendingTransferIds(): string[] {
return readJsonArray(PENDING_KEY);
}
export function clearStalePendingTransfer(transferId: string): void {
writeJsonArray(
PENDING_KEY,
readJsonArray(PENDING_KEY).filter((id) => id !== transferId),
);
}
@@ -0,0 +1,339 @@
import { toast } from "sonner";
import type { TFunction } from "i18next";
import {
pollTransferUntilComplete,
cancelTransferToHost,
cleanupCancelledTransfer,
retryTransferToHost,
createTransferProgressTracker,
type TransferProgressResponse,
type TransferTimings,
} from "@/main-axios.ts";
import { TransferProgressToast } from "./components/TransferProgressToast.tsx";
import {
markTransferNotified,
registerPendingTransfer,
} from "./transferNotificationStore.ts";
const monitoredTransferIds = new Set<string>();
const TOAST_CLASS = "!pr-10 !pl-4 transfer-progress-toast";
function formatFileSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
function renderTransferProgressToast(
toastId: string | number,
status: TransferProgressResponse,
liveMbPerSec?: number,
onCancel?: () => void,
cancelling?: boolean,
stalled?: boolean,
): void {
toast.loading(
<TransferProgressToast
status={status}
liveMbPerSec={liveMbPerSec}
stalled={stalled}
formatSize={formatFileSize}
onCancel={onCancel}
cancelling={cancelling}
/>,
{ id: toastId, duration: Infinity, className: TOAST_CLASS },
);
}
export function showTransferCompletionToast(
finalStatus: TransferProgressResponse,
t: TFunction,
toastId?: string | number,
formatTransferMetrics?: (timings?: TransferTimings) => string,
): void {
showDefaultCompletionToast(
finalStatus,
toastId ?? `transfer-done-${finalStatus.transferId}`,
t,
formatTransferMetrics,
);
markTransferNotified(finalStatus.transferId);
}
export function isTransferBeingMonitored(transferId: string): boolean {
return monitoredTransferIds.has(transferId);
}
export interface TransferMonitorHandle {
toastId: string | number;
waitForCompletion: Promise<TransferProgressResponse>;
}
export interface BeginTransferMonitoringOptions {
resumed?: boolean;
initialStatus?: Partial<TransferProgressResponse>;
onComplete?: (
finalStatus: TransferProgressResponse,
toastId: string | number,
) => void;
formatTransferMetrics?: (timings?: TransferTimings) => string;
}
function showCancelledTransferToast(
finalStatus: TransferProgressResponse,
toastId: string | number,
t: TFunction,
): void {
const hasPartialDest =
(finalStatus.partialDestRemaining ??
(finalStatus.bytesTransferred ?? 0) > 0) ||
(finalStatus.itemsCompleted ?? 0) > 0;
let description: string | undefined;
if (finalStatus.moveRequested && hasPartialDest) {
description = t("transfer.transferCancelledMoveHint");
} else if (hasPartialDest) {
description = t("transfer.transferCancelledCopyHint");
}
const showCleanupAction = hasPartialDest && !finalStatus.cleanupCompleted;
toast.info(t("transfer.transferCancelled"), {
id: toastId,
description,
className: TOAST_CLASS,
duration: showCleanupAction ? Infinity : undefined,
action: showCleanupAction
? {
label: t("transfer.cleanupDestFiles"),
onClick: () => {
void cleanupCancelledTransfer(finalStatus.transferId)
.then((result) => {
toast.dismiss(toastId);
if (result.failedPaths.length > 0) {
toast.warning(t("transfer.cleanupDestFilesPartial"));
} else if (result.removedPaths.length > 0) {
toast.success(t("transfer.cleanupDestFilesSuccess"));
} else {
toast.info(t("transfer.cleanupDestFilesNothing"));
}
})
.catch((error: unknown) => {
const message =
error instanceof Error
? error.message
: t("fileManager.unknownError");
toast.error(
`${t("transfer.cleanupDestFilesError")}: ${message}`,
);
});
},
}
: undefined,
});
}
function showFailedTransferToast(
finalStatus: TransferProgressResponse,
toastId: string | number,
t: TFunction,
formatTransferMetrics?: (timings?: TransferTimings) => string,
): void {
const hasPartial =
(finalStatus.partialDestRemaining ??
(finalStatus.bytesTransferred ?? 0) > 0) ||
(finalStatus.itemsCompleted ?? 0) > 0;
const descriptionParts: string[] = [];
if (finalStatus.message) {
descriptionParts.push(finalStatus.message);
}
if (finalStatus.retryable && hasPartial) {
descriptionParts.push(t("transfer.transferFailedRetryHint"));
}
const showRetry = finalStatus.retryable === true;
toast.error(t("transfer.transferError"), {
id: toastId,
description:
descriptionParts.length > 0 ? descriptionParts.join(" ") : undefined,
className: TOAST_CLASS,
duration: showRetry ? Infinity : undefined,
action: showRetry
? {
label: t("transfer.retryTransfer"),
onClick: () => {
void retryTransferToHost(finalStatus.transferId)
.then(() => {
toast.dismiss(toastId);
beginTransferProgressMonitoring(finalStatus.transferId, t, {
resumed: true,
formatTransferMetrics,
});
})
.catch((error: unknown) => {
const message =
error instanceof Error
? error.message
: t("fileManager.unknownError");
toast.error(`${t("transfer.retryTransferError")}: ${message}`);
});
},
}
: undefined,
});
}
function showDefaultCompletionToast(
finalStatus: TransferProgressResponse,
toastId: string | number,
t: TFunction,
formatTransferMetrics?: (timings?: TransferTimings) => string,
): void {
if (finalStatus.status === "cancelled") {
showCancelledTransferToast(finalStatus, toastId, t);
return;
}
if (finalStatus.status === "error") {
showFailedTransferToast(finalStatus, toastId, t, formatTransferMetrics);
return;
}
if (finalStatus.status === "partial") {
const failed = finalStatus.failedPaths?.join(", ") || "";
const metrics = formatTransferMetrics?.(finalStatus.timings);
toast.warning(
t("transfer.transferPartialHint", {
paths: failed,
count: finalStatus.failedPaths?.length || 0,
}),
{
id: toastId,
description: metrics || undefined,
className: TOAST_CLASS,
},
);
return;
}
const metrics = formatTransferMetrics?.(finalStatus.timings);
toast.success(t("transfer.transferSuccess"), {
id: toastId,
description: metrics || undefined,
className: TOAST_CLASS,
});
}
export function beginTransferProgressMonitoring(
transferId: string,
t: TFunction,
options: BeginTransferMonitoringOptions = {},
): TransferMonitorHandle | null {
if (monitoredTransferIds.has(transferId)) {
return null;
}
monitoredTransferIds.add(transferId);
registerPendingTransfer(transferId);
const progressTracker = createTransferProgressTracker();
let cancelling = false;
const initialStatus: TransferProgressResponse = {
transferId,
status: "running",
phase: "transferring",
...options.initialStatus,
};
const progressToast = toast.loading(
<TransferProgressToast
status={initialStatus}
formatSize={formatFileSize}
/>,
{
duration: Infinity,
description: options.resumed ? t("transfer.resumedHint") : undefined,
className: TOAST_CLASS,
},
);
const handleCancelTransfer = () => {
if (cancelling) return;
cancelling = true;
renderTransferProgressToast(
progressToast,
{ ...initialStatus, transferId },
undefined,
handleCancelTransfer,
true,
);
void cancelTransferToHost(transferId);
};
const waitForCompletion = pollTransferUntilComplete(
transferId,
(status) => {
const { rate, stalled } = progressTracker.update(status);
renderTransferProgressToast(
progressToast,
status,
rate,
handleCancelTransfer,
cancelling,
stalled,
);
},
250,
)
.then((finalStatus) => {
markTransferNotified(transferId);
if (options.onComplete) {
options.onComplete(finalStatus, progressToast);
} else {
showDefaultCompletionToast(
finalStatus,
progressToast,
t,
options.formatTransferMetrics,
);
}
return finalStatus;
})
.catch((error: unknown) => {
const message =
error instanceof Error ? error.message : t("fileManager.unknownError");
toast.error(`${t("transfer.transferError")}: ${message}`, {
id: progressToast,
className: TOAST_CLASS,
});
markTransferNotified(transferId);
throw error;
})
.finally(() => {
monitoredTransferIds.delete(transferId);
});
renderTransferProgressToast(
progressToast,
initialStatus,
undefined,
handleCancelTransfer,
cancelling,
);
return { toastId: progressToast, waitForCompletion };
}
+2 -6
View File
@@ -124,7 +124,7 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
if (result) setToken(result.token);
})
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
}, [hostId, retryCount]);
}, [hostId, protocol, retryCount, t]);
const handleReconnect = useCallback(() => {
setConnectionError(null);
@@ -236,11 +236,7 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
isVisible={true}
onError={(err) => setConnectionError(err)}
/>
<GuacamoleToolbar
displayRef={displayRef}
protocol={resolvedProtocol}
onReconnect={handleReconnect}
/>
<GuacamoleToolbar displayRef={displayRef} protocol={resolvedProtocol} />
</div>
);
};
@@ -437,6 +437,9 @@ export const GuacamoleDisplay = forwardRef<
onError,
refreshKeyboardHandlers,
rescaleDisplay,
connectionConfig.protocol,
connectionConfig.type,
t,
]);
const hasInitiatedRef = useRef(false);
@@ -520,7 +523,6 @@ export const GuacamoleDisplay = forwardRef<
const h = Math.round(rect.height);
if (w > 0 && h > 0) {
clientRef.current.sendSize(w, h);
rescaleDisplay(true);
}
}
}, 150);
@@ -27,7 +27,6 @@ import { cn } from "@/lib/utils";
interface GuacamoleToolbarProps {
displayRef: React.RefObject<GuacamoleDisplayHandle>;
protocol: "rdp" | "vnc" | "telnet";
onReconnect: () => void;
}
const MODIFIER_KEYSYMS = {
@@ -108,7 +107,6 @@ function TipIconBtn({
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
displayRef,
protocol,
onReconnect,
}) => {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: 0, y: 12 });
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Separator } from "@/components/separator.tsx";
import { Button } from "@/components/button.tsx";
@@ -1,4 +1,4 @@
import { Network, WifiOff } from "lucide-react";
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
@@ -24,6 +24,16 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
};
const interfaces = metricsWithNetwork?.network?.interfaces ?? [];
const ifaceIcon = (name: string) => {
if (/^(wl|wlan|wifi|ath)/.test(name))
return <Wifi className="size-3 text-muted-foreground/50" />;
if (/^(eth|en|enp|eno|ens)/.test(name))
return <Cable className="size-3 text-muted-foreground/50" />;
if (/^(docker|br-|veth|virbr|vlan|bond|tun|tap|wg|lo)/.test(name))
return <Container className="size-3 text-muted-foreground/50" />;
return <Network className="size-3 text-muted-foreground/50" />;
};
return (
<SectionCard
title={t("serverStats.networkInterfaces")}
@@ -46,6 +56,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{ifaceIcon(iface.name)}
<div
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`}
/>
+38 -166
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
useEffect,
useRef,
@@ -33,11 +34,10 @@ import { OPKSSHDialog } from "@/ssh/dialogs/OPKSSHDialog.tsx";
import { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog.tsx";
import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx";
import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
} from "@/lib/terminal-themes.ts";
import type { TerminalConfig } from "@/types";
import "./terminal-global-styles.ts";
import { useTheme } from "@/components/theme-provider.tsx";
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
@@ -52,72 +52,9 @@ import {
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import { toast } from "sonner";
import { Button } from "@/components/button";
// Background/foreground per UI theme for "Termix Default" — must match index.css
const TERMIX_DEFAULT_COLORS: Record<
string,
{ background: string; foreground: string }
> = {
dark: { background: "#0c0d0b", foreground: "#fafafa" },
light: { background: "#ffffff", foreground: "#111210" },
dracula: { background: "#282a36", foreground: "#f8f8f2" },
catppuccin: { background: "#1e1e2e", foreground: "#cdd6f4" },
nord: { background: "#2e3440", foreground: "#eceff4" },
solarized: { background: "#002b36", foreground: "#839496" },
"tokyo-night": { background: "#1a1b26", foreground: "#a9b1d6" },
"one-dark": { background: "#282c34", foreground: "#abb2bf" },
gruvbox: { background: "#282828", foreground: "#ebdbb2" },
};
function resolveTermixThemeColors(activeTheme: string, appTheme: string) {
if (activeTheme !== "termix") {
return (
TERMINAL_THEMES[activeTheme]?.colors || TERMINAL_THEMES.termixDark.colors
);
}
let resolvedUiTheme = appTheme;
if (appTheme === "system") {
resolvedUiTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
const uiColors =
TERMIX_DEFAULT_COLORS[resolvedUiTheme] ?? TERMIX_DEFAULT_COLORS.dark;
const base = TERMINAL_THEMES.termixDark.colors;
return {
...base,
background: uiColors.background,
foreground: uiColors.foreground,
cursor: uiColors.foreground,
cursorAccent: uiColors.background,
};
}
interface HostConfig {
id?: number;
instanceId?: string;
restoredSessionId?: string | null;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
credentialId?: number;
terminalConfig?: TerminalConfig;
[key: string]: unknown;
}
interface TerminalHandle {
disconnect: () => void;
reconnect: () => void;
fit: () => void;
sendInput: (data: string) => void;
notifyResize: () => void;
refresh: () => void;
}
import { resolveTermixThemeColors } from "./terminal-theme.ts";
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
export type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
type HostKeyVerificationData = Omit<
React.ComponentProps<typeof HostKeyVerificationDialog>,
@@ -125,7 +62,7 @@ type HostKeyVerificationData = Omit<
>;
interface SSHTerminalProps {
hostConfig: HostConfig;
hostConfig: TerminalHostConfig;
isVisible: boolean;
title?: string;
showTitle?: boolean;
@@ -203,8 +140,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
"no_keyboard" | "auth_failed" | "timeout"
>("no_keyboard");
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
useState(false);
const [, setKeyboardInteractiveDetected] = useState(false);
const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false);
const [warpgateAuthUrl, setWarpgateAuthUrl] = useState<string>("");
const [warpgateSecurityKey, setWarpgateSecurityKey] = useState<string>("");
@@ -222,7 +158,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const opksshFailedRef = useRef(false);
const currentHostIdRef = useRef<number | null>(null);
const currentHostConfigRef = useRef<HostConfig | null>(null);
const currentHostConfigRef = useRef<TerminalHostConfig | null>(null);
const [hostKeyVerification, setHostKeyVerification] = useState<{
isOpen: boolean;
@@ -233,7 +169,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const sessionIdRef = useRef<string | null>(null);
const isAttachingSessionRef = useRef<boolean>(false);
// Consumed on first connectToHost call so retries don't re-attempt a stale session
const pendingRestoredSessionIdRef = useRef<string | null>(hostConfig.restoredSessionId ?? null);
const pendingRestoredSessionIdRef = useRef<string | null>(
hostConfig.restoredSessionId ?? null,
);
const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{
sessions: Array<{
name: string;
@@ -1052,7 +990,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.write(outputData);
const sudoPasswordPattern =
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required)/i;
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required|password for [^\n]*:\s*$|Password:\s*$)/i;
const hasSudoPw =
hostConfig.terminalConfig?.sudoPassword ||
hostConfig.password ||
@@ -1484,7 +1422,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
sessionIdRef.current = msg.sessionId;
if (hostConfig.instanceId) {
import("@/main-axios").then(({ patchOpenTab }) => {
patchOpenTab(hostConfig.instanceId!, { backendSessionId: msg.sessionId }).catch(() => {});
patchOpenTab(hostConfig.instanceId!, {
backendSessionId: msg.sessionId,
}).catch(() => {});
});
}
} else if (msg.type === "sessionAttached") {
@@ -1520,7 +1460,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
wasSessionExpiredRef.current = true;
if (hostConfig.instanceId) {
import("@/main-axios").then(({ patchOpenTab }) => {
patchOpenTab(hostConfig.instanceId!, { backendSessionId: null }).catch(() => {});
patchOpenTab(hostConfig.instanceId!, {
backendSessionId: null,
}).catch(() => {});
});
}
if (webSocketRef.current) {
@@ -1993,6 +1935,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.unicode.activeVersion = "11";
terminal.open(xtermRef.current);
document.fonts.ready.then(() => {
terminal.refresh(0, terminal.rows - 1);
fitAddon.fit();
});
terminal.attachCustomWheelEventHandler((ev) => {
const cfg = {
@@ -2135,8 +2081,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
isMountedRef.current = true;
const currentHostId = hostConfig.id;
const currentInstanceId = hostConfig.instanceId;
return () => {
if (!isMountedRef.current) {
return;
@@ -2359,20 +2303,29 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
e.preventDefault();
e.stopPropagation();
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") === "true";
if (!autocompleteEnabled) {
const sendTabToShell = () => {
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: "\t" }),
);
}
};
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") === "true";
if (!autocompleteEnabled) {
sendTabToShell();
return false;
}
const currentCmd = getCurrentCommandRef.current().trim();
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
if (currentCmd.length === 0) {
sendTabToShell();
return false;
}
if (webSocketRef.current?.readyState === 1) {
const matches = autocompleteHistory.current
.filter(
(cmd) =>
@@ -2432,6 +2385,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
setShowAutocomplete(true);
} else {
sendTabToShell();
}
}
return false;
@@ -2474,7 +2429,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminal, hostConfig.id, isVisible, isConnected, isConnecting]);
useEffect(() => {
@@ -2769,85 +2723,3 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
);
},
);
const style = document.createElement("style");
style.innerHTML = `
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
font-weight: bold;
font-style: italic;
font-display: swap;
}
.xterm .xterm-viewport::-webkit-scrollbar {
width: 8px;
background: transparent;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.3);
border-radius: 4px;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.5);
}
.xterm .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.3) transparent;
}
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
}
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.5);
}
.dark .xterm .xterm-viewport {
scrollbar-color: rgba(255,255,255,0.3) transparent;
}
.xterm {
font-feature-settings: "liga" 0, "calt" 0;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.xterm .xterm-screen {
font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
font-variant-ligatures: none;
}
.xterm .xterm-screen .xterm-char {
font-feature-settings: "liga" 0, "calt" 0;
}
`;
document.head.appendChild(style);
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import React, {
createContext,
useContext,
@@ -0,0 +1,81 @@
const style = document.createElement("style");
style.innerHTML = `
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
font-weight: bold;
font-style: italic;
font-display: swap;
}
.xterm .xterm-viewport::-webkit-scrollbar {
width: 8px;
background: transparent;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.3);
border-radius: 4px;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.5);
}
.xterm .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.3) transparent;
}
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
}
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.5);
}
.dark .xterm .xterm-viewport {
scrollbar-color: rgba(255,255,255,0.3) transparent;
}
.xterm {
font-feature-settings: "liga" 0, "calt" 0;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.xterm .xterm-screen {
font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
font-variant-ligatures: none;
}
.xterm .xterm-screen .xterm-char {
font-feature-settings: "liga" 0, "calt" 0;
}
`;
document.head.appendChild(style);
@@ -0,0 +1,44 @@
import { TERMINAL_THEMES } from "@/lib/terminal-themes.ts";
// Background/foreground per UI theme for "Termix Default" - must match index.css
const TERMIX_DEFAULT_COLORS: Record<
string,
{ background: string; foreground: string }
> = {
dark: { background: "#0c0d0b", foreground: "#fafafa" },
light: { background: "#ffffff", foreground: "#111210" },
dracula: { background: "#282a36", foreground: "#f8f8f2" },
catppuccin: { background: "#1e1e2e", foreground: "#cdd6f4" },
nord: { background: "#2e3440", foreground: "#eceff4" },
solarized: { background: "#002b36", foreground: "#839496" },
"tokyo-night": { background: "#1a1b26", foreground: "#a9b1d6" },
"one-dark": { background: "#282c34", foreground: "#abb2bf" },
gruvbox: { background: "#282828", foreground: "#ebdbb2" },
};
export function resolveTermixThemeColors(
activeTheme: string,
appTheme: string,
) {
if (activeTheme !== "termix") {
return (
TERMINAL_THEMES[activeTheme]?.colors || TERMINAL_THEMES.termixDark.colors
);
}
let resolvedUiTheme = appTheme;
if (appTheme === "system") {
resolvedUiTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
const uiColors =
TERMIX_DEFAULT_COLORS[resolvedUiTheme] ?? TERMIX_DEFAULT_COLORS.dark;
const base = TERMINAL_THEMES.termixDark.colors;
return {
...base,
background: uiColors.background,
foreground: uiColors.foreground,
cursor: uiColors.foreground,
cursorAccent: uiColors.background,
};
}
@@ -0,0 +1,27 @@
import type { TerminalConfig } from "@/types";
export interface TerminalHostConfig {
id?: number;
instanceId?: string;
restoredSessionId?: string | null;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
credentialId?: number;
terminalConfig?: TerminalConfig;
[key: string]: unknown;
}
export interface TerminalHandle {
disconnect: () => void;
reconnect: () => void;
fit: () => void;
sendInput: (data: string) => void;
notifyResize: () => void;
refresh: () => void;
}
+24 -16
View File
@@ -48,14 +48,12 @@ function statusLabel(status: TunnelStatus | undefined): string {
function TunnelCard({
host,
index,
tunnel,
status,
isActing,
onAction,
}: {
host: SSHHost;
index: number;
tunnel: TunnelConnection;
status: TunnelStatus | undefined;
isActing: boolean;
@@ -261,7 +259,7 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
} catch {
/* ignore */
}
}, [host?.id]);
}, [host]);
useEffect(() => {
fetchHost();
@@ -284,7 +282,7 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
logActivity("tunnel", sshHost.id, name).catch(() => {
activityLoggedRef.current = false;
});
}, [sshHost?.id]);
}, [sshHost]);
const handleAction = async (
action: "connect" | "disconnect" | "cancel",
@@ -298,24 +296,31 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
setTunnelActions((prev) => ({ ...prev, [name]: true }));
try {
if (action === "connect") {
const allHosts = await getSSHHosts();
const endpointSsh = allHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
const isDirect =
!tunnel.endpointHost ||
tunnel.endpointHost === "127.0.0.1" ||
tunnel.endpointHost === "localhost";
let endpointSsh: SSHHost | undefined;
if (!isDirect) {
const allHosts = await getSSHHosts();
endpointSsh = allHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
}
await connectTunnel({
name,
scope: tunnel.scope ?? "s2s",
mode:
tunnel.mode ??
(tunnel.tunnelType as "local" | "remote" | "dynamic") ??
"remote",
"local",
tunnelType:
tunnel.tunnelType ??
(tunnel.mode === "local" || tunnel.mode === "remote"
? tunnel.mode
: "remote"),
: "local"),
bindHost: tunnel.bindHost,
targetHost: tunnel.targetHost,
sourceHostId: sshHost.id,
@@ -334,9 +339,13 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
sshHost.authType === "key" ? sshHost.keyType : undefined,
sourceCredentialId: sshHost.credentialId,
endpointHost: tunnel.endpointHost ?? "",
endpointIP: endpointSsh?.ip ?? tunnel.endpointHost ?? "",
endpointSSHPort: endpointSsh?.port ?? 22,
endpointUsername: endpointSsh?.username ?? "",
endpointIP: isDirect
? sshHost.ip
: (endpointSsh?.ip ?? tunnel.endpointHost ?? ""),
endpointSSHPort: isDirect ? sshHost.port : (endpointSsh?.port ?? 22),
endpointUsername: isDirect
? sshHost.username
: (endpointSsh?.username ?? ""),
endpointPassword:
endpointSsh?.authType === "password"
? endpointSsh.password
@@ -428,7 +437,6 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
<TunnelCard
key={name}
host={sshHost}
index={index}
tunnel={tunnel}
status={tunnelStatuses[name]}
isActing={tunnelActions[name] ?? false}
@@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import {
getTunnelTypeForMode,
getTunnelPortLabels,
getTunnelModeDescription,
} from "./tunnel-form-utils.js";
// A fake translate that echoes the key plus any interpolation args so we can
// assert which translation key + payload the helpers chose.
const t = (key: string, opts?: Record<string, string | number>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key;
describe("getTunnelTypeForMode", () => {
it("maps remote to remote and everything else to local", () => {
expect(getTunnelTypeForMode("remote")).toBe("remote");
expect(getTunnelTypeForMode("local")).toBe("local");
expect(getTunnelTypeForMode("dynamic")).toBe("local");
});
});
describe("getTunnelPortLabels", () => {
it("labels client local mode source/endpoint ports", () => {
const labels = getTunnelPortLabels("client", "local", t);
expect(labels.sourcePortLabel).toBe("tunnels.localPort");
expect(labels.endpointPortLabel).toBe("tunnels.remotePort");
});
it("swaps labels for client remote mode", () => {
const labels = getTunnelPortLabels("client", "remote", t);
expect(labels.sourcePortLabel).toBe("tunnels.remotePort");
expect(labels.endpointPortLabel).toBe("tunnels.localPort");
});
it("uses host/endpoint labels in server scope", () => {
const labels = getTunnelPortLabels("server", "local", t);
expect(labels.sourcePortLabel).toBe("tunnels.currentHostPort");
expect(labels.endpointPortLabel).toBe("tunnels.endpointPort");
});
});
describe("getTunnelModeDescription", () => {
const ports = { sourcePort: 8080, endpointPort: 9090 };
it("selects the client dynamic description with only the source port", () => {
const desc = getTunnelModeDescription("client", "dynamic", ports, t);
expect(desc).toContain("tunnels.forwardDescriptionClientDynamic");
expect(desc).toContain("8080");
expect(desc).not.toContain("9090");
});
it("selects the client local description with both ports", () => {
const desc = getTunnelModeDescription("client", "local", ports, t);
expect(desc).toContain("tunnels.forwardDescriptionClientLocal");
expect(desc).toContain("8080");
expect(desc).toContain("9090");
});
it("selects the server remote description", () => {
const desc = getTunnelModeDescription("server", "remote", ports, t);
expect(desc).toContain("tunnels.forwardDescriptionServerRemote");
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useIsMobile } from "./use-mobile.js";
function setViewport(width: number) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: width,
});
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: width < 768,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("useIsMobile", () => {
it("is true below the 768px breakpoint", () => {
setViewport(500);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it("is false at or above the breakpoint", () => {
setViewport(1024);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it("treats exactly 768 as not mobile", () => {
setViewport(768);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
});
+2 -4
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import React, {
createContext,
useContext,
@@ -132,10 +133,7 @@ export function ServerStatusProvider({
}
}, [isAuthenticated]);
const stableEnabledHostIds = useMemo(
() => enabledHostIds,
[[...enabledHostIds].sort().join(",")],
);
const stableEnabledHostIds = useMemo(() => enabledHostIds, [enabledHostIds]);
const getStatus = useCallback(
(hostId: number): StatusValue => {
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect, afterEach } from "vitest";
import { getBasePath } from "./base-path.js";
const win = window as unknown as Record<string, unknown>;
afterEach(() => {
delete win.__TERMIX_BASE_PATH__;
});
describe("getBasePath", () => {
it("returns empty string when no runtime override is set (default base)", () => {
// Vite test env BASE_URL is "/" which the helper normalizes to "".
expect(getBasePath()).toBe("");
});
it("uses the runtime override when present", () => {
win.__TERMIX_BASE_PATH__ = "/termix";
expect(getBasePath()).toBe("/termix");
});
it("strips a trailing slash from the runtime override", () => {
win.__TERMIX_BASE_PATH__ = "/termix/";
expect(getBasePath()).toBe("/termix");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, afterEach } from "vitest";
import { isElectron } from "./electron.js";
const win = window as unknown as Record<string, unknown>;
afterEach(() => {
delete win.IS_ELECTRON;
delete win.electronAPI;
});
describe("isElectron", () => {
it("returns false in a plain browser window", () => {
expect(isElectron()).toBe(false);
});
it("returns true when IS_ELECTRON flag is set", () => {
win.IS_ELECTRON = true;
expect(isElectron()).toBe(true);
});
it("returns true when electronAPI is present", () => {
win.electronAPI = {};
expect(isElectron()).toBe(true);
});
it("returns true when electronAPI.isElectron is true", () => {
win.electronAPI = { isElectron: true };
expect(isElectron()).toBe(true);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { apiLogger } from "./frontend-logger.js";
// The formatting helpers (status/performance icons, URL sanitization) are
// private, so we exercise them through the public request* methods and assert
// on what gets written to the console.
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let groupSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
groupSpy = vi.spyOn(console, "group").mockImplementation(() => {});
vi.spyOn(console, "groupEnd").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
function allLoggedText(): string {
return logSpy.mock.calls
.concat(errorSpy.mock.calls)
.concat(groupSpy.mock.calls)
.map((args) => args.join(" "))
.join("\n");
}
describe("frontend logger request success", () => {
it("includes the success status icon and a fast-performance icon", () => {
apiLogger.requestSuccess("get", "https://x.test/api", 200, 50);
const text = allLoggedText();
expect(text).toContain("✅"); // 2xx status icon
expect(text).toContain("⚡"); // <100ms performance icon
expect(text).toContain("200");
});
it("uses the slow-performance icon for long requests", () => {
apiLogger.requestSuccess("get", "https://x.test/api", 200, 4000);
expect(allLoggedText()).toContain("🐌");
});
});
describe("frontend logger error mapping", () => {
it("uses a client-error icon for 4xx responses", () => {
apiLogger.requestError("get", "https://x.test/api", 404, "Not Found", 30);
expect(allLoggedText()).toContain("⚠️");
});
it("uses a server-error icon for 5xx responses", () => {
apiLogger.requestError("get", "https://x.test/api", 500, "Boom", 30);
expect(allLoggedText()).toContain("❌");
});
});
describe("frontend logger URL sanitization", () => {
it("strips password/token query params from logged URLs", () => {
apiLogger.requestStart("get", "https://x.test/login?password=hunter2");
const text = allLoggedText();
expect(text).not.toContain("hunter2");
});
it("keeps a normal URL intact", () => {
apiLogger.requestStart("get", "https://x.test/api/hosts");
expect(allLoggedText()).toContain("x.test/api/hosts");
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { highlightTerminalOutput } from "./terminal-syntax-highlighter.js";
const ESC = "\x1b";
describe("highlightTerminalOutput", () => {
it("returns empty/whitespace text unchanged", () => {
expect(highlightTerminalOutput("")).toBe("");
expect(highlightTerminalOutput(" ")).toBe(" ");
});
it("wraps error keywords in red ANSI codes", () => {
const out = highlightTerminalOutput("Something ERROR happened");
expect(out).toContain(ESC + "[91m");
expect(out).toContain(ESC + "[0m");
expect(out).toContain("ERROR");
});
it("highlights IPv4 addresses", () => {
const out = highlightTerminalOutput("connect to 192.168.1.10 now");
expect(out).toContain("192.168.1.10");
expect(out).toContain(ESC + "[35m"); // magenta
});
it("leaves text with no matchable tokens unchanged", () => {
const plain = "just a normal sentence here";
expect(highlightTerminalOutput(plain)).toBe(plain);
});
it("does not re-highlight text that already contains many ANSI codes", () => {
let heavy = "";
for (let i = 0; i < 12; i++) heavy += `${ESC}[32mgreen${ESC}[0m `;
expect(highlightTerminalOutput(heavy)).toBe(heavy);
});
it("leaves text ending in an incomplete ANSI escape untouched", () => {
const partial = `loading${ESC}[`;
expect(highlightTerminalOutput(partial)).toBe(partial);
});
it("does not exceed MAX_LINE_LENGTH processing on huge lines", () => {
const huge = "ERROR " + "x".repeat(6000);
// Over the 5000 char cap, highlightPlainText returns the input unchanged.
expect(highlightTerminalOutput(huge)).toBe(huge);
});
});
+1 -18
View File
@@ -1,9 +1,8 @@
import type {
AccentColorId,
DashboardCardConfig,
FontSizeId,
SplitMode,
} from "./types";
} from "@/types/ui-types";
export const DASHBOARD_CARDS: DashboardCardConfig[] = [
{
@@ -59,12 +58,6 @@ export const ACCENT_PRESET_COLORS = [
{ label: "Lime", value: "#84cc16" },
];
export const ACCENT_COLORS = ACCENT_PRESET_COLORS.map((c) => ({
id: c.label.toLowerCase() as AccentColorId,
label: c.label,
value: c.value,
}));
export function applyAccentColor(colorValue: string) {
document.documentElement.style.setProperty("--accent-brand", colorValue);
}
@@ -114,13 +107,3 @@ export const PANE_COUNTS: Record<SplitMode, number> = {
"5-way": 5,
"6-way": 6,
};
export const PANE_LAYOUTS: Record<SplitMode, string> = {
none: "",
"2-way": "grid-cols-2 grid-rows-1",
"3-way": "grid-cols-2 grid-rows-2",
"3-way-horizontal": "grid-cols-2 grid-rows-2",
"4-way": "grid-cols-2 grid-rows-2",
"5-way": "grid-cols-3 grid-rows-2",
"6-way": "grid-cols-3 grid-rows-2",
};
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { cn } from "./utils.js";
describe("cn", () => {
it("joins class names", () => {
expect(cn("a", "b")).toBe("a b");
});
it("drops falsy values", () => {
expect(cn("a", false, null, undefined, "b")).toBe("a b");
});
it("merges conflicting tailwind classes, keeping the last", () => {
expect(cn("p-2", "p-4")).toBe("p-4");
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
});
it("supports conditional object syntax", () => {
expect(cn("base", { active: true, hidden: false })).toBe("base active");
});
});
+111 -1
View File
@@ -38,6 +38,8 @@
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)",
"changeServer": "Change Server",
"mustIncludeProtocol": "Server URL must start with http:// or https://",
"allowInvalidCertificate": "Allow invalid certificate",
"allowInvalidCertificateDesc": "Use only for trusted self-hosted servers with self-signed or IP-address certificates.",
"useEmbedded": "Use Local Server",
"embeddedDesc": "Run Termix with the built-in local server (no remote server needed)",
"embeddedConnecting": "Connecting to local server...",
@@ -356,6 +358,7 @@
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"sameHost": "This host (direct tunnel)",
"endpointHost": "Endpoint Host",
"endpointPort": "Endpoint Port",
"bindHost": "Bind Host",
@@ -465,6 +468,8 @@
"vncUrlCopied": "VNC URL copied",
"telnetUrlCopied": "Telnet URL copied",
"remoteDesktopUrlCopied": "Remote Desktop URL copied",
"expandActions": "Expand actions",
"collapseActions": "Collapse actions",
"wakeOnLanAction": "Wake on LAN",
"wakeOnLanSuccess": "Magic packet sent to {{name}}",
"wakeOnLanError": "Failed to send magic packet",
@@ -542,6 +547,7 @@
"checkingHostStatuses": "Checking host statuses...",
"pinnedSection": "Pinned",
"hostsExported": "Hosts exported successfully",
"exportFailed": "Failed to export hosts",
"sampleDownloaded": "Sample file downloaded",
"failedToDeleteCredential2": "Failed to delete credential",
"noFolderOption": "(No folder)",
@@ -1143,6 +1149,107 @@
"loadingPdf": "Loading PDF...",
"loadingPage": "Loading page..."
},
"transfer": {
"copyToHost": "Copy to host…",
"moveToHost": "Move to host…",
"copyItemsToHost": "Copy {{count}} items to host…",
"moveItemsToHost": "Move {{count}} items to host…",
"noHostsConnected": "No other file-manager hosts available.",
"noHostsConnectedHint": "Add another SSH host with File Manager enabled in Host Manager.",
"selectDestinationHost": "Select destination host",
"destinationPath": "Destination path",
"recentDestinations": "Recent destinations",
"collapseRecentDestinations": "Collapse recent destinations",
"expandRecentDestinations": "Expand recent destinations",
"browseFolders": "Browse destination folders",
"browseDestination": "Browse or enter path",
"confirmCopy": "Copy",
"confirmMove": "Move",
"transferring": "Transferring…",
"compressing": "Compressing…",
"extracting": "Extracting…",
"transferringItems": "Transferring {{current}} of {{total}} items…",
"transferSuccess": "Transfer complete",
"transferError": "Transfer failed",
"transferPartial": "Transfer completed with {{count}} errors",
"transferPartialHint": "Could not transfer: {{paths}}",
"itemsSummary": "{{count}} items",
"destMustBeDirectory": "Destination must be a directory for multi-item transfers",
"selectThisFolder": "Select this folder",
"browsePathWillBeCreated": "This folder does not exist yet. It will be created when the transfer starts.",
"browsePathError": "Could not open this path on the destination host.",
"goUp": "Go up",
"copyFolderToHost": "Copy folder to host…",
"moveFolderToHost": "Move folder to host…",
"hostReady": "Ready",
"hostConnecting": "Connecting…",
"hostDisconnected": "Not connected",
"hostAuthRequired": "Authentication required — open File Manager on this host first",
"hostConnectionFailed": "Connection failed",
"metricsTitle": "Transfer timings",
"metricsPrepare": "Prepare destination: {{duration}}",
"metricsCompress": "Compress on source: {{duration}}",
"metricsHopSourceRead": "Source → server: {{throughput}}",
"metricsHopDestSftpWrite": "Server → dest (SFTP): {{throughput}}",
"metricsHopDestLocalWrite": "Server → dest (local): {{throughput}}",
"metricsTransfer": "End-to-end: {{throughput}} ({{duration}})",
"metricsExtract": "Extract on destination: {{duration}}",
"metricsSourceDelete": "Remove from source: {{duration}}",
"metricsTotal": "Total: {{duration}}",
"progressCompressing": "Compressing on source host…",
"progressExtracting": "Extracting on destination…",
"progressTransferring": "Transferring data…",
"progressReconnecting": "Reconnecting…",
"parallelSegmentsLabel": "Parallel transfer lanes",
"parallelSegmentsOption": "{{count}} lanes",
"parallelSegmentsHint": "Large files are split into 256 MB chunks. Multiple lanes use separate connections (like starting several transfers) for higher total throughput.",
"progressTotalSpeed": "{{speed}} total ({{lanes}} lanes)",
"progressTransferringItems": "Transferring files ({{current}} of {{total}})…",
"progressBytes": "{{transferred}} / {{total}}",
"progressItems": "{{current}} / {{total}} files",
"sourceNotDeletedPartial": "Source files kept (partial transfer)",
"jumpHostLimitation": "Both hosts must be reachable from the Termix server. Direct host-to-host routing is not supported.",
"cancel": "Cancel",
"methodLabel": "Transfer method",
"methodAuto": "Auto",
"methodTar": "Tar archive",
"methodItemSftp": "Per-file SFTP",
"methodAutoHint": "Picks tar or per-file SFTP based on file count, size, and compressibility. Single files always use streaming SFTP.",
"methodTarHint": "Compress on source, transfer one archive, extract on destination. Requires tar on both Unix hosts.",
"methodItemSftpHint": "Transfer each file individually over SFTP. Works on all hosts including Windows.",
"methodPreviewLoading": "Calculating transfer method…",
"methodPreviewError": "Could not preview transfer method. The server will still pick a method when you start.",
"methodPreviewWillUseTar": "Will use: Tar archive",
"methodPreviewWillUseItemSftp": "Will use: Per-file SFTP",
"methodPreviewScanSummary": "{{fileCount}} files, {{totalSize}} total (scanned on source host).",
"methodItemSftpLimitation": "Each file uses the same SFTP stream as a single-file copy, one after another. Progress is combined across all files, so the bar moves slowly during large files.",
"methodReason": {
"user_item_sftp": "You chose per-file SFTP.",
"user_tar": "You chose tar archive.",
"tar_unavailable": "Tar is not available on one or both hosts — per-file SFTP will be used instead.",
"windows_host": "A Windows host is involved — tar is not used.",
"auto_multi_large": "Auto: multiple files including a large file ({{largestSize}}) with compressible data — tar bundles into one transfer.",
"auto_single_large_in_archive": "Auto: one large file ({{largestSize}}) in this set — per-file SFTP.",
"auto_many_incompressible": "Auto: mostly incompressible data — per-file SFTP.",
"auto_many_files": "Auto: many files ({{fileCount}}) — tar reduces per-file overhead.",
"auto_default": "Auto: per-file SFTP for this set."
},
"progressCancel": "Cancel",
"progressCancelling": "Cancelling…",
"progressStalled": "Stalled",
"resumedHint": "Reconnected to an active transfer started in another window.",
"transferCancelled": "Transfer cancelled",
"transferCancelledCopyHint": "Partial files may remain on the destination.",
"transferCancelledMoveHint": "Partial files may remain on the destination. Source files were not removed.",
"cleanupDestFiles": "Clean up destination",
"cleanupDestFilesSuccess": "Removed partial files from the destination",
"cleanupDestFilesPartial": "Some partial files could not be removed",
"cleanupDestFilesNothing": "Nothing to clean up on the destination",
"cleanupDestFilesError": "Cleanup failed",
"retryTransfer": "Retry",
"retryTransferError": "Retry failed",
"transferFailedRetryHint": "Partial data was kept on the destination. Retry will resume when the connection is back."
},
"tunnels": {
"noSshTunnels": "No SSH Tunnels",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
@@ -1768,6 +1875,9 @@
"linkAccountTargetUsername": "Target Username",
"linkAccountTargetPlaceholder": "Enter the local account username to link to",
"linkAccounts": "Link Accounts",
"linkAccountSuccess": "OIDC account linked to \"{{username}}\"",
"linkAccountFailed": "Failed to link OIDC account",
"linkAccountInProgress": "Linking...",
"saving": "Saving...",
"updateRegistrationFailed": "Failed to update registration setting",
"updatePasswordLoginFailed": "Failed to update password login setting",
@@ -1980,7 +2090,7 @@
"showHostTags": "Show Host Tags",
"showHostTagsDesc": "Display tags in host list",
"hostTrayOnClick": "Click to Expand Host Actions",
"hostTrayOnClickDesc": "Require a click to expand host actions instead of hover",
"hostTrayOnClickDesc": "Always show connection buttons; click to expand management options instead of hover",
"pinAppRail": "Pin App Rail",
"pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover",
"settingsSnippets": "Snippets",
+3
View File
@@ -38,6 +38,8 @@
"helpText": "输入您的 Termix 服务器运行所对应的 URL(例如,http://localhost:30001 或 https://your-server.com",
"changeServer": "变更服务器",
"mustIncludeProtocol": "服务器 URL 必须以 http:// 或 https:// 开头。",
"allowInvalidCertificate": "允许无效证书",
"allowInvalidCertificateDesc": "仅用于可信的自托管服务器,例如自签名证书或 IP 地址证书。",
"useEmbedded": "使用本地服务器",
"embeddedDesc": "使用内置的本地服务器运行 Termixed (不需要远程服务器)",
"embeddedConnecting": "正在连接本地服务器...",
@@ -356,6 +358,7 @@
"tunnelModeLocalDesc": "将本地端口转发到远程服务器上的端口(或其主机可访问)。",
"tunnelModeRemoteDesc": "将远程服务器上的端口转回您机器上的本地端口。",
"tunnelModeDynamicDesc": "在本地端口上创建一个 SOCKS5 代理, 用于动态端口转发.",
"sameHost": "本机(直接隧道)",
"endpointHost": "端点主机",
"endpointPort": "端点端口",
"bindHost": "绑定主机",
+550 -3588
View File
File diff suppressed because it is too large Load Diff
+23 -26
View File
@@ -25,7 +25,6 @@ import {
Network,
User,
KeyRound,
LayoutDashboard,
Monitor,
MousePointerClick,
Clock,
@@ -33,13 +32,13 @@ import {
Pencil,
} from "lucide-react";
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
import type { Host } from "@/types/ui-types";
import type { Host, TabType } from "@/types/ui-types";
interface CommandPaletteProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
hosts: Host[];
onOpenTab: (type: any, label?: string, pendingEvent?: string) => void;
onOpenTab: (type: TabType, label?: string, pendingEvent?: string) => void;
}
const ACTIVITY_ICONS: Record<string, React.ReactNode> = {
@@ -53,7 +52,7 @@ const ACTIVITY_ICONS: Record<string, React.ReactNode> = {
rdp: <Monitor className="size-3.5" />,
};
const ACTIVITY_TAB_TYPE: Record<string, string> = {
const ACTIVITY_TAB_TYPE: Record<string, TabType> = {
terminal: "terminal",
file_manager: "files",
server_stats: "stats",
@@ -64,7 +63,11 @@ const ACTIVITY_TAB_TYPE: Record<string, string> = {
rdp: "rdp",
};
function getSshActions(host: Host) {
function getSshActions(host: Host): {
type: TabType;
icon: React.ElementType;
label: string;
}[] {
const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
return [
host.enableTerminal !== false && {
@@ -81,7 +84,7 @@ function getSshActions(host: Host) {
host.enableTunnel && { type: "tunnel", icon: Network, label: "Tunnels" },
metricsEnabled && { type: "stats", icon: Activity, label: "Stats" },
].filter(Boolean) as {
type: string;
type: TabType;
icon: React.ElementType;
label: string;
}[];
@@ -187,23 +190,6 @@ export function CommandPalette({
heading={t("commandPalette.quickActions")}
className="px-2"
>
<CommandItem
onSelect={() => handleAction(() => onOpenTab("host-manager"))}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
<LayoutDashboard className="size-4 text-accent-brand" />
</div>
<div className="flex flex-col flex-1">
<span className="text-sm font-semibold">
{t("commandPalette.hostManager")}
</span>
<span className="text-xs text-muted-foreground">
{t("commandPalette.hostManagerDesc")}
</span>
</div>
</CommandItem>
<CommandItem
onSelect={() =>
handleAction(() =>
@@ -302,7 +288,7 @@ export function CommandPalette({
onSelect={() =>
handleAction(() =>
onOpenTab(
ACTIVITY_TAB_TYPE[item.type] as any,
ACTIVITY_TAB_TYPE[item.type],
item.hostName,
),
)
@@ -351,7 +337,18 @@ export function CommandPalette({
<CommandItem
key={i}
onSelect={() =>
handleAction(() => onOpenTab("terminal", host.name))
handleAction(() => {
const type = host.enableSsh
? "terminal"
: host.enableRdp
? "rdp"
: host.enableVnc
? "vnc"
: host.enableTelnet
? "telnet"
: "terminal";
onOpenTab(type, host.name);
})
}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
@@ -388,7 +385,7 @@ export function CommandPalette({
onClick={(e) => {
e.stopPropagation();
handleAction(() =>
onOpenTab(type as any, host.name),
onOpenTab(type, host.name),
);
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
+3 -1
View File
@@ -381,7 +381,9 @@ export function TabBar({
<div className="flex items-center gap-2 flex-1 min-w-0">
{tabIcon(tab.type)}
<span className="truncate">
{tab.type === "dashboard" ? t("nav.dashboard") : tab.label}
{tab.type === "dashboard"
? t("nav.dashboard")
: tab.label}
</span>
</div>
{tab.type !== "dashboard" && (
+151 -139
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import React, {
createContext,
useContext,
@@ -40,7 +41,6 @@ interface TabContextType {
const TabContext = createContext<TabContextType | undefined>(undefined);
export function useTabs() {
const context = useContext(TabContext);
if (context === undefined) {
@@ -80,7 +80,9 @@ export function clearTermixSessionStorage() {
export function TabProvider({ children }: TabProviderProps) {
const { t } = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([{ id: 1, type: "home", title: "Home" }]);
const [tabs, setTabs] = useState<Tab[]>([
{ id: 1, type: "home", title: "Home" },
]);
const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const [previewTerminalTheme, setPreviewTerminalTheme] = useState<
@@ -106,151 +108,158 @@ export function TabProvider({ children }: TabProviderProps) {
);
}, [t]);
function computeUniqueTitle(
tabType: Tab["type"],
desiredTitle: string | undefined,
): string {
const defaultTitle =
tabType === "server_stats"
? t("nav.serverStats")
: tabType === "file_manager"
? t("nav.fileManager")
: tabType === "tunnel"
? t("nav.tunnels")
: tabType === "docker"
? t("nav.docker")
: t("nav.terminal");
const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
const computeUniqueTitle = useCallback(
(tabType: Tab["type"], desiredTitle: string | undefined): string => {
const defaultTitle =
tabType === "server_stats"
? t("nav.serverStats")
: tabType === "file_manager"
? t("nav.fileManager")
: tabType === "tunnel"
? t("nav.tunnels")
: tabType === "docker"
? t("nav.docker")
: t("nav.terminal");
const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
const usedNumbers = new Set<number>();
let rootUsed = false;
tabs.forEach((t) => {
if (!t.title) return;
if (t.title === root) {
rootUsed = true;
return;
}
const m = t.title.match(
new RegExp(
`^${root.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`,
),
);
if (m) {
const n = parseInt(m[1], 10);
if (!isNaN(n)) usedNumbers.add(n);
}
});
if (!rootUsed) return root;
let n = 2;
while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`;
}
const addTab = (tabData: Omit<Tab, "id">): number => {
if (tabData.type === "ssh_manager") {
const existingTab = tabs.find((t) => t.type === "ssh_manager");
if (existingTab) {
setTabs((prev) =>
prev.map((t) =>
t.id === existingTab.id
? {
...t,
title: existingTab.title,
hostConfig: tabData.hostConfig
? { ...tabData.hostConfig }
: undefined,
initialTab: tabData.initialTab,
_updateTimestamp: Date.now(),
}
: t,
const usedNumbers = new Set<number>();
let rootUsed = false;
tabs.forEach((t) => {
if (!t.title) return;
if (t.title === root) {
rootUsed = true;
return;
}
const m = t.title.match(
new RegExp(
`^${root.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`,
),
);
setCurrentTab(existingTab.id);
setAllSplitScreenTab((prev) =>
prev.filter((tid) => tid !== existingTab.id),
);
return existingTab.id;
}
}
if (m) {
const n = parseInt(m[1], 10);
if (!isNaN(n)) usedNumbers.add(n);
}
});
const id = nextTabId.current++;
const instanceId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const needsUniqueTitle =
tabData.type === "terminal" ||
tabData.type === "server_stats" ||
tabData.type === "file_manager" ||
tabData.type === "tunnel" ||
tabData.type === "docker";
const effectiveTitle = needsUniqueTitle
? computeUniqueTitle(tabData.type, tabData.title)
: tabData.title || "";
const newTab: Tab = {
...tabData,
id,
instanceId,
title: effectiveTitle,
terminalRef:
tabData.type === "terminal"
? React.createRef<TerminalRefHandle>()
if (!rootUsed) return root;
let n = 2;
while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`;
},
[t, tabs],
);
const addTab = useCallback(
(tabData: Omit<Tab, "id">): number => {
if (tabData.type === "ssh_manager") {
const existingTab = tabs.find((t) => t.type === "ssh_manager");
if (existingTab) {
setTabs((prev) =>
prev.map((t) =>
t.id === existingTab.id
? {
...t,
title: existingTab.title,
hostConfig: tabData.hostConfig
? { ...tabData.hostConfig }
: undefined,
initialTab: tabData.initialTab,
_updateTimestamp: Date.now(),
}
: t,
),
);
setCurrentTab(existingTab.id);
setAllSplitScreenTab((prev) =>
prev.filter((tid) => tid !== existingTab.id),
);
return existingTab.id;
}
}
const id = nextTabId.current++;
const instanceId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const needsUniqueTitle =
tabData.type === "terminal" ||
tabData.type === "server_stats" ||
tabData.type === "file_manager" ||
tabData.type === "tunnel" ||
tabData.type === "docker";
const effectiveTitle = needsUniqueTitle
? computeUniqueTitle(tabData.type, tabData.title)
: tabData.title || "";
const newTab: Tab = {
...tabData,
id,
instanceId,
title: effectiveTitle,
terminalRef:
tabData.type === "terminal"
? React.createRef<TerminalRefHandle>()
: undefined,
hostConfig: tabData.hostConfig
? {
...tabData.hostConfig,
instanceId,
}
: undefined,
hostConfig: tabData.hostConfig
? {
...tabData.hostConfig,
instanceId,
}
: undefined,
};
setTabs((prev) => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
return id;
};
};
setTabs((prev) => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
return id;
},
[computeUniqueTitle, tabs],
);
const pendingCurrentTabRef = useRef<number | null>(null);
const removeTab = (tabId: number) => {
const tab = tabs.find((t) => t.id === tabId);
if (
tab &&
tab.terminalRef?.current &&
typeof tab.terminalRef.current.disconnect === "function"
) {
tab.terminalRef.current.disconnect();
}
setTabs((prev) => {
const closedIndex = prev.findIndex((t) => t.id === tabId);
const filtered = prev.filter((t) => t.id !== tabId);
if (filtered.length === 0) {
pendingCurrentTabRef.current = 1;
return [{ id: 1, type: "home", title: t("nav.home") }];
const removeTab = useCallback(
(tabId: number) => {
const tab = tabs.find((t) => t.id === tabId);
if (
tab &&
tab.terminalRef?.current &&
typeof tab.terminalRef.current.disconnect === "function"
) {
tab.terminalRef.current.disconnect();
}
// If the closed tab was active, compute the next tab to activate
// using the latest prev so rapid closes don't use stale data
const nextIndex =
closedIndex < filtered.length ? closedIndex : filtered.length - 1;
pendingCurrentTabRef.current = filtered[Math.max(0, nextIndex)]?.id ?? 1;
setTabs((prev) => {
const closedIndex = prev.findIndex((t) => t.id === tabId);
const filtered = prev.filter((t) => t.id !== tabId);
return filtered;
});
if (filtered.length === 0) {
pendingCurrentTabRef.current = 1;
return [{ id: 1, type: "home", title: t("nav.home") }];
}
setAllSplitScreenTab((prev) => {
const newSplits = prev.filter((id) => id !== tabId);
return newSplits.length <= 1 ? [] : newSplits;
});
// If the closed tab was active, compute the next tab to activate
// using the latest prev so rapid closes don't use stale data
const nextIndex =
closedIndex < filtered.length ? closedIndex : filtered.length - 1;
pendingCurrentTabRef.current =
filtered[Math.max(0, nextIndex)]?.id ?? 1;
setCurrentTab((prevCurrentTab) => {
if (prevCurrentTab !== tabId) return prevCurrentTab;
return pendingCurrentTabRef.current ?? 1;
});
};
return filtered;
});
const setSplitScreenTab = (tabId: number) => {
setAllSplitScreenTab((prev) => {
const newSplits = prev.filter((id) => id !== tabId);
return newSplits.length <= 1 ? [] : newSplits;
});
setCurrentTab((prevCurrentTab) => {
if (prevCurrentTab !== tabId) return prevCurrentTab;
return pendingCurrentTabRef.current ?? 1;
});
},
[t, tabs],
);
const setSplitScreenTab = useCallback((tabId: number) => {
setAllSplitScreenTab((prev) => {
if (prev.includes(tabId)) {
return prev.filter((id) => id !== tabId);
@@ -259,15 +268,18 @@ export function TabProvider({ children }: TabProviderProps) {
}
return prev;
});
};
}, []);
const getTab = (tabId: number) => {
return tabs.find((tab) => tab.id === tabId);
};
const getTab = useCallback(
(tabId: number) => {
return tabs.find((tab) => tab.id === tabId);
},
[tabs],
);
const isReorderingRef = useRef(false);
const reorderTabs = (fromIndex: number, toIndex: number) => {
const reorderTabs = useCallback((fromIndex: number, toIndex: number) => {
if (isReorderingRef.current) return;
isReorderingRef.current = true;
@@ -287,7 +299,7 @@ export function TabProvider({ children }: TabProviderProps) {
return newTabs;
});
};
}, []);
const updateHostConfig = useCallback(
(
+8 -3
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import {
Box,
FolderSearch,
@@ -14,6 +15,10 @@ import {
import { useTranslation } from "react-i18next";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
import { Terminal as TerminalFeature } from "@/features/terminal/Terminal";
import type {
TerminalHandle,
TerminalHostConfig,
} from "@/features/terminal/Terminal";
import { FileManager } from "@/features/file-manager/FileManager";
import { DockerManager } from "@/features/docker/DockerManager";
import { ServerStats } from "@/features/server-stats/ServerStats";
@@ -127,14 +132,14 @@ function TerminalTabContent({
return (
<CommandHistoryProvider>
<TerminalFeature
ref={tab.terminalRef as any}
ref={tab.terminalRef as React.Ref<TerminalHandle>}
hostConfig={
{
...hostToSSHHost(host),
sshPort: host.sshPort ?? host.port,
instanceId: tab.instanceId ?? tab.id,
restoredSessionId: tab.restoredSessionId ?? null,
} as any
} as TerminalHostConfig
}
isVisible={isVisible}
title={label}
@@ -213,7 +218,7 @@ export function renderTabContent(
);
return (
<ServerStats
hostConfig={hostToSSHHost(host) as any}
hostConfig={hostToSSHHost(host)}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
+244
View File
@@ -0,0 +1,244 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { deleteApiKey } from "@/main-axios";
import type { ApiKey } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Copy, Network, Plus, RefreshCw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
type AdminApiKeysSectionProps = {
open: boolean;
onToggle: () => void;
apiKeys: ApiKey[];
setApiKeys: Dispatch<SetStateAction<ApiKey[]>>;
loadApiKeys: () => void;
showCreateKey: boolean;
setShowCreateKey: Dispatch<SetStateAction<boolean>>;
createdKeyToken: string | null;
setCreatedKeyToken: Dispatch<SetStateAction<string | null>>;
newKeyName: string;
setNewKeyName: Dispatch<SetStateAction<string>>;
newKeyUserId: string;
setNewKeyUserId: Dispatch<SetStateAction<string>>;
newKeyExpiry: string;
setNewKeyExpiry: Dispatch<SetStateAction<string>>;
users: AdminUser[];
handleCreateApiKey: () => void;
newKeyLoading: boolean;
};
export function AdminApiKeysSection({
open,
onToggle,
apiKeys,
setApiKeys,
loadApiKeys,
showCreateKey,
setShowCreateKey,
createdKeyToken,
setCreatedKeyToken,
newKeyName,
setNewKeyName,
newKeyUserId,
setNewKeyUserId,
newKeyExpiry,
setNewKeyExpiry,
users,
handleCreateApiKey,
newKeyLoading,
}: AdminApiKeysSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionApiKeys")}
icon={<Network className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeysCount", { count: apiKeys.length })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadApiKeys}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setShowCreateKey((o) => !o);
setCreatedKeyToken(null);
}}
>
<Plus className="size-3" />
{t("admin.createRole")}
</Button>
</div>
</div>
{showCreateKey && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.newApiKey")}
</span>
{createdKeyToken ? (
<div className="flex flex-col gap-2">
<span className="text-[10px] text-accent-brand font-semibold">
{t("admin.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
<span className="text-[10px] font-mono flex-1 truncate">
{createdKeyToken}
</span>
<button
onClick={() => {
navigator.clipboard.writeText(createdKeyToken);
toast.info(t("admin.copiedToClipboard"));
}}
className="text-muted-foreground hover:text-accent-brand shrink-0"
>
<Copy className="size-3.5" />
</button>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs self-end"
onClick={() => {
setShowCreateKey(false);
setCreatedKeyToken(null);
}}
>
{t("admin.done")}
</Button>
</div>
) : (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="e.g., CI Pipeline"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyUser")}{" "}
<span className="text-accent-brand">*</span>
</label>
<select
className="px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none"
value={newKeyUserId}
onChange={(e) => setNewKeyUserId(e.target.value)}
>
<option value="">{t("admin.apiKeySelectUser")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyExpiresAt")}
</label>
<Input
type="date"
value={newKeyExpiry}
onChange={(e) => setNewKeyExpiry(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => setShowCreateKey(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateApiKey}
disabled={newKeyLoading}
>
{newKeyLoading ? t("admin.creating") : t("admin.createKey")}
</Button>
</div>
</>
)}
</div>
)}
{apiKeys.map((key) => (
<div
key={key.id}
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold truncate">
{key.name}
</span>
{!key.isActive && (
<span className="text-[9px] font-semibold px-1 py-px border border-destructive/40 bg-destructive/10 text-destructive">
{t("admin.revokedBadge")}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeyUser")}: {key.username}
</span>
<span className="text-[10px] font-mono text-muted-foreground truncate">
{key.tokenPrefix}
</span>
<span className="text-[10px] text-muted-foreground">
{key.createdAt.split("T")[0]} ·{" "}
{key.expiresAt
? key.expiresAt.split("T")[0]
: t("admin.apiKeyNoExpiry")}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={async () => {
try {
await deleteApiKey(key.id);
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
toast.success(
t("admin.revokeKeySuccess", { name: key.name }),
);
} catch {
toast.error(t("admin.revokeKeyFailed"));
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
</AccordionSection>
);
}
+494
View File
@@ -0,0 +1,494 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
deleteRole,
deleteUser,
revokeAllUserSessions,
revokeSession,
} from "@/main-axios";
import type { Role } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Activity,
KeyRound,
Pencil,
Plus,
RefreshCw,
Share2,
Trash2,
User,
} from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
export type AdminUser = {
id: string;
username: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash?: string;
};
export type AdminSession = {
id: string;
userId: string;
username?: string;
deviceType: string;
deviceInfo: string;
createdAt: string;
expiresAt: string;
lastActiveAt: string;
isRevoked?: boolean;
isCurrentSession?: boolean;
};
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
};
function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
type UsersSectionProps = {
open: boolean;
onToggle: () => void;
users: AdminUser[];
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
loadUsers: () => void;
setCreateUserOpen: Dispatch<SetStateAction<boolean>>;
setEditUserTarget: Dispatch<SetStateAction<AdminUser | null>>;
setEditUserOpen: Dispatch<SetStateAction<boolean>>;
setLinkAccountTarget: Dispatch<
SetStateAction<{ id: string; username: string } | null>
>;
setLinkAccountOpen: Dispatch<SetStateAction<boolean>>;
};
export function AdminUsersSection({
open,
onToggle,
users,
setUsers,
loadUsers,
setCreateUserOpen,
setEditUserTarget,
setEditUserOpen,
setLinkAccountTarget,
setLinkAccountOpen,
}: UsersSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionUsers")}
icon={<User className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.usersCount", { count: users.length })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadUsers}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => setCreateUserOpen(true)}
>
<Plus className="size-3" />
{t("admin.createUser")}
</Button>
</div>
</div>
{users.map((user) => {
const authLabel =
user.isOidc && user.passwordHash
? t("admin.authTypeDual")
: user.isOidc
? t("admin.authTypeOidc")
: t("admin.authTypeLocal");
return (
<div
key={user.id}
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
>
<div className="flex items-center gap-2 min-w-0">
<div className="size-6 bg-muted border border-border flex items-center justify-center text-[10px] font-bold shrink-0">
{user.username[0].toUpperCase()}
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold truncate max-w-[120px]">
{user.username}
</span>
<div className="flex items-center gap-1">
{user.isAdmin && (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.adminBadge")}
</span>
)}
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
{authLabel}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditUserTarget(user);
setEditUserOpen(true);
}}
>
<Pencil className="size-3" />
</Button>
{user.isOidc && (
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setLinkAccountTarget({
id: user.id,
username: user.username,
});
setLinkAccountOpen(true);
}}
>
<Share2 className="size-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
disabled={user.isAdmin}
onClick={async () => {
try {
await deleteUser(user.username);
setUsers((prev) => prev.filter((u) => u.id !== user.id));
toast.success(
t("admin.deleteUserSuccess", {
username: user.username,
}),
);
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("admin.deleteUserFailed")),
);
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
);
})}
</div>
</AccordionSection>
);
}
type SessionsSectionProps = {
open: boolean;
onToggle: () => void;
sessions: AdminSession[];
setSessions: Dispatch<SetStateAction<AdminSession[]>>;
loadSessions: () => void;
};
export function AdminSessionsSection({
open,
onToggle,
sessions,
setSessions,
loadSessions,
}: SessionsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionSessions")}
icon={<Activity className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionsActive", { count: sessions.length })}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadSessions}
>
<RefreshCw className="size-3" />
</Button>
</div>
{sessions.map((session) => (
<div
key={session.id}
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold">
{session.username}
</span>
{session.isCurrentSession && (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.youBadge")}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground truncate">
{session.deviceInfo}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionActive", { time: session.lastActiveAt })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionExpires", { time: session.expiresAt })}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="sm"
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
onClick={async () => {
try {
await revokeAllUserSessions(session.userId);
setSessions((prev) =>
prev.filter((s) => s.userId !== session.userId),
);
toast.success(t("admin.revokeAllSessionsSuccess"));
} catch {
toast.error(t("admin.revokeAllSessionsFailed"));
}
}}
>
{t("admin.revokeAll")}
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
try {
await revokeSession(session.id);
setSessions((prev) =>
prev.filter((s) => s.id !== session.id),
);
} catch {
toast.error(t("admin.revokeSessionFailed"));
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
))}
</div>
</AccordionSection>
);
}
type RolesSectionProps = {
open: boolean;
onToggle: () => void;
roles: Role[];
setRoles: Dispatch<SetStateAction<Role[]>>;
showCreateRole: boolean;
setShowCreateRole: Dispatch<SetStateAction<boolean>>;
newRoleName: string;
setNewRoleName: Dispatch<SetStateAction<string>>;
newRoleDisplayName: string;
setNewRoleDisplayName: Dispatch<SetStateAction<string>>;
newRoleDescription: string;
setNewRoleDescription: Dispatch<SetStateAction<string>>;
handleCreateRole: () => void;
createRoleLoading: boolean;
};
export function AdminRolesSection({
open,
onToggle,
roles,
setRoles,
showCreateRole,
setShowCreateRole,
newRoleName,
setNewRoleName,
newRoleDisplayName,
setNewRoleDisplayName,
newRoleDescription,
setNewRoleDescription,
handleCreateRole,
createRoleLoading,
}: RolesSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionRoles")}
icon={<KeyRound className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.rolesCount", { count: roles.length })}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => setShowCreateRole((o) => !o)}
>
<Plus className="size-3" />
{t("admin.createRole")}
</Button>
</div>
{showCreateRole && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.newRole")}
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.roleName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="e.g., developer"
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.roleDisplayName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="e.g., Developer"
value={newRoleDisplayName}
onChange={(e) => setNewRoleDisplayName(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.roleDescription")}
</label>
<textarea
rows={2}
placeholder={t("common.optional")}
value={newRoleDescription}
onChange={(e) => setNewRoleDescription(e.target.value)}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
setShowCreateRole(false);
setNewRoleName("");
setNewRoleDisplayName("");
setNewRoleDescription("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateRole}
disabled={createRoleLoading}
>
{createRoleLoading
? t("admin.creating")
: t("admin.createRole")}
</Button>
</div>
</div>
)}
{roles.map((role) => (
<div
key={role.id}
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold truncate">
{role.displayName}
</span>
{role.isSystem ? (
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
{t("admin.systemBadge")}
</span>
) : (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.customBadge")}
</span>
)}
</div>
<span className="text-[10px] font-mono text-muted-foreground">
{role.name}
</span>
</div>
{!role.isSystem && (
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
await deleteRole(role.id);
setRoles((prev) => prev.filter((r) => r.id !== role.id));
toast.success(
t("admin.deleteRoleSuccess", {
name: role.displayName,
}),
);
}}
>
<Trash2 className="size-3" />
</Button>
</div>
)}
</div>
))}
</div>
</AccordionSection>
);
}
File diff suppressed because it is too large Load Diff
+547
View File
@@ -0,0 +1,547 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SettingRow } from "@/components/section-card";
import { Database, RefreshCw, Settings, Shield, Trash2 } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
type GeneralSettingsSectionProps = {
open: boolean;
onToggle: () => void;
allowRegistration: boolean;
handleToggleRegistration: () => void;
allowPasswordLogin: boolean;
handleTogglePasswordLogin: () => void;
oidcAutoProvision: boolean;
handleToggleOidcAutoProvision: () => void;
allowPasswordReset: boolean;
handleTogglePasswordReset: () => void;
sessionTimeout: string;
setSessionTimeout: Dispatch<SetStateAction<string>>;
handleSaveSessionTimeout: () => void;
statusInterval: string;
setStatusInterval: Dispatch<SetStateAction<string>>;
metricsInterval: string;
setMetricsInterval: Dispatch<SetStateAction<string>>;
handleSaveMonitoring: () => void;
guacEnabled: boolean;
handleToggleGuacamole: () => void;
guacUrl: string;
setGuacUrl: Dispatch<SetStateAction<string>>;
handleSaveGuacamole: () => void;
logLevel: string;
handleSaveLogLevel: (level: string) => void;
};
export function AdminGeneralSettingsSection({
open,
onToggle,
allowRegistration,
handleToggleRegistration,
allowPasswordLogin,
handleTogglePasswordLogin,
oidcAutoProvision,
handleToggleOidcAutoProvision,
allowPasswordReset,
handleTogglePasswordReset,
sessionTimeout,
setSessionTimeout,
handleSaveSessionTimeout,
statusInterval,
setStatusInterval,
metricsInterval,
setMetricsInterval,
handleSaveMonitoring,
guacEnabled,
handleToggleGuacamole,
guacUrl,
setGuacUrl,
handleSaveGuacamole,
logLevel,
handleSaveLogLevel,
}: GeneralSettingsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionGeneral")}
icon={<Settings className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-0 pt-2">
<SettingRow
label={t("admin.allowRegistration")}
description={t("admin.allowRegistrationDesc")}
>
<AdminToggle
on={allowRegistration}
onToggle={handleToggleRegistration}
/>
</SettingRow>
<SettingRow
label={t("admin.allowPasswordLogin")}
description={t("admin.allowPasswordLoginDesc")}
>
<AdminToggle
on={allowPasswordLogin}
onToggle={handleTogglePasswordLogin}
/>
</SettingRow>
<SettingRow
label={t("admin.oidcAutoProvision")}
description={t("admin.oidcAutoProvisionDesc")}
>
<AdminToggle
on={oidcAutoProvision}
onToggle={handleToggleOidcAutoProvision}
/>
</SettingRow>
<SettingRow
label={t("admin.allowPasswordReset")}
description={t("admin.allowPasswordResetDesc")}
>
<AdminToggle
on={allowPasswordReset}
onToggle={handleTogglePasswordReset}
/>
</SettingRow>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.sessionTimeout")}
</span>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={720}
value={sessionTimeout}
onChange={(e) => setSessionTimeout(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.hours")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveSessionTimeout}
>
{t("common.save")}
</Button>
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionTimeoutRange")}
</span>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.monitoringDefaults")}
</span>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.statusCheck")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
value={statusInterval}
onChange={(e) => setStatusInterval(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.sec")}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.metrics")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
value={metricsInterval}
onChange={(e) => setMetricsInterval(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.sec")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveMonitoring}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<SettingRow
label={t("admin.enableGuacamole")}
description={t("admin.enableGuacamoleDesc")}
>
<AdminToggle on={guacEnabled} onToggle={handleToggleGuacamole} />
</SettingRow>
{guacEnabled && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.guacdUrl")}
</label>
<div className="flex items-center gap-2">
<Input
value={guacUrl}
onChange={(e) => setGuacUrl(e.target.value)}
placeholder="guacd:4822"
className="text-sm"
/>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7 shrink-0"
onClick={handleSaveGuacamole}
>
Save
</Button>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.logLevel")}
</span>
<div className="flex gap-1.5 flex-wrap">
{["debug", "info", "warn", "error"].map((l) => (
<button
key={l}
onClick={() => handleSaveLogLevel(l)}
className={`px-2 py-1 text-[10px] font-semibold border capitalize transition-colors ${logLevel === l ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{l}
</button>
))}
</div>
</div>
</div>
</AccordionSection>
);
}
type OidcSettingsSectionProps = {
open: boolean;
onToggle: () => void;
oidcClientId: string;
setOidcClientId: Dispatch<SetStateAction<string>>;
oidcClientSecret: string;
setOidcClientSecret: Dispatch<SetStateAction<string>>;
oidcAuthUrl: string;
setOidcAuthUrl: Dispatch<SetStateAction<string>>;
oidcIssuerUrl: string;
setOidcIssuerUrl: Dispatch<SetStateAction<string>>;
oidcTokenUrl: string;
setOidcTokenUrl: Dispatch<SetStateAction<string>>;
oidcUserIdentifier: string;
setOidcUserIdentifier: Dispatch<SetStateAction<string>>;
oidcDisplayName: string;
setOidcDisplayName: Dispatch<SetStateAction<string>>;
oidcScopes: string;
setOidcScopes: Dispatch<SetStateAction<string>>;
oidcUserinfoUrl: string;
setOidcUserinfoUrl: Dispatch<SetStateAction<string>>;
oidcAllowedUsers: string;
setOidcAllowedUsers: Dispatch<SetStateAction<string>>;
oidcSaving: boolean;
handleRemoveOidc: () => void;
handleSaveOidc: () => void;
};
export function AdminOidcSettingsSection({
open,
onToggle,
oidcClientId,
setOidcClientId,
oidcClientSecret,
setOidcClientSecret,
oidcAuthUrl,
setOidcAuthUrl,
oidcIssuerUrl,
setOidcIssuerUrl,
oidcTokenUrl,
setOidcTokenUrl,
oidcUserIdentifier,
setOidcUserIdentifier,
oidcDisplayName,
setOidcDisplayName,
oidcScopes,
setOidcScopes,
oidcUserinfoUrl,
setOidcUserinfoUrl,
oidcAllowedUsers,
setOidcAllowedUsers,
oidcSaving,
handleRemoveOidc,
handleSaveOidc,
}: OidcSettingsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionOidc")}
icon={<Shield className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcDescription").split("*")[0]}
<span className="text-accent-brand">*</span>
{t("admin.oidcDescription").split("*")[1]}
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcClientId")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcClientId}
onChange={(e) => setOidcClientId(e.target.value)}
placeholder="your-client-id"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcClientSecret")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
type="password"
value={oidcClientSecret}
onChange={(e) => setOidcClientSecret(e.target.value)}
placeholder="your-client-secret"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcAuthUrl")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcAuthUrl}
onChange={(e) => setOidcAuthUrl(e.target.value)}
placeholder="https://provider/oauth2/auth"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcIssuerUrl")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcIssuerUrl}
onChange={(e) => setOidcIssuerUrl(e.target.value)}
placeholder="https://provider"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcTokenUrl")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcTokenUrl}
onChange={(e) => setOidcTokenUrl(e.target.value)}
placeholder="https://provider/oauth2/token"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcUserIdentifier")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcUserIdentifier}
onChange={(e) => setOidcUserIdentifier(e.target.value)}
placeholder="sub"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcDisplayName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcDisplayName}
onChange={(e) => setOidcDisplayName(e.target.value)}
placeholder="name"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcScopes")} <span className="text-accent-brand">*</span>
</label>
<Input
value={oidcScopes}
onChange={(e) => setOidcScopes(e.target.value)}
placeholder="openid email profile"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcUserinfoUrl")}
</label>
<Input
value={oidcUserinfoUrl}
onChange={(e) => setOidcUserinfoUrl(e.target.value)}
placeholder="https://provider/oauth2/userinfo"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcAllowedUsers")}
</label>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcAllowedUsersDesc")}
</span>
<textarea
value={oidcAllowedUsers}
onChange={(e) => setOidcAllowedUsers(e.target.value)}
placeholder={"user@example.com\nanother@example.com"}
rows={3}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
className="text-xs border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleRemoveOidc}
>
<Trash2 className="size-3" />
{t("admin.removeOidc")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSaveOidc}
disabled={oidcSaving}
>
<RefreshCw className="size-3" />
{oidcSaving ? t("admin.saving") : t("common.save")}
</Button>
</div>
</div>
</AccordionSection>
);
}
type DatabaseSectionProps = {
open: boolean;
onToggle: () => void;
importFile: File | null;
setImportFile: Dispatch<SetStateAction<File | null>>;
exportLoading: boolean;
importLoading: boolean;
handleExportDatabase: () => void;
handleImportDatabase: () => void;
};
export function AdminDatabaseSection({
open,
onToggle,
importFile,
setImportFile,
exportLoading,
importLoading,
handleExportDatabase,
handleImportDatabase,
}: DatabaseSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionDatabase")}
icon={<Database className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium">
{t("admin.exportDatabase")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.exportDatabaseDesc")}
</span>
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand mt-1"
onClick={handleExportDatabase}
disabled={exportLoading}
>
{exportLoading ? t("admin.exporting") : t("admin.export")}
</Button>
</div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-xs font-medium">
{t("admin.importDatabase")}
</span>
<span className="text-[10px] text-muted-foreground">
{importFile
? t("admin.importDatabaseSelected", { name: importFile.name })
: t("admin.importDatabaseDesc")}
</span>
<div className="flex items-center gap-2 mt-1">
<div className="relative">
<input
type="file"
accept=".sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
variant="outline"
size="sm"
className="pointer-events-none text-xs"
>
{importFile ? t("admin.changeFile") : t("admin.selectFile")}
</Button>
</div>
{importFile && (
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleImportDatabase}
disabled={importLoading}
>
{importLoading ? t("admin.importing") : t("admin.import")}
</Button>
)}
</div>
</div>
</div>
</AccordionSection>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type React from "react";
import { ChevronDown } from "lucide-react";
export function AdminToggle({
on,
onToggle,
}: {
on: boolean;
onToggle: () => void;
}) {
return (
<button
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
);
}
export function AccordionSection({
label,
icon,
open,
onToggle,
children,
}: {
label: string;
icon: React.ReactNode;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="border border-border bg-card overflow-hidden">
<button
onClick={onToggle}
className="flex items-center gap-2 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
>
<span className="text-muted-foreground shrink-0">{icon}</span>
<span className="text-xs font-bold uppercase tracking-widest text-foreground flex-1">
{label}
</span>
<ChevronDown
className={`size-3.5 text-muted-foreground shrink-0 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<div className="border-t border-border px-3 pb-3">{children}</div>
)}
</div>
);
}
+498
View File
@@ -0,0 +1,498 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
assignRoleToUser,
linkOIDCToPasswordAccount,
removeRoleFromUser,
} from "@/main-axios";
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminToggle } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
message?: string;
};
function apiErrorMessage(error: unknown, fallback: string) {
const err = error as ApiErrorLike;
return err.response?.data?.error || err.message || fallback;
}
type CreateUserDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
newUsername: string;
setNewUsername: Dispatch<SetStateAction<string>>;
newPassword: string;
setNewPassword: Dispatch<SetStateAction<string>>;
showNewPassword: boolean;
setShowNewPassword: Dispatch<SetStateAction<boolean>>;
handleCreateUser: () => void;
createUserLoading: boolean;
};
export function AdminCreateUserDialog({
open,
onOpenChange,
newUsername,
setNewUsername,
newPassword,
setNewPassword,
showNewPassword,
setShowNewPassword,
handleCreateUser,
createUserLoading,
}: CreateUserDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.createUserTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder="Enter password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
type EditUserDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
editUserTarget: AdminUser | null;
editUserLoading: boolean;
editUserRoles: UserRole[];
editUserRolesLoading: boolean;
roles: Role[];
setEditUserRoles: Dispatch<SetStateAction<UserRole[]>>;
handleToggleAdmin: (user: AdminUser) => void;
handleRevokeUserSessions: (userId: string) => void;
handleDeleteEditUser: () => void;
};
export function AdminEditUserDialog({
open,
onOpenChange,
editUserTarget,
editUserLoading,
editUserRoles,
editUserRolesLoading,
roles,
setEditUserRoles,
handleToggleAdmin,
handleRevokeUserSessions,
handleDeleteEditUser,
}: EditUserDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.editUserTitle", { username: editUserTarget?.username })}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.editUserDesc")}
</DialogDescription>
</DialogHeader>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isOidc && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isOidc
? t("admin.authTypeOidc")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">
{t("admin.userRoles")}
</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter(
(r) => r.roleId !== ur.roleId,
),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(
editUserTarget.id,
r.id,
);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
type LinkAccountDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
linkAccountTarget: { id: string; username: string } | null;
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
};
export function AdminLinkAccountDialog({
open,
onOpenChange,
linkAccountTarget,
setUsers,
}: LinkAccountDialogProps) {
const { t } = useTranslation();
const [targetUsername, setTargetUsername] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) setTargetUsername("");
}, [open, linkAccountTarget]);
const handleSubmit = async () => {
const trimmedUsername = targetUsername.trim();
if (!linkAccountTarget || !trimmedUsername) return;
setSubmitting(true);
try {
await linkOIDCToPasswordAccount(linkAccountTarget.id, trimmedUsername);
toast.success(
t("admin.linkAccountSuccess", { username: trimmedUsername }),
);
setUsers((prev) => prev.filter((u) => u.id !== linkAccountTarget.id));
setTargetUsername("");
onOpenChange(false);
} catch (error: unknown) {
toast.error(apiErrorMessage(error, t("admin.linkAccountFailed")));
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.linkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.linkAccountTargetUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={targetUsername}
onChange={(e) => setTargetUsername(e.target.value)}
placeholder={t("admin.linkAccountTargetPlaceholder")}
autoFocus
disabled={submitting}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={
submitting || !linkAccountTarget || !targetUsername.trim()
}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+1 -4
View File
@@ -44,7 +44,6 @@ type RailItem =
function buildRailButtons(
splitMode: SplitMode,
t: (key: string) => string,
connectionCount: number,
): RailItem[] {
return [
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
@@ -97,7 +96,6 @@ export function AppRail({
railView,
sidebarOpen,
splitMode,
connectionCount,
username,
isAdmin,
profileDropdownOpen,
@@ -109,7 +107,6 @@ export function AppRail({
railView: RailView;
sidebarOpen: boolean;
splitMode: SplitMode;
connectionCount: number;
username: string;
isAdmin: boolean;
profileDropdownOpen: boolean;
@@ -132,7 +129,7 @@ export function AppRail({
}, []);
const railExpanded = pinned || hovered || profileDropdownOpen;
const railButtons = buildRailButtons(splitMode, t, connectionCount);
const railButtons = buildRailButtons(splitMode, t);
return (
<div
+65 -20
View File
@@ -1,12 +1,22 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Plug, Search, X } from "lucide-react";
import { getActiveSessions, deleteOpenTab, type ActiveSessionInfo, type OpenTabRecord } from "@/main-axios";
import {
getActiveSessions,
deleteOpenTab,
type ActiveSessionInfo,
type OpenTabRecord,
} from "@/main-axios";
import { tabIcon } from "@/shell/tabUtils";
import type { Tab, TabType } from "@/types/ui-types";
import { Badge } from "@/components/badge";
import { Input } from "@/components/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
const CONNECTION_TAB_TYPES: TabType[] = [
"terminal",
@@ -128,7 +138,10 @@ function ConnectionRow({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); onSwitch(); }}
onClick={(e) => {
e.stopPropagation();
onSwitch();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
>
<ExternalLink className="size-3" />
@@ -138,7 +151,10 @@ function ConnectionRow({
</Tooltip>
)}
<button
onClick={(e) => { e.stopPropagation(); onClose(); }}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
>
<X className="size-3" />
@@ -178,7 +194,10 @@ export function ConnectionsPanel({
backgroundTabRecords: OpenTabRecord[];
onSwitchToTab: (tabId: string) => void;
onCloseTab: (tabId: string) => void;
onReopenTab: (record: OpenTabRecord, restoredSessionId: string | null) => void;
onReopenTab: (
record: OpenTabRecord,
restoredSessionId: string | null,
) => void;
onForgetBackground: (recordId: string) => void;
}) {
const { t } = useTranslation();
@@ -187,15 +206,23 @@ export function ConnectionsPanel({
const [search, setSearch] = useState("");
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const openTabs = tabs.filter((tab) => CONNECTION_TAB_TYPES.includes(tab.type));
const openTabs = tabs.filter((tab) =>
CONNECTION_TAB_TYPES.includes(tab.type),
);
// Filter background records to only those not already open in the tab bar
const openInstanceIds = new Set(tabs.map((t) => t.instanceId).filter(Boolean));
const backgroundTabs = backgroundTabRecords.filter((r) => !openInstanceIds.has(r.id));
const openInstanceIds = new Set(
tabs.map((t) => t.instanceId).filter(Boolean),
);
const backgroundTabs = backgroundTabRecords.filter(
(r) => !openInstanceIds.has(r.id),
);
const q = search.trim().toLowerCase();
const filteredOpenTabs = q
? openTabs.filter((tab) => (tab.host?.name ?? tab.label).toLowerCase().includes(q))
? openTabs.filter((tab) =>
(tab.host?.name ?? tab.label).toLowerCase().includes(q),
)
: openTabs;
const filteredBackgroundTabs = q
? backgroundTabs.filter((r) => {
@@ -225,13 +252,18 @@ export function ConnectionsPanel({
useEffect(() => {
refresh();
pollTimerRef.current = setInterval(refresh, 5000);
return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); };
return () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
};
}, [refresh]);
const sessionByInstanceId = new Map(activeSessions.map((s) => [s.tabInstanceId, s]));
const sessionByInstanceId = new Map(
activeSessions.map((s) => [s.tabInstanceId, s]),
);
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
const hasResults = filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
const hasResults =
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
if (!hasAnything) {
return (
@@ -265,19 +297,27 @@ export function ConnectionsPanel({
{!hasResults && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<span className="text-xs text-muted-foreground/50">{t("connections.noSearchResults")}</span>
<span className="text-xs text-muted-foreground/50">
{t("connections.noSearchResults")}
</span>
</div>
)}
{filteredOpenTabs.length > 0 && (
<div className="flex flex-col">
<SectionHeader label={t("connections.sectionOpen")} count={filteredOpenTabs.length} />
<SectionHeader
label={t("connections.sectionOpen")}
count={filteredOpenTabs.length}
/>
{filteredOpenTabs.map((tab) => {
const isActive = tab.id === activeTabId;
const liveSession = tab.instanceId ? sessionByInstanceId.get(tab.instanceId) : undefined;
const isLive = tab.type === "terminal"
? (liveSession?.isConnected ?? false)
: true;
const liveSession = tab.instanceId
? sessionByInstanceId.get(tab.instanceId)
: undefined;
const isLive =
tab.type === "terminal"
? (liveSession?.isConnected ?? false)
: true;
const duration = liveSession?.createdAt
? formatDuration(now - liveSession.createdAt)
: formatDuration(now - tab.openedAt);
@@ -306,8 +346,13 @@ export function ConnectionsPanel({
)}
{filteredBackgroundTabs.length > 0 && (
<div className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}>
<SectionHeader label={t("connections.sectionBackground")} count={filteredBackgroundTabs.length} />
<div
className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}
>
<SectionHeader
label={t("connections.sectionBackground")}
count={filteredBackgroundTabs.length}
/>
<div className="px-3 py-1.5 border-b border-border/40">
<span className="text-[10px] text-muted-foreground/50">
{t("connections.backgroundDesc")}
+514
View File
@@ -0,0 +1,514 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Info, Lock, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { SectionCard } from "@/components/section-card";
import {
createCredential,
generateKeyPair,
generatePublicKeyFromPrivate,
updateCredential,
} from "@/main-axios";
import type { Credential } from "@/types/ui-types";
type CredentialAuthType = Credential["type"];
type CredentialWithCertificate = Credential & { certPublicKey?: string };
export function CredentialEditorView({
credential,
activeTab,
onBack,
onSave,
}: {
credential: Credential | null;
activeTab: string;
onBack: () => void;
onSave: (saved: Record<string, unknown>) => void;
}) {
const [credForm, setCredForm] = useState(() => ({
name: credential?.name ?? "",
username: credential?.username ?? "",
folder: credential?.folder ?? "",
description: credential?.description ?? "",
tags: credential?.tags ?? ([] as string[]),
tagInput: "",
type: credential?.type ?? "password",
value: credential?.value ?? "",
publicKey: credential?.publicKey ?? "",
passphrase: credential?.passphrase ?? "",
certPublicKey:
(credential as CredentialWithCertificate | null)?.certPublicKey ?? "",
}));
const { t } = useTranslation();
const [generatingKey, setGeneratingKey] = useState(false);
const [generatingPublicKey, setGeneratingPublicKey] = useState(false);
const credFileInputRef = useRef<HTMLInputElement>(null);
const certFileInputRef = useRef<HTMLInputElement>(null);
const setCredField = <K extends keyof typeof credForm>(
k: K,
v: (typeof credForm)[K],
) => setCredForm((p) => ({ ...p, [k]: v }));
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const data = {
name: credForm.name,
username: credForm.username,
folder: credForm.folder || null,
description: credForm.description || null,
tags: credForm.tags,
authType: credForm.type,
password: credForm.type === "password" ? credForm.value : null,
key:
credForm.type === "key"
? credForm.value === "existing_key"
? undefined
: credForm.value || null
: null,
publicKey: credForm.type === "key" ? credForm.publicKey : null,
certPublicKey:
credForm.type === "key" ? credForm.certPublicKey || null : null,
keyPassword:
credForm.type === "key"
? credForm.passphrase === "existing_key_password"
? undefined
: credForm.passphrase || null
: null,
};
const saved = credential
? await updateCredential(Number(credential.id), data)
: await createCredential(data);
toast.success(
credential
? t("hosts.credentialUpdated")
: t("hosts.credentialCreated"),
);
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
onSave(saved);
} catch {
toast.error(t("hosts.failedToSaveCredential"));
} finally {
setSaving(false);
}
};
const type = credForm.type;
return (
<div className="flex flex-col gap-3">
{activeTab === "general" && (
<SectionCard
title={t("hosts.basicInformation")}
icon={<Info className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.friendlyNameLabel")}
</label>
<Input
placeholder="e.g. Production SSH Key"
value={credForm.name}
onChange={(e) => setCredField("name", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.folder")}
</label>
<Input
placeholder="e.g. Server Keys"
value={credForm.folder}
onChange={(e) => setCredField("folder", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.descriptionLabel")}
</label>
<Input
placeholder="Optional details..."
value={credForm.description}
onChange={(e) => setCredField("description", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.tags")}
</label>
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
{credForm.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
>
{tag}
<button
type="button"
onClick={() =>
setCredField(
"tags",
credForm.tags.filter((tg) => tg !== tag),
)
}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={
credForm.tags.length === 0
? t("hosts.addTagsPlaceholder")
: ""
}
value={credForm.tagInput}
onChange={(e) => setCredField("tagInput", e.target.value)}
onKeyDown={(e) => {
if (
(e.key === " " || e.key === "Enter") &&
credForm.tagInput.trim()
) {
e.preventDefault();
const tag = credForm.tagInput.trim();
if (!credForm.tags.includes(tag))
setCredField("tags", [...credForm.tags, tag]);
setCredField("tagInput", "");
} else if (
e.key === "Backspace" &&
!credForm.tagInput &&
credForm.tags.length > 0
) {
setCredField("tags", credForm.tags.slice(0, -1));
}
}}
/>
</div>
</div>
</div>
</SectionCard>
)}
{activeTab === "auth" && (
<SectionCard
title={t("hosts.authDetailsSection")}
icon={<Lock className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.credTypeLabel")}
</label>
<div className="flex gap-2">
{["password", "key"].map((m) => (
<button
key={m}
onClick={() =>
setCredField("type", m as CredentialAuthType)
}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m === "key"
? t("hosts.sshPrivateKey")
: t("hosts.password")}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.username")}
</label>
<Input
placeholder="e.g. root or deploy"
value={credForm.username}
onChange={(e) => setCredField("username", e.target.value)}
/>
</div>
{type === "password" && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.password")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={credForm.value}
onChange={(e) => setCredField("value", e.target.value)}
/>
</div>
)}
{type === "key" && (
<div className="flex flex-col gap-4">
<div className="p-3 border border-border bg-muted/20">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
{t("hosts.generateKeyPairTitle")}
</p>
<p className="text-[10px] text-muted-foreground mb-2">
{t("hosts.generateKeyPairDescription")}
</p>
<div className="flex flex-wrap gap-2">
{[
{ label: "Ed25519", type: "ssh-ed25519" },
{
label: "ECDSA (nistp256)",
type: "ecdsa-sha2-nistp256",
},
{ label: "RSA (2048)", type: "ssh-rsa", bits: 2048 },
].map(({ label, type: keyType, bits }) => (
<Button
key={label}
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px] px-2"
disabled={generatingKey}
onClick={async () => {
setGeneratingKey(true);
try {
const result = await generateKeyPair(
keyType as
| "ssh-ed25519"
| "ssh-rsa"
| "ecdsa-sha2-nistp256",
bits,
credForm.passphrase === "existing_key_password"
? undefined
: credForm.passphrase || undefined,
);
if (result.success) {
setCredField("value", result.privateKey);
setCredField("publicKey", result.publicKey);
toast.success(
t("hosts.keyPairGenerated", { label }),
);
} else {
toast.error(
result.error ??
t("hosts.failedToGenerateKeyPair"),
);
}
} catch {
toast.error(t("hosts.failedToGenerateKeyPair"));
} finally {
setGeneratingKey(false);
}
}}
>
{generatingKey
? t("hosts.generatingKey")
: t("hosts.generateLabel", { label })}
</Button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sshPrivateKey")}
</label>
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1"
onClick={() => credFileInputRef.current?.click()}
>
<Upload className="size-3" /> {t("hosts.uploadFileBtn")}
</button>
</div>
<input
ref={credFileInputRef}
type="file"
accept=".pem,.key,.txt,.ppk"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
setCredField("value", text.trim());
e.target.value = "";
}}
/>
{credForm.value === "existing_key" && (
<div className="px-3 py-2 text-[10px] border border-accent-brand/30 bg-accent-brand/5 text-accent-brand">
{t("hosts.keySaved")} {t("hosts.keyReplaceNotice")}
</div>
)}
<textarea
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
rows={8}
value={
credForm.value === "existing_key" ? "" : credForm.value
}
onChange={(e) => setCredField("value", e.target.value)}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.keyPassphraseOptional")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
credForm.passphrase === "existing_key_password"
? t("hosts.keyPassphraseSaved")
: "••••••••"
}
value={
credForm.passphrase === "existing_key_password"
? ""
: credForm.passphrase
}
onFocus={() => {
if (credForm.passphrase === "existing_key_password")
setCredField("passphrase", "");
}}
onChange={(e) => setCredField("passphrase", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sshPublicKeyOptional")}
</label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
disabled={
!credForm.value ||
credForm.value === "existing_key" ||
generatingPublicKey
}
onClick={async () => {
setGeneratingPublicKey(true);
try {
const result = await generatePublicKeyFromPrivate(
credForm.value,
credForm.passphrase === "existing_key_password"
? undefined
: credForm.passphrase || undefined,
);
if (result?.publicKey) {
setCredField("publicKey", result.publicKey);
toast.success(t("hosts.publicKeyGenerated"));
} else {
toast.error(t("hosts.failedToGeneratePublicKey"));
}
} catch {
toast.error(t("hosts.failedToGeneratePublicKey"));
} finally {
setGeneratingPublicKey(false);
}
}}
>
{generatingPublicKey
? t("hosts.generatingKey")
: t("hosts.generateFromPrivateKey")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px] px-2"
disabled={!credForm.publicKey}
onClick={() => {
navigator.clipboard.writeText(credForm.publicKey ?? "");
toast.success(t("hosts.publicKeyCopied"));
}}
>
<Copy className="size-3 mr-1" /> {t("common.copy")}
</Button>
</div>
<textarea
placeholder="ssh-rsa AAAAB3Nza..."
rows={3}
value={credForm.publicKey}
onChange={(e) => setCredField("publicKey", e.target.value)}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex flex-col gap-1.5 p-3 border border-border bg-muted/20">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.caCertificate")}
</label>
{credForm.certPublicKey && (
<button
type="button"
className="text-[10px] text-destructive hover:text-destructive/80"
onClick={() => setCredField("certPublicKey", "")}
>
{t("credentials.clearCert")}
</button>
)}
</div>
<p className="text-[10px] text-muted-foreground">
{t("credentials.caCertificateDescription")}
</p>
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1 self-start"
onClick={() => certFileInputRef.current?.click()}
>
<Upload className="size-3" />{" "}
{t("credentials.uploadCertFile")}
</button>
<input
ref={certFileInputRef}
type="file"
accept=".pub,.txt"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
setCredField("certPublicKey", text.trim());
e.target.value = "";
}}
/>
<textarea
placeholder={t("credentials.pasteOrUploadCert")}
rows={2}
value={credForm.certPublicKey}
onChange={(e) =>
setCredField("certPublicKey", e.target.value)
}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
</div>
)}
</div>
</SectionCard>
)}
<div className="flex justify-end gap-3 mt-3">
<Button variant="ghost" onClick={onBack} disabled={saving}>
{t("hosts.cancelBtn")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
onClick={handleSave}
disabled={saving}
>
{saving
? t("hosts.savingBtn")
: credential
? t("hosts.updateCredentialBtn")
: t("hosts.addCredentialBtn")}
</Button>
</div>
</div>
);
}
+413
View File
@@ -0,0 +1,413 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
Copy,
FolderOpen,
KeyRound,
Loader2,
Pencil,
Plus,
Trash2,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { getCredentialDetails } from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
type CredentialWithCertificate = Credential & { certPublicKey?: string };
type ConfirmDialog = {
message: string;
onConfirm: () => void;
};
function CredentialItem({
cred,
usedByCount,
stripeIndex,
onDeploy,
onEdit,
onDelete,
}: {
cred: Credential;
usedByCount: number;
stripeIndex: number;
onDeploy: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const isKey = cred.type === "key";
return (
<div
className={`group relative flex items-stretch cursor-default select-none transition-colors hover:bg-muted/40 ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
>
{/* Type stripe */}
<div className="w-[3px] shrink-0 bg-transparent" />
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
{/* Name row */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[13px] font-medium truncate text-foreground leading-none">
{cred.name}
</span>
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey ? "KEY" : "PWD"}
</span>
</div>
{/* Username row */}
{(cred.username || usedByCount > 0) && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
{cred.username}
{usedByCount > 0 && (
<span className="text-muted-foreground/30">
{cred.username ? " · " : ""}
{usedByCount}h
</span>
)}
</span>
)}
{/* Tag pills */}
{cred.tags && cred.tags.length > 0 && (
<div className="flex items-center gap-1 min-w-0 overflow-hidden pl-3">
{cred.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="text-[9px] px-1 py-px border border-border/50 bg-muted/30 text-muted-foreground/60 lowercase shrink-0 leading-none"
>
{tag}
</span>
))}
{cred.tags.length > 4 && (
<span className="text-[9px] text-muted-foreground/40 shrink-0">
+{cred.tags.length - 4}
</span>
)}
</div>
)}
{/* Action tray — slides open on hover */}
<div className="overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[60px] group-hover:opacity-100">
<div className="flex items-center gap-1 pt-1.5 pl-2 pb-1 border-t border-border/40 mt-0.5">
{isKey && (
<>
<button
title="Deploy key to host"
onClick={onDeploy}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Upload className="size-3.5" />
</button>
<button
title="Copy deploy command"
onClick={() => {
const pubKey = cred.publicKey;
if (!pubKey) {
toast.error(
"No public key available — open the credential editor first",
);
return;
}
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
navigator.clipboard.writeText(cmd);
toast.success("Deploy command copied");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Copy className="size-3.5" />
</button>
</>
)}
<button
title="Edit credential"
onClick={onEdit}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
<button
title="Delete credential"
onClick={onDelete}
className="flex items-center justify-center size-7 rounded text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
</div>
</div>
);
}
function CredentialFolderItem({
folder,
creds,
allHosts,
stripeOffset,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeploy,
onEdit,
onDelete,
}: {
folder: string;
creds: Credential[];
allHosts: Host[];
stripeOffset: number;
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeploy: (cred: Credential) => void;
onEdit: (cred: Credential) => void;
onDelete: (cred: Credential) => void;
}) {
const [open, setOpen] = useState(true);
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className={`group/folder flex items-center gap-2 w-full px-3 py-2 hover:bg-muted/50 transition-colors text-left cursor-pointer ${stripeOffset % 2 === 1 ? "bg-muted/20" : ""}`}
>
<ChevronRight
className={`size-3 shrink-0 text-muted-foreground/50 transition-transform ${open ? "rotate-90" : ""}`}
/>
<FolderOpen
className={`size-3.5 shrink-0 ${open ? "text-accent-brand" : "text-muted-foreground/60"}`}
/>
{editingFolderName === folder ? (
<>
<input
autoFocus
value={editingFolderValue}
onChange={(e) => onEditingFolderValueChange(e.target.value)}
onBlur={async () => {
const newName = editingFolderValue.trim();
onEditingFolderNameChange(null);
if (newName && newName !== folder) {
await onRenameFolder(folder, newName);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") onEditingFolderNameChange(null);
}}
onClick={(e) => e.stopPropagation()}
className="text-[10px] font-semibold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
/>
<button
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(null);
}}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</>
) : (
<>
<span className="text-[13px] font-semibold text-foreground/80 truncate flex-1">
{folder}
</span>
<span className="text-[10px] tabular-nums shrink-0 ml-1 text-muted-foreground/40">
{creds.length}
</span>
{folder !== "Uncategorized" && (
<span
className="opacity-0 group-hover/folder:opacity-100 transition-opacity ml-1 text-muted-foreground/50 hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(folder);
onEditingFolderValueChange(folder);
}}
>
<Pencil className="size-2.5" />
</span>
)}
</>
)}
</button>
{open && (
<div className="border-l border-border/40 ml-[30px]">
{creds.map((cred, i) => {
const usedByCount = allHosts.filter(
(h) => h.credentialId === cred.id,
).length;
return (
<CredentialItem
key={cred.id}
cred={cred}
usedByCount={usedByCount}
stripeIndex={stripeOffset + 1 + i}
onDeploy={() => onDeploy(cred)}
onEdit={() => onEdit(cred)}
onDelete={() => onDelete(cred)}
/>
);
})}
</div>
)}
</div>
);
}
export function HostCredentialList({
credentialFolders,
filteredCredentials,
credentialsLoading,
allHosts,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeployCredential,
onEditCredential,
onDeleteCredential,
onAddCredential,
onConfirmDialogChange,
}: {
credentialFolders: string[];
filteredCredentials: Credential[];
credentialsLoading: boolean;
allHosts: Host[];
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeployCredential: (cred: Credential) => void;
onEditCredential: (cred: Credential) => void;
onDeleteCredential: (cred: Credential) => Promise<void>;
onAddCredential: () => void;
onConfirmDialogChange: (dialog: ConfirmDialog) => void;
}) {
const { t } = useTranslation();
async function handleDelete(cred: Credential) {
onConfirmDialogChange({
message: t("hosts.deleteCredentialConfirm", { name: cred.name }),
onConfirm: async () => {
try {
await onDeleteCredential(cred);
toast.success(t("hosts.deletedCredential", { name: cred.name }));
} catch {
toast.error(t("hosts.failedToDeleteCredential2"));
}
},
});
}
async function handleEdit(cred: Credential) {
try {
const full = await getCredentialDetails(Number(cred.id));
onEditCredential({
...cred,
value: (
full as CredentialWithCertificate & {
hasKey?: boolean;
hasKeyPassword?: boolean;
}
).hasKey
? "existing_key"
: ((
full as CredentialWithCertificate & {
password?: string;
}
).password ?? ""),
passphrase: (
full as CredentialWithCertificate & {
hasKeyPassword?: boolean;
}
).hasKeyPassword
? "existing_key_password"
: "",
publicKey:
(full as CredentialWithCertificate).certPublicKey ?? cred.publicKey,
});
} catch {
onEditCredential(cred);
}
}
let globalStripe = 0;
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col">
{credentialFolders.map((folder) => {
const creds = filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === folder,
);
if (creds.length === 0) return null;
const offset = globalStripe;
globalStripe += 1 + creds.length;
return (
<CredentialFolderItem
key={folder}
folder={folder}
creds={creds}
allHosts={allHosts}
stripeOffset={offset}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
onEditingFolderNameChange={onEditingFolderNameChange}
onEditingFolderValueChange={onEditingFolderValueChange}
onRenameFolder={onRenameFolder}
onDeploy={onDeployCredential}
onEdit={handleEdit}
onDelete={handleDelete}
/>
);
})}
{credentialsLoading && (
<div className="flex flex-col px-2 py-2 space-y-1.5">
{[60, 45, 55, 40].map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2">
<div className="size-3 rounded-sm bg-muted/50 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-muted/50 animate-pulse"
style={{ width: `${w * 2}px` }}
/>
</div>
))}
<div className="flex items-center justify-center gap-2 pt-2 text-muted-foreground/40">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-xs">{t("hosts.loadingCredentials")}</span>
</div>
</div>
)}
{!credentialsLoading && filteredCredentials.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<KeyRound className="size-8 text-muted-foreground/20 mb-2" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("hosts.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={onAddCredential}
>
<Plus className="size-3 mr-1" />
{t("hosts.addCredentialBtn2")}
</Button>
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
import { TERMINAL_THEMES } from "@/lib/terminal-themes";
import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types";
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
export type HostProtocols = {
enableSsh: boolean;
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
};
export type HostAuthType = Host["authType"];
export type HostCursorStyle = NonNullable<
Host["terminalConfig"]
>["cursorStyle"];
export type HostBellStyle = NonNullable<Host["terminalConfig"]>["bellStyle"];
export type HostBackspaceMode = NonNullable<
Host["terminalConfig"]
>["backspaceMode"];
export type HostFastScrollModifier = NonNullable<
Host["terminalConfig"]
>["fastScrollModifier"];
type SnippetListItem = {
id: number;
name?: string;
title?: string;
};
type SnippetResponse = SnippetListItem[] | { snippets?: SnippetListItem[] };
export function mapSnippetResponse(
res: unknown,
): { id: number; name: string }[] {
const snippetRes = res as SnippetResponse;
return (
Array.isArray(snippetRes) ? snippetRes : (snippetRes.snippets ?? [])
).map((s) => ({
id: s.id,
name: s.name ?? s.title ?? `Snippet ${s.id}`,
}));
}
export function createHostEditorForm(host: Host | null) {
const rawTheme = host?.terminalConfig?.theme;
const normalizedTheme =
!rawTheme ||
["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes(
rawTheme,
)
? "termix"
: TERMINAL_THEMES[rawTheme]
? rawTheme
: "termix";
return {
name: host?.name ?? "",
ip: host?.ip ?? "",
username: host?.username ?? "",
sshPort: host?.sshPort ?? host?.port ?? 22,
rdpPort: host?.rdpPort ?? 3389,
vncPort: host?.vncPort ?? 5900,
telnetPort: host?.telnetPort ?? 23,
authType: host?.authType ?? "password",
password: host?.password ?? "",
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
keyPassword: host?.hasKeyPassword
? "existing_key_password"
: (host?.keyPassword ?? ""),
keyType: host?.keyType ?? "auto",
keySubTab: "paste" as "paste" | "upload",
credentialId: host?.credentialId ?? "",
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
folder: host?.folder ?? "",
tags: host?.tags ?? ([] as string[]),
tagInput: "",
notes: host?.notes ?? "",
pin: host?.pin ?? false,
macAddress: host?.macAddress ?? "",
useSocks5: host?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? 1080,
socks5Username: host?.socks5Username ?? "",
socks5Password: host?.socks5Password ?? "",
socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0
? "chain"
: "single") as "single" | "chain",
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
enableTerminal: host?.enableTerminal ?? true,
enableFileManager: host?.enableFileManager ?? false,
enableDocker: host?.enableDocker ?? false,
enableTunnel: host?.enableTunnel ?? false,
defaultPath: host?.defaultPath ?? "/",
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
fontSize: host?.terminalConfig?.fontSize ?? 14,
fontFamily:
host?.terminalConfig?.fontFamily ?? "Caskaydia Cove Nerd Font Mono",
theme: normalizedTheme,
cursorStyle: (host?.terminalConfig?.cursorStyle ?? "bar") as
| "block"
| "underline"
| "bar",
cursorBlink: host?.terminalConfig?.cursorBlink ?? true,
scrollback: host?.terminalConfig?.scrollback ?? 10000,
letterSpacing: host?.terminalConfig?.letterSpacing ?? 0,
lineHeight: host?.terminalConfig?.lineHeight ?? 1.0,
bellStyle: (host?.terminalConfig?.bellStyle ?? "none") as
| "none"
| "sound"
| "visual"
| "both",
rightClickSelectsWord: host?.terminalConfig?.rightClickSelectsWord ?? false,
fastScrollModifier: (host?.terminalConfig?.fastScrollModifier ?? "alt") as
| "alt"
| "ctrl"
| "shift",
fastScrollSensitivity: host?.terminalConfig?.fastScrollSensitivity ?? 5,
minimumContrastRatio: host?.terminalConfig?.minimumContrastRatio ?? 1,
backspaceMode: (host?.terminalConfig?.backspaceMode ?? "normal") as
| "normal"
| "control-h",
startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null,
moshCommand: host?.terminalConfig?.moshCommand ?? "",
agentForwarding: host?.terminalConfig?.agentForwarding ?? false,
autoMosh: host?.terminalConfig?.autoMosh ?? false,
autoTmux: host?.terminalConfig?.autoTmux ?? false,
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
sudoPassword: host?.terminalConfig?.sudoPassword ?? "",
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
environmentVariables:
host?.terminalConfig?.environmentVariables ??
([] as { key: string; value: string }[]),
serverTunnels: host?.serverTunnels ?? ([] as Host["serverTunnels"]),
jumpHosts: host?.jumpHosts ?? ([] as { hostId: string }[]),
portKnockSequence:
host?.portKnockSequence ??
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
quickActions:
host?.quickActions ?? ([] as { name: string; snippetId: string }[]),
rdpUser: host?.rdpUser ?? "",
rdpPassword: host?.rdpPassword ?? "",
domain: host?.domain ?? "",
security: host?.security ?? "",
ignoreCert: host?.ignoreCert ?? false,
vncPassword: host?.vncPassword ?? "",
vncUser: host?.vncUser ?? "",
telnetUser: host?.telnetUser ?? "",
telnetPassword: host?.telnetPassword ?? "",
guacamoleConfig: host?.guacamoleConfig ?? {},
statsConfig: host?.statsConfig ?? {
statusCheckEnabled: true,
statusCheckInterval: 60,
useGlobalStatusInterval: true,
metricsEnabled: true,
metricsInterval: 30,
useGlobalMetricsInterval: true,
enabledWidgets: [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
"processes",
"ports",
"firewall",
],
},
};
}
export type HostEditorForm = ReturnType<typeof createHostEditorForm>;
export function buildHostEditorPayload(
form: HostEditorForm,
protocols: HostProtocols,
): SSHHostData {
return {
connectionType: protocols.enableSsh
? "ssh"
: protocols.enableRdp
? "rdp"
: protocols.enableVnc
? "vnc"
: "telnet",
name: form.name,
ip: form.ip,
port: protocols.enableSsh
? Number(form.sshPort)
: protocols.enableRdp
? Number(form.rdpPort)
: protocols.enableVnc
? Number(form.vncPort)
: Number(form.telnetPort),
username: form.username,
folder: form.folder,
tags: form.tags,
pin: form.pin,
authType: form.authType,
password: form.password || null,
key: form.key === "existing_key" ? undefined : form.key || null,
keyPassword:
form.keyPassword === "existing_key_password"
? undefined
: form.keyPassword || null,
keyType: form.keyType !== "auto" ? form.keyType : null,
credentialId: form.credentialId ? Number(form.credentialId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
notes: form.notes,
macAddress: form.macAddress || null,
enableTerminal: form.enableTerminal,
enableTunnel: form.enableTunnel,
enableFileManager: form.enableFileManager,
enableDocker: form.enableDocker,
defaultPath: form.defaultPath || "/",
useSocks5: form.useSocks5,
socks5Host:
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
socks5Port:
form.socks5ProxyMode === "single" ? form.socks5Port || null : null,
socks5Username:
form.socks5ProxyMode === "single" ? form.socks5Username || null : null,
socks5Password:
form.socks5ProxyMode === "single" ? form.socks5Password || null : null,
socks5ProxyChain:
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
enableSsh: protocols.enableSsh,
enableRdp: protocols.enableRdp,
enableVnc: protocols.enableVnc,
enableTelnet: protocols.enableTelnet,
sshPort: Number(form.sshPort),
rdpPort: Number(form.rdpPort),
vncPort: Number(form.vncPort),
telnetPort: Number(form.telnetPort),
forceKeyboardInteractive: form.forceKeyboardInteractive,
rdpUser: form.rdpUser || null,
rdpPassword: form.rdpPassword || null,
rdpDomain: form.domain || null,
rdpSecurity: form.security || null,
rdpIgnoreCert: form.ignoreCert,
vncPassword: form.vncPassword || null,
vncUser: form.vncUser || null,
telnetUser: form.telnetUser || null,
telnetPassword: form.telnetPassword || null,
jumpHosts: form.jumpHosts,
portKnockSequence: form.portKnockSequence,
tunnelConnections: form.serverTunnels,
quickActions: form.quickActions.map((a) => ({
name: a.name,
snippetId: Number(a.snippetId),
})),
statsConfig: form.statsConfig,
guacamoleConfig:
(protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet) &&
Object.keys(form.guacamoleConfig).length > 0
? form.guacamoleConfig
: null,
terminalConfig: protocols.enableSsh
? {
theme: form.theme,
cursorBlink: form.cursorBlink,
cursorStyle: form.cursorStyle,
fontSize: Number(form.fontSize),
fontFamily: form.fontFamily,
scrollback: Number(form.scrollback),
letterSpacing: Number(form.letterSpacing),
lineHeight: Number(form.lineHeight),
bellStyle: form.bellStyle,
rightClickSelectsWord: form.rightClickSelectsWord,
fastScrollModifier: form.fastScrollModifier,
fastScrollSensitivity: Number(form.fastScrollSensitivity),
minimumContrastRatio: Number(form.minimumContrastRatio),
backspaceMode: form.backspaceMode,
startupSnippetId: form.startupSnippetId ?? null,
moshCommand: form.moshCommand || null,
agentForwarding: form.agentForwarding,
autoMosh: form.autoMosh,
autoTmux: form.autoTmux,
sudoPasswordAutoFill: form.sudoPasswordAutoFill,
sudoPassword: form.sudoPassword || null,
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
environmentVariables: form.environmentVariables,
}
: null,
};
}
+82
View File
@@ -0,0 +1,82 @@
import { useTranslation } from "react-i18next";
import { Box, FolderSearch } from "lucide-react";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "./HostEditorData";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostDockerTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.dockerIntegration")}
icon={<Box className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableDockerMonitor")}
description={t("hosts.enableDockerMonitorDesc")}
>
<FakeSwitch
checked={form.enableDocker}
onChange={(v) => setField("enableDocker", v)}
/>
</SettingRow>
</div>
</SectionCard>
);
}
export function HostFilesTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.fileManager")}
icon={<FolderSearch className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableFileManagerMonitor")}
description={t("hosts.enableFileManagerMonitorDesc")}
>
<FakeSwitch
checked={form.enableFileManager}
onChange={(v) => setField("enableFileManager", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.defaultPathLabel")}
</label>
<Input
placeholder="/"
value={form.defaultPath}
onChange={(e) => setField("defaultPath", e.target.value)}
/>
<span className="text-[10px] text-muted-foreground">
{t("hosts.fileManagerPathHint")}
</span>
</div>
</div>
</SectionCard>
);
}
+699
View File
@@ -0,0 +1,699 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import type { Host } from "@/types/ui-types";
import {
Globe,
Monitor,
MousePointerClick,
Plus,
Tag,
Terminal,
Trash2,
X,
} from "lucide-react";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
type HostEditorSetField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostEditorGeneralTab({
form,
setField,
protocols,
handleProtocolToggle,
hosts,
host,
}: {
form: HostEditorForm;
setField: HostEditorSetField;
protocols: HostProtocols;
handleProtocolToggle: (proto: keyof HostProtocols, value: boolean) => void;
hosts: Host[];
host: Host | null;
}) {
const { t } = useTranslation();
return (
<>
{/* Protocols — enable/disable each connection type */}
<SectionCard
title={t("hosts.protocols")}
icon={<Globe className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
{[
{
proto: "enableSsh" as const,
label: t("hosts.tabSsh"),
desc: t("hosts.secureShell"),
icon: <Terminal className="size-4" />,
portField: "sshPort" as const,
},
{
proto: "enableRdp" as const,
label: t("hosts.tabRdp"),
desc: t("hosts.remoteDesktop"),
icon: <Monitor className="size-4" />,
portField: "rdpPort" as const,
},
{
proto: "enableVnc" as const,
label: t("hosts.tabVnc"),
desc: t("hosts.virtualNetwork"),
icon: <MousePointerClick className="size-4" />,
portField: "vncPort" as const,
},
{
proto: "enableTelnet" as const,
label: t("hosts.tabTelnet"),
desc: t("hosts.unencryptedShell"),
icon: <Terminal className="size-4" />,
portField: "telnetPort" as const,
},
].map(({ proto, label, desc, icon }) => {
const enabled = protocols[proto];
return (
<div
key={proto}
className={`flex items-center gap-3 p-3 border transition-colors ${enabled ? "border-accent-brand/20 bg-accent-brand/5" : "border-border bg-muted/10"}`}
>
<div
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
>
{icon}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span
className={`text-xs font-bold ${enabled ? "text-foreground" : "text-muted-foreground/50"}`}
>
{label}
</span>
<span className="text-[10px] text-muted-foreground/50">
{desc}
</span>
</div>
<FakeSwitch
checked={enabled}
onChange={(v: boolean) => handleProtocolToggle(proto, v)}
/>
</div>
);
})}
</div>
</SectionCard>
<SectionCard
title={t("hosts.connectionDetails")}
icon={<Globe className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.addressIp")}
</label>
<Input
placeholder="10.0.0.1 or example.com"
value={form.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.friendlyName")}
</label>
<Input
placeholder="e.g. Web Server Production"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
/>
</div>
{protocols.enableSsh && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
MAC Address
</label>
<Input
placeholder="AA:BB:CC:DD:EE:FF"
value={form.macAddress}
onChange={(e) => setField("macAddress", e.target.value)}
/>
</div>
)}
</div>
</div>
</SectionCard>
{!protocols.enableSsh &&
!protocols.enableRdp &&
!protocols.enableVnc &&
!protocols.enableTelnet && (
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
</div>
)}
<SectionCard
title={t("hosts.folderAndAdvanced")}
icon={<Tag className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.folder")}
</label>
<Input
placeholder="e.g. Production"
value={form.folder}
onChange={(e) => setField("folder", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.tags")}
</label>
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
{form.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
>
{tag}
<button
type="button"
onClick={() =>
setField(
"tags",
form.tags.filter((tg) => tg !== tag),
)
}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={form.tags.length === 0 ? t("hosts.addTag") : ""}
value={form.tagInput}
onChange={(e) => setField("tagInput", e.target.value)}
onKeyDown={(e) => {
if (
(e.key === " " || e.key === "Enter") &&
form.tagInput.trim()
) {
e.preventDefault();
const tag = form.tagInput.trim();
if (!form.tags.includes(tag))
setField("tags", [...form.tags, tag]);
setField("tagInput", "");
} else if (
e.key === "Backspace" &&
!form.tagInput &&
form.tags.length > 0
) {
setField("tags", form.tags.slice(0, -1));
}
}}
/>
</div>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.privateNotes")}
</label>
<textarea
rows={3}
placeholder={t("hosts.privateNotesPlaceholder")}
className="w-full px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
value={form.notes}
onChange={(e) => setField("notes", e.target.value)}
/>
</div>
<SettingRow
label={t("hosts.pinToTop")}
description={t("hosts.pinToTopDesc")}
>
<FakeSwitch
checked={form.pin}
onChange={(v) => setField("pin", v)}
/>
</SettingRow>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 pb-0">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.portKnockingSequence")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("portKnockSequence", [
...form.portKnockSequence,
{ port: 0, protocol: "tcp" as const, delay: 0 },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addKnockBtn")}
</Button>
</div>
{form.portKnockSequence.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noPortKnocking")}
</p>
)}
<div className="flex flex-col gap-2">
{form.portKnockSequence.map((knock, i) => (
<div
key={i}
className="flex items-end gap-1.5 p-1.5 bg-muted/30 border border-border"
>
<span className="text-[9px] font-bold text-muted-foreground/50 mb-1.5 shrink-0">
{i + 1}.
</span>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
{t("hosts.knockPort")}
</span>
<Input
className="h-7 text-xs w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="8080"
type="number"
value={knock.port}
onChange={(e) => {
const updated = [...form.portKnockSequence];
updated[i] = {
...updated[i],
port: Number(e.target.value),
};
setField("portKnockSequence", updated);
}}
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
{t("hosts.protocol")}
</span>
<select
className="h-7 text-[10px] bg-background border border-border px-1"
value={knock.protocol}
onChange={(e) => {
const updated = [...form.portKnockSequence];
updated[i] = {
...updated[i],
protocol: e.target.value as "tcp" | "udp",
};
setField("portKnockSequence", updated);
}}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
{t("hosts.delayAfterMs")}
</span>
<Input
className="h-7 text-xs w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="100"
type="number"
value={knock.delay}
onChange={(e) => {
const updated = [...form.portKnockSequence];
updated[i] = {
...updated[i],
delay: Number(e.target.value),
};
setField("portKnockSequence", updated);
}}
/>
</div>
<button
className="text-destructive p-1 mb-0.5"
onClick={() =>
setField(
"portKnockSequence",
form.portKnockSequence.filter((_, idx) => idx !== i),
)
}
>
<X className="size-3" />
</button>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-4 border-t border-border pt-4 pb-2">
<SettingRow
label={t("hosts.useSocks5Proxy")}
description={t("hosts.useSocks5ProxyDesc")}
>
<FakeSwitch
checked={form.useSocks5}
onChange={(v) => setField("useSocks5", v)}
/>
</SettingRow>
{form.useSocks5 && (
<div className="flex flex-col gap-3">
{/* Single / Chain mode toggle */}
<div className="flex gap-2">
{(["single", "chain"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setField("socks5ProxyMode", m)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${form.socks5ProxyMode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m === "single"
? t("hosts.proxySingleMode")
: t("hosts.proxyChainMode")}
</button>
))}
</div>
{form.socks5ProxyMode === "single" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3 bg-muted/20 border border-border">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyHost")}
</label>
<Input
className="h-7 text-xs"
placeholder="proxy.example.com"
value={form.socks5Host}
onChange={(e) => setField("socks5Host", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPort")}
</label>
<Input
className="h-7 text-xs"
type="number"
placeholder="1080"
value={form.socks5Port}
onChange={(e) =>
setField("socks5Port", Number(e.target.value))
}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyUsername")}
</label>
<Input
className="h-7 text-xs"
placeholder={t("hosts.optional")}
value={form.socks5Username}
onChange={(e) =>
setField("socks5Username", e.target.value)
}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={form.socks5Password}
onChange={(e) =>
setField("socks5Password", e.target.value)
}
/>
</div>
</div>
)}
{form.socks5ProxyMode === "chain" && (
<div className="flex flex-col gap-2">
{form.socks5ProxyChain.map((node, ni) => (
<div
key={ni}
className="flex flex-col gap-2 p-3 bg-muted/20 border border-border"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-muted-foreground">
{t("hosts.proxyNode")} {ni + 1}
</span>
<button
type="button"
className="text-destructive"
onClick={() =>
setField(
"socks5ProxyChain",
form.socks5ProxyChain.filter(
(_, idx) => idx !== ni,
),
)
}
>
<X className="size-3.5" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyHost")}
</label>
<Input
className="h-7 text-xs"
placeholder="proxy.example.com"
value={node.host}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = { ...u[ni], host: e.target.value };
setField("socks5ProxyChain", u);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPort")}
</label>
<Input
className="h-7 text-xs"
type="number"
placeholder="1080"
value={node.port}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = {
...u[ni],
port: Number(e.target.value),
};
setField("socks5ProxyChain", u);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyType")}
</label>
<select
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={node.type}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = { ...u[ni], type: e.target.value };
setField("socks5ProxyChain", u);
}}
>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
<option value="http">HTTP</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyUsername")}
</label>
<Input
className="h-7 text-xs"
placeholder={t("hosts.optional")}
value={node.username}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = {
...u[ni],
username: e.target.value,
};
setField("socks5ProxyChain", u);
}}
/>
</div>
<div className="flex flex-col gap-1 col-span-2">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={node.password}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = {
...u[ni],
password: e.target.value,
};
setField("socks5ProxyChain", u);
}}
/>
</div>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand self-start"
onClick={() =>
setField("socks5ProxyChain", [
...form.socks5ProxyChain,
{
host: "",
port: 1080,
type: "socks5",
username: "",
password: "",
},
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addProxyNode")}
</Button>
</div>
)}
{/* Connection path visualization */}
{(form.socks5ProxyMode === "single" && form.socks5Host) ||
(form.socks5ProxyMode === "chain" &&
form.socks5ProxyChain.length > 0) ? (
<div className="flex items-center gap-1 flex-wrap p-2 bg-muted/30 border border-border text-[10px]">
<span className="px-2 py-0.5 bg-background border border-border text-foreground font-mono">
{t("hosts.you")}
</span>
{form.socks5ProxyMode === "single" && form.socks5Host ? (
<>
<span className="text-muted-foreground"></span>
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
{form.socks5Host}:{form.socks5Port}
</span>
</>
) : (
form.socks5ProxyChain
.filter((n) => n.host)
.map((n, ni) => (
<React.Fragment key={ni}>
<span className="text-muted-foreground"></span>
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
{n.host}:{n.port}
</span>
</React.Fragment>
))
)}
{form.jumpHosts
.filter((j) => j.hostId)
.map((j, ji) => {
const jh = hosts.find((h) => h.id === j.hostId);
return jh ? (
<React.Fragment key={`j${ji}`}>
<span className="text-muted-foreground"></span>
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
{jh.name || jh.ip}
</span>
</React.Fragment>
) : null;
})}
<span className="text-muted-foreground"></span>
<span className="px-2 py-0.5 bg-accent-brand/10 border border-accent-brand/30 text-accent-brand font-mono">
{form.ip || "target"}:{form.sshPort}
</span>
</div>
) : null}
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.jumpHostChainLabel")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("jumpHosts", [...form.jumpHosts, { hostId: "" }])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addJumpBtn")}
</Button>
</div>
{form.jumpHosts.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noJumpHosts")}
</p>
)}
<div className="flex flex-col gap-2">
{form.jumpHosts.map((jh, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-background border border-border"
>
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
{i + 1}.
</span>
<select
className="flex h-7 flex-1 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={jh.hostId}
onChange={(e) => {
const updated = [...form.jumpHosts];
updated[i] = { hostId: e.target.value };
setField("jumpHosts", updated);
}}
>
<option value="">{t("hosts.selectAServer")}</option>
{hosts
.filter((h) => (host ? h.id !== host.id : true))
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</select>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive"
onClick={() =>
setField(
"jumpHosts",
form.jumpHosts.filter((_, idx) => idx !== i),
)
}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
</div>
</div>
</SectionCard>
</>
);
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More