* Improve Docker container list UI

* Rework SSH tunnel forwarding

* Update macOS Electron packaging

* Optimize frontend bundle splitting

* Add beta version update status

* Add client tunnel preset management

* Secure cookie authentication flows

* Add client tunnel bridge support

* Preserve sessions on restart

* Update runtime to Node 24

* Add client remote tunnel support

* Fix stale frontend cache handling

* Fix Docker image platforms for Node 24

* Fix Electron packaging workflows

* Fix client auth cache after upgrades

* chore: cleanup files

* fix: npm i error

* Fix OIDC auth cookie readiness

* Fix Docker npm ci config

* Add react-is peer dependency

* Fix Electron auth and cache handling

* Improve terminal clipboard and refresh actions

* feat: add API keys

* feat: improve lazy loading with loading spinners

* feat: Introduce FolderTree component with lazy-loading and motion animations for improved file manager UX (#735)

* feat: integrate FolderTree component with lazy-loading for file manager sidebar

- Add motion animation library (v12.38.0) for smooth UI transitions
- Create new FolderTree component with advanced keyboard navigation support
- Refactor kbd component: introduce KbdKey and KbdSeparator subcomponents
- Implement lazy-loading strategy for directory tree in FileManagerSidebar
- Refactor FileManagerSidebar with improved code organization and better separation of concerns
- Update keyboard shortcut displays across CommandPalette, FileViewer, and Dashboard
- Change React/ReactDOM dependency flags from dev to devOptional in package-lock.json

BREAKING CHANGE: KbdGroup component has been replaced. Use <Kbd><KbdKey>...</KbdKey><KbdSeparator /></Kbd> instead.

- Improves UX with smooth animations and better folder navigation
- Reduces initial load time through lazy-loading subdirectories
- Enhances accessibility with ARIA labels and keyboard navigation
- Maintains dark mode support and proper styling

* fix: incorrect use of the theme system and linked file manger sidebar with current folder

---------

Co-authored-by: suryacagur <suryacagur.dev@gmail.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Enhance VNC token generation to include optional username parameter and refactor username input handling in HostGeneralTab (#733)

* Fix Docker build info generation

* Remove unused node-fetch dependency

* feat: prompt user for SSH key passphrase on use (#715)

When an encrypted SSH key has no stored passphrase, show a lightweight
dialog prompting the user to enter it at connection time instead of
failing with a parse error. Supports both desktop and mobile terminals.

Closes Termix-SSH/Support#354

* fix: prevent session crash when uploading to permission-denied directory (#716)

- Wrap writeFile sftp.stat callback in try-catch to prevent uncaught
  exceptions from escaping the callback into the event loop
- Add missing stream.stderr error handler in writeFile fallback to
  prevent unhandled error events from crashing the process
- Remove bogus activeOperations decrement in both writeFile and
  uploadFile fallback methods (counter was never incremented)
- Add res.headersSent checks in fallback disconnect paths to prevent
  ERR_HTTP_HEADERS_SENT crashes

Closes Termix-SSH/Support#652

* feat: add LOG_TIMESTAMP_FORMAT env var for 24h/ISO log timestamps (#718)

Support LOG_TIMESTAMP_FORMAT environment variable with values:
- "24h": 24-hour format (14:58:45)
- "iso": ISO 8601 format (2026-04-25T14:58:45.000Z)
- default: locale format (2:58:45 PM)

Closes Termix-SSH/Support#650

* feat: open file manager at terminal current working directory (#719)

* feat: open file manager at terminal current working directory

When right-clicking in the terminal and selecting "Open File Manager
Here", query the current working directory via a separate SSH exec
channel and pass it as the initial path to the file manager tab.

Closes Termix-SSH/Support#649

* chore: sync package-lock.json with node-fetch and deps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove undefined TerminalContextMenu from bad merge resolution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: show reconnect overlay when SSH server reboots (#720)

When the remote server reboots, the SSH connection closes while the
stream is still active. The close handler only sent the "disconnected"
message when sshStream was null, so the frontend never received the
disconnect notification and hung with a blinking cursor.

Change the else-if condition to always send the "disconnected" message
regardless of stream state.

Closes Termix-SSH/Support#648

* feat: support read-only Docker container mode (#721)

Move nginx runtime files (config, pid, logs, temp dirs) from /app/nginx/
to /tmp/nginx/ so the container can run with read_only: true. Template
files remain in /app/nginx/ as read-only assets.

Users can now harden the container with:
  read_only: true
  tmpfs:
    - /tmp

Closes Termix-SSH/Support#647

* fix: allow editing host folder without re-entering password (#722)

When editing an existing host, the password field is stripped by the
backend for security. The form validation treated the empty password
as invalid, disabling the Update Host button even for non-auth changes
like folder assignment.

Use an "existing_password" sentinel (mirroring the existing
"existing_key" pattern) to represent an unchanged password during
editing, skip validation for it, and omit it from the update payload.

Closes Termix-SSH/Support#645

* fix: auto-close tab on graceful SSH disconnect (exit/Ctrl+D) (#723)

Distinguish between graceful shell exit and unexpected disconnection
using the stream close event's exit code. When the shell exits normally
(code != null), send "session_ended" instead of "disconnected". The
frontend auto-closes the tab on session_ended, and shows the reconnect
overlay only on unexpected disconnections.

Closes Termix-SSH/Support#643

* fix: reattach existing SSH session on WebSocket reconnect (#724)

WebSocket reconnection was always creating a new SSH connection with
full authentication instead of reattaching to the existing SSH session.
The condition `!isReconnectingRef.current` prevented session reattach
during reconnection, causing repeated password auth attempts that
trigger SSHGuard/fail2ban blocking.

Remove the guard so reconnection tries to reattach the persisted
session first. If the session has expired, the backend sends
sessionExpired and the frontend falls back to a new connection.

Closes Termix-SSH/Support#644

* fix: prevent browser crash when uploading large files (>100MB) (#725)

The file-to-base64 conversion used a byte-by-byte string concatenation
loop (String.fromCharCode + btoa), which allocated ~3x the file size
in intermediate strings, causing the browser tab to OOM on files over
~100MB.

Replace with FileReader.readAsDataURL which delegates base64 encoding
to the browser engine natively, avoiding the intermediate allocations.

Closes Termix-SSH/Support#577

* fix: support SSH multi-factor auth with publickey + password (#726)

When sshd requires AuthenticationMethods publickey,password, the
connection failed because the key auth branch only set privateKey
without also setting password. After publickey partial auth succeeded,
ssh2 sent keyboard-interactive (due to tryKeyboard:true) instead of
password, which the server rejected.

Pass the credential password alongside the private key so ssh2 can
complete the password step after publickey succeeds.

Closes Termix-SSH/Support#629

* feat(oidc): add OIDC_ALLOW_REGISTRATION env to bypass allow_registration for OIDC (#727)

The `allow_registration` setting blocks both password-based and OIDC user
creation. Admins who want to close password registration but still onboard
new users via a trusted IdP (with the existing `OIDC_ALLOWED_USERS` whitelist)
have no way to do that today.

Introduce an `OIDC_ALLOW_REGISTRATION` env var. When set to `true`, the OIDC
callback skips the `allow_registration` settings check while still honoring
the `OIDC_ALLOWED_USERS` whitelist. Password registration via `POST
/users/create` continues to respect `allow_registration`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: lazy load locales, file previews, and decouple startup imports (#729)

* perf: lazy load locale bundles

* perf: lazy load file preview modules

* perf: avoid eager api client load on startup

* chore: remove dead code, tighten types, fix lint warnings (#730)

* chore: clean up low-risk lint warnings

* chore: tighten utility types

* chore: preserve backend error causes

* chore: simplify command palette host state

* chore: remove unused frontend code

* chore: prune stale frontend state

* chore: trim unused navigation code

* chore: prune unused user settings props

* chore: trim unused sidebar state

* chore: remove stale host editor imports

* chore: tighten shared frontend types

* chore: narrow desktop helper types

* chore: type network topology data

* chore: type connection log errors

* chore: use typed tab context

* chore: type api client error metadata

* chore: tighten terminal config types

* chore: type host proxy chains

* chore: type host editor form data

* chore: use typed host viewer fields

* chore: format app builder patch script

* Fix client auth cache after upgrades

* chore: fix pr checks after dev merge

* fix: remove duplicate session-expired useEffect in FullScreenAppWrapper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Xenthys <x@dis.gg>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: npm package warnings

* feat: reconnect after file manager disconnects

* feat: add docs button in api keys

* feat: change colors for server tunnels

* fix: fetch password from API for Copy Password button (#736)

* chore: update readme's

* feat: improve c2s UI in user profile

* feat: improve ssh key detection and move open file manager at path for terminal button

* fix: restore missing getHostPassword import in Tab.tsx (#737)

* fix: security related fixes

* feat: improve alert code

* Fix Electron clipboard handling

* fix: untranslated alert text

---------

Co-authored-by: Xenthys <x@dis.gg>
Co-authored-by: PT Kelana Tech Solutions <ptkelanatechsolutions@gmail.com>
Co-authored-by: suryacagur <suryacagur.dev@gmail.com>
Co-authored-by: zimmra <28514085+zimmra@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Fuad <funtik1229@yandex.ru>
This commit is contained in:
Luke Gustafson
2026-05-06 15:12:07 -05:00
committed by GitHub
co-authored by Claude Opus 4.7 Xenthys LukeGus PT Kelana Tech Solutions suryacagur zimmra ZacharyZcR Fuad
parent af9fc95b0e
commit 2768f11dfc
181 changed files with 15785 additions and 11276 deletions
+71 -8
View File
@@ -8,12 +8,12 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import guacamoleRoutes from "../guacamole/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import fetch from "node-fetch";
import fs from "fs";
import path from "path";
import os from "os";
@@ -118,6 +118,31 @@ class GitHubCache {
const githubCache = new GitHubCache();
function parseSemver(
version: string | undefined,
): [number, number, number] | null {
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
}
function compareSemver(
a: string | undefined,
b: string | undefined,
): number | null {
const parsedA = parseSemver(a);
const parsedB = parseSemver(b);
if (!parsedA || !parsedB) return null;
for (let i = 0; i < 3; i += 1) {
if (parsedA[i] > parsedB[i]) return 1;
if (parsedA[i] < parsedB[i]) return -1;
}
return 0;
}
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Termix";
@@ -143,7 +168,7 @@ async function fetchGitHubAPI<T>(
"User-Agent": "TermixUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
agent: getProxyAgent(url),
dispatcher: getProxyAgent(url),
});
if (!response.ok) {
@@ -299,12 +324,19 @@ app.get("/version", authenticateJWT, async (req, res) => {
return res.status(401).send("Remote Version Not Found");
}
const isUpToDate = localVersion === remoteVersion;
const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const response = {
status: isUpToDate ? "up_to_date" : "requires_update",
status,
localVersion: localVersion,
version: remoteVersion,
remoteVersion: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
@@ -624,7 +656,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
operation: "export_temp_dir_error",
tempDir,
});
throw new Error(`Failed to create temp directory: ${dirError.message}`);
throw new Error(`Failed to create temp directory: ${dirError.message}`, {
cause: dirError,
});
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
@@ -1161,7 +1195,7 @@ app.post(
mimetype: req.file.mimetype,
});
let userDataKey = DataCrypto.getUserDataKey(userId);
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data not unlocked");
}
@@ -1719,6 +1753,7 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/guacamole", guacamoleRoutes);
app.use("/network-topology", networkTopologyRoutes);
@@ -1738,10 +1773,38 @@ if (frontendDist) {
databaseLogger.info(`Serving frontend from: ${frontendDist}`, {
operation: "static_files",
});
app.use(express.static(frontendDist));
app.use(
express.static(frontendDist, {
setHeaders: (res, filePath) => {
const relativePath = path
.relative(frontendDist, filePath)
.replaceAll(path.sep, "/");
if (relativePath.startsWith("assets/")) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
return;
}
if (
relativePath === "index.html" ||
relativePath === "sw.js" ||
relativePath === "manifest.json"
) {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
);
}
},
}),
);
app.use((req, res, next) => {
if (req.method === "GET" && req.accepts("html")) {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
);
res.sendFile(path.join(frontendDist, "index.html"));
} else {
next();
@@ -1750,13 +1813,13 @@ if (frontendDist) {
}
app.use(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(
err: unknown,
req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
void _next;
apiLogger.error("Unhandled error in request", err, {
operation: "error_handler",
method: req.method,
+86 -1
View File
@@ -118,6 +118,7 @@ async function initializeDatabaseAsync(): Promise<void> {
throw new Error(
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
{ cause: error },
);
}
} else {
@@ -317,6 +318,18 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
config TEXT NOT NULL,
platform TEXT,
computer_name TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ssh_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -424,10 +437,31 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT,
last_used_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
try {
sqlite.prepare("DELETE FROM sessions").run();
const result = sqlite
.prepare("DELETE FROM sessions WHERE expires_at <= ?")
.run(new Date().toISOString());
if (result.changes > 0) {
databaseLogger.info("Expired sessions cleaned up on startup", {
operation: "db_init_session_cleanup",
deletedSessions: result.changes,
});
}
} catch (e) {
databaseLogger.warn("Could not clear expired sessions on startup", {
operation: "db_init_session_cleanup_failed",
@@ -803,6 +837,31 @@ const migrateSchema = () => {
}
}
try {
sqlite.prepare("SELECT id FROM c2s_tunnel_presets LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
config TEXT NOT NULL,
platform TEXT,
computer_name TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create c2s_tunnel_presets table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite
.prepare("SELECT id FROM sessions LIMIT 1")
@@ -1175,6 +1234,32 @@ const migrateSchema = () => {
}
}
try {
sqlite.prepare("SELECT id FROM api_keys LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT,
last_used_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create api_keys table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
+31
View File
@@ -298,6 +298,23 @@ export const snippetFolders = sqliteTable("snippet_folders", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
config: text("config").notNull(),
platform: text("platform"),
computerName: text("computer_name"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippetAccess = sqliteTable("snippet_access", {
id: integer("id").primaryKey({ autoIncrement: true }),
snippetId: integer("snippet_id")
@@ -572,3 +589,17 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
expiresAt: text("expires_at").notNull(),
lastUsed: text("last_used"),
});
export const apiKeys = sqliteTable("api_keys", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
tokenHash: text("token_hash").notNull(),
tokenPrefix: text("token_prefix").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
});
+1 -2
View File
@@ -7,7 +7,6 @@ import express from "express";
import { db } from "../db/index.js";
import { dismissedAlerts } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import fetch from "node-fetch";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { getProxyAgent } from "../../utils/proxy-agent.js";
@@ -61,7 +60,7 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
Accept: "application/json",
"User-Agent": "TermixAlertChecker/1.0",
},
agent: getProxyAgent(url),
dispatcher: getProxyAgent(url),
});
if (!response.ok) {
@@ -0,0 +1,247 @@
import type {
AuthenticatedRequest,
TunnelConnection,
} from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { c2sTunnelPresets } from "../db/schema.js";
import { and, asc, eq, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
function parsePreset(row: typeof c2sTunnelPresets.$inferSelect) {
return {
...row,
config: JSON.parse(row.config) as TunnelConnection[],
};
}
function validateConfig(config: unknown): config is TunnelConnection[] {
if (!Array.isArray(config)) return false;
return config.every((item) => {
if (!item || typeof item !== "object") return false;
const tunnel = item as Partial<TunnelConnection>;
const mode = tunnel.mode || tunnel.tunnelType;
return (
tunnel.scope === "c2s" &&
(mode === "local" || mode === "remote" || mode === "dynamic") &&
typeof tunnel.sourcePort === "number" &&
tunnel.sourcePort >= 1 &&
tunnel.sourcePort <= 65535 &&
(mode === "dynamic" ||
(typeof tunnel.endpointPort === "number" &&
tunnel.endpointPort >= 1 &&
tunnel.endpointPort <= 65535))
);
});
}
router.get(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
try {
const result = await db
.select()
.from(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.userId, userId))
.orderBy(asc(c2sTunnelPresets.name));
res.json(result.map(parsePreset));
} catch (error) {
authLogger.error("Failed to fetch C2S tunnel presets", error);
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
}
},
);
router.post(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, config, platform, computerName } = req.body;
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
if (!validateConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
const trimmedName = name.trim();
try {
const existing = await db
.select()
.from(c2sTunnelPresets)
.where(
and(
eq(c2sTunnelPresets.userId, userId),
eq(c2sTunnelPresets.name, trimmedName),
),
);
if (existing.length > 0) {
return res.status(409).json({ error: "Preset name already exists" });
}
const result = await db
.insert(c2sTunnelPresets)
.values({
userId,
name: trimmedName,
config: JSON.stringify(config),
platform: platform?.trim() || null,
computerName: computerName?.trim() || null,
})
.returning();
databaseLogger.info("C2S tunnel preset created", {
operation: "c2s_tunnel_preset_create",
userId,
presetId: result[0].id,
});
res.status(201).json(parsePreset(result[0]));
} catch (error) {
authLogger.error("Failed to create C2S tunnel preset", error);
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
}
},
);
router.put(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = Number(req.params.id);
const { name, config, platform, computerName } = req.body;
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Preset not found" });
}
const updateFields: Record<string, unknown> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (name !== undefined) {
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
const trimmedName = name.trim();
const duplicate = await db
.select()
.from(c2sTunnelPresets)
.where(
and(
eq(c2sTunnelPresets.userId, userId),
eq(c2sTunnelPresets.name, trimmedName),
),
);
if (duplicate.some((preset) => preset.id !== id)) {
return res.status(409).json({ error: "Preset name already exists" });
}
updateFields.name = trimmedName;
}
if (config !== undefined) {
if (!validateConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
updateFields.config = JSON.stringify(config);
}
if (platform !== undefined)
updateFields.platform = platform?.trim() || null;
if (computerName !== undefined)
updateFields.computerName = computerName?.trim() || null;
await db
.update(c2sTunnelPresets)
.set(updateFields)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
const updated = await db
.select()
.from(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.id, id));
res.json(parsePreset(updated[0]));
} catch (error) {
authLogger.error("Failed to update C2S tunnel preset", error);
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
}
},
);
router.delete(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = Number(req.params.id);
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Preset not found" });
}
await db
.delete(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
res.json({ success: true });
} catch (error) {
authLogger.error("Failed to delete C2S tunnel preset", error);
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
}
},
);
export default router;
+35 -46
View File
@@ -27,6 +27,7 @@ import {
inArray,
} from "drizzle-orm";
import type { Request, Response } from "express";
import axios from "axios";
import multer from "multer";
import { sshLogger, databaseLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
@@ -42,6 +43,32 @@ const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
function notifyStatsHostUpdated(
hostId: number,
headers: Pick<Request["headers"], "authorization" | "cookie">,
operation: string,
): void {
axios
.post(
"http://localhost:30005/host-updated",
{ hostId },
{
headers: {
Authorization: headers.authorization || "",
Cookie: headers.cookie || "",
},
timeout: 5000,
},
)
.catch((err) => {
sshLogger.warn("Failed to notify stats server of host update", {
operation,
hostId,
error: err instanceof Error ? err.message : String(err),
});
});
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
@@ -581,29 +608,12 @@ router.post(
name,
});
try {
const axios = (await import("axios")).default;
const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-updated`,
{ hostId: createdHost.id },
{
headers: {
Authorization: req.headers.authorization || "",
Cookie: req.headers.cookie || "",
},
timeout: 5000,
},
);
} catch (err) {
sshLogger.warn("Failed to notify stats server of new host", {
operation: "host_create",
hostId: createdHost.id as number,
error: err instanceof Error ? err.message : String(err),
});
}
res.json(resolvedHost);
notifyStatsHostUpdated(
createdHost.id as number,
req.headers,
"host_create",
);
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
operation: "host_create",
@@ -1189,29 +1199,8 @@ router.put(
hostId: parseInt(hostId),
});
try {
const axios = (await import("axios")).default;
const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-updated`,
{ hostId: parseInt(hostId) },
{
headers: {
Authorization: req.headers.authorization || "",
Cookie: req.headers.cookie || "",
},
timeout: 5000,
},
);
} catch (err) {
sshLogger.warn("Failed to notify stats server of host update", {
operation: "host_update",
hostId: parseInt(hostId),
error: err instanceof Error ? err.message : String(err),
});
}
res.json(resolvedHost);
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
} catch (err) {
sshLogger.error("Failed to update SSH host in database", err, {
operation: "host_update",
@@ -3319,7 +3308,7 @@ router.patch(
.update(hosts)
.set({ statsConfig: JSON.stringify(merged) })
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
} catch (e) {
} catch {
errors.push(`Failed to update statsConfig for host ${host.id}`);
}
}
@@ -5346,7 +5335,7 @@ router.post(
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = parseInt(req.params.id);
const hostId = Number.parseInt(String(req.params.id), 10);
const userId = (req as AuthenticatedRequest).userId;
try {
+264 -48
View File
@@ -30,6 +30,7 @@ import {
networkTopology,
dashboardPreferences,
opksshTokens,
apiKeys,
} from "../db/schema.js";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
@@ -1142,7 +1143,11 @@ router.get("/oidc/callback", async (req, res) => {
}
}
if (!isFirstUser) {
const oidcAllowRegistration =
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
"true";
if (!isFirstUser && !oidcAllowRegistration) {
try {
const regRow = db.$client
.prepare(
@@ -1321,10 +1326,6 @@ router.get("/oidc/callback", async (req, res) => {
const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("success", "true");
if (deviceInfo.type === "desktop" || deviceInfo.type === "mobile") {
redirectUrl.searchParams.set("token", token);
}
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
@@ -1576,14 +1577,6 @@ router.post("/login", async (req, res) => {
username: userRecord.username,
};
const isElectron =
req.headers["x-electron-app"] === "true" ||
req.headers["X-Electron-App"] === "true";
if (isElectron) {
response.token = token;
}
const timeoutRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
.get() as { value: string } | undefined;
@@ -1621,18 +1614,7 @@ router.post("/logout", authenticateJWT, async (req, res) => {
const userId = authReq.userId;
if (userId) {
const token =
req.cookies?.jwt || req.headers["authorization"]?.split(" ")[1];
let sessionId: string | undefined;
if (token) {
try {
const payload = await authManager.verifyJWTToken(token);
sessionId = payload?.sessionId;
} catch {
// expected - token verification may fail during logout
}
}
const sessionId = authReq.sessionId;
await authManager.logoutUser(userId, sessionId);
authLogger.info("User logged out", {
@@ -1693,6 +1675,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
is_oidc: !!user[0].isOidc,
is_dual_auth: isDualAuth,
totp_enabled: !!user[0].totpEnabled,
data_unlocked: authManager.isUserUnlocked(userId),
});
} catch (err) {
authLogger.error("Failed to get username", err);
@@ -3396,10 +3379,6 @@ router.post("/totp/verify-login", async (req, res) => {
deviceInfo: deviceInfo.deviceInfo,
});
const isElectron =
req.headers["x-electron-app"] === "true" ||
req.headers["X-Electron-App"] === "true";
authLogger.success("TOTP verification successful", {
operation: "totp_verify_success",
userId: userRecord.id,
@@ -3416,10 +3395,6 @@ router.post("/totp/verify-login", async (req, res) => {
totp_enabled: !!userRecord.totpEnabled,
};
if (isElectron) {
response.token = token;
}
const timeoutRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
.get() as { value: string } | undefined;
@@ -3560,9 +3535,14 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
* description: Failed to unlock data.
*/
router.post("/unlock-data", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const authReq = req as AuthenticatedRequest;
const userId = authReq.userId;
const { password } = req.body;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!password) {
return res.status(400).json({ error: "Password is required" });
}
@@ -3570,6 +3550,19 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
try {
const unlocked = await authManager.authenticateUser(userId, password);
if (unlocked) {
const refreshedSession =
userId && authReq.sessionId
? await authManager.refreshSessionToken(userId, authReq.sessionId)
: null;
if (refreshedSession) {
res.cookie(
"jwt",
refreshedSession.token,
authManager.getSecureCookieOptions(req, refreshedSession.maxAge),
);
}
res.json({
success: true,
message: "Data unlocked successfully",
@@ -3608,9 +3601,10 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const unlocked = authManager.isUserUnlocked(userId);
res.json({
unlocked: true,
message: "Data is unlocked",
unlocked,
message: unlocked ? "Data is unlocked" : "Data is locked",
});
} catch (err) {
authLogger.error("Failed to check data status", err, {
@@ -3638,7 +3632,9 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
* description: Failed to get sessions.
*/
router.get("/sessions", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const authReq = req as AuthenticatedRequest;
const userId = authReq.userId;
const currentSessionId = authReq.sessionId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
@@ -3661,8 +3657,16 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
.limit(1);
return {
...session,
id: session.id,
userId: session.userId,
username: sessionUser[0]?.username || "Unknown",
deviceType: session.deviceType,
deviceInfo: session.deviceInfo,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
lastActiveAt: session.lastActiveAt,
isRevoked: session.isRevoked,
isCurrentSession: session.id === currentSessionId,
};
}),
);
@@ -3670,7 +3674,19 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
return res.json({ sessions: enrichedSessions });
} else {
sessionList = await authManager.getUserSessions(userId);
return res.json({ sessions: sessionList });
return res.json({
sessions: sessionList.map((session) => ({
id: session.id,
userId: session.userId,
deviceType: session.deviceType,
deviceInfo: session.deviceInfo,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
lastActiveAt: session.lastActiveAt,
isRevoked: session.isRevoked,
isCurrentSession: session.id === currentSessionId,
})),
});
}
} catch (err) {
authLogger.error("Failed to get sessions", err);
@@ -3812,12 +3828,7 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
let currentSessionId: string | undefined;
if (exceptCurrent) {
const token =
req.cookies?.jwt || req.headers?.authorization?.split(" ")[1];
if (token) {
const payload = await authManager.verifyJWTToken(token);
currentSessionId = payload?.sessionId;
}
currentSessionId = (req as AuthenticatedRequest).sessionId;
}
const revokedCount = await authManager.revokeAllUserSessions(
@@ -4205,7 +4216,7 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
* 500:
* description: Failed to get guacamole settings.
*/
router.get("/guacamole-settings", async (req, res) => {
router.get("/guacamole-settings", authenticateJWT, async (req, res) => {
try {
const enabledRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
@@ -4305,7 +4316,7 @@ router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
* 200:
* description: Current log level.
*/
router.get("/log-level", async (_req, res) => {
router.get("/log-level", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
@@ -4374,7 +4385,7 @@ router.patch("/log-level", authenticateJWT, async (req, res) => {
* 200:
* description: Current session timeout hours.
*/
router.get("/session-timeout", async (_req, res) => {
router.get("/session-timeout", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
@@ -4433,4 +4444,209 @@ router.patch("/session-timeout", authenticateJWT, async (req, res) => {
}
});
/**
* @openapi
* /users/api-keys:
* post:
* summary: Create an API key (admin only)
* description: Creates a new API key scoped to a specific user. The full token is returned only once.
* tags:
* - API Keys
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - userId
* properties:
* name:
* type: string
* description: Human-readable name for the key.
* userId:
* type: string
* description: ID of the user this key is scoped to.
* expiresAt:
* type: string
* format: date-time
* description: Optional expiration date. Null means the key never expires.
* responses:
* 201:
* description: API key created. Contains the full token (shown only once).
* 400:
* description: Invalid input.
* 403:
* description: Admin access required.
* 404:
* description: Target user not found.
* 500:
* description: Failed to create API key.
*/
router.post("/api-keys", requireAdmin, async (req, res) => {
try {
const { name, userId: targetUserId, expiresAt } = req.body;
if (typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (typeof targetUserId !== "string" || !targetUserId.trim()) {
return res.status(400).json({ error: "userId is required" });
}
const targetUser = await db
.select()
.from(users)
.where(eq(users.id, targetUserId))
.limit(1);
if (targetUser.length === 0) {
return res.status(404).json({ error: "Target user not found" });
}
let expiresAtValue: string | null = null;
if (expiresAt) {
const parsed = new Date(expiresAt);
if (isNaN(parsed.getTime())) {
return res.status(400).json({ error: "Invalid expiresAt date" });
}
if (parsed <= new Date()) {
return res
.status(400)
.json({ error: "expiresAt must be in the future" });
}
expiresAtValue = parsed.toISOString();
}
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
const tokenPrefix = rawToken.substring(0, 12);
const tokenHash = await bcrypt.hash(rawToken, 10);
const keyId = nanoid();
const now = new Date().toISOString();
await db.insert(apiKeys).values({
id: keyId,
userId: targetUserId,
name: name.trim(),
tokenHash,
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
lastUsedAt: null,
isActive: true,
});
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
return res.status(201).json({
id: keyId,
name: name.trim(),
userId: targetUserId,
username: targetUser[0].username,
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
token: rawToken,
});
} catch (err) {
authLogger.error("Failed to create API key", err);
return res.status(500).json({ error: "Failed to create API key" });
}
});
/**
* @openapi
* /users/api-keys:
* get:
* summary: List all API keys (admin only)
* description: Returns all API keys with associated usernames. Token hashes are never returned.
* tags:
* - API Keys
* responses:
* 200:
* description: List of API keys.
* 403:
* description: Admin access required.
* 500:
* description: Failed to fetch API keys.
*/
router.get("/api-keys", requireAdmin, async (_req, res) => {
try {
const keys = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
userId: apiKeys.userId,
username: users.username,
tokenPrefix: apiKeys.tokenPrefix,
createdAt: apiKeys.createdAt,
expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive,
})
.from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id))
.orderBy(apiKeys.createdAt);
return res.json({ apiKeys: keys });
} catch (err) {
authLogger.error("Failed to list API keys", err);
return res.status(500).json({ error: "Failed to fetch API keys" });
}
});
/**
* @openapi
* /users/api-keys/{keyId}:
* delete:
* summary: Delete an API key (admin only)
* description: Permanently deletes an API key. It can no longer be used to authenticate.
* tags:
* - API Keys
* parameters:
* - in: path
* name: keyId
* required: true
* schema:
* type: string
* description: The ID of the API key to delete.
* responses:
* 200:
* description: API key deleted.
* 403:
* description: Admin access required.
* 404:
* description: API key not found.
* 500:
* description: Failed to delete API key.
*/
router.delete("/api-keys/:keyId", requireAdmin, async (req, res) => {
try {
const keyId = String(req.params.keyId);
const existing = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.id, keyId))
.limit(1);
if (existing.length === 0) {
return res.status(404).json({ error: "API key not found" });
}
await db.delete(apiKeys).where(eq(apiKeys.id, keyId));
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
return res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete API key", err, {
keyId: String(req.params.keyId),
});
return res.status(500).json({ error: "Failed to delete API key" });
}
});
export default router;
@@ -1,12 +1,8 @@
import GuacamoleLite from "guacamole-lite";
import { parse as parseUrl } from "url";
import { guacLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import { GuacamoleTokenService } from "./token-service.js";
import { getDb } from "../database/db/index.js";
import type { IncomingMessage } from "http";
const authManager = AuthManager.getInstance();
const tokenService = GuacamoleTokenService.getInstance();
function parseGuacUrl(url: string): { host: string; port: number } {
+20 -11
View File
@@ -6,7 +6,7 @@ import { PermissionManager } from "../utils/permission-manager.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { getDb } from "../database/db/index.js";
import { hosts } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { eq } from "drizzle-orm";
import type { AuthenticatedRequest } from "../../types/index.js";
const router = express.Router();
@@ -31,7 +31,6 @@ router.use(authManager.createAuthMiddleware());
*/
router.post("/token", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const { type, hostname, port, username, password, domain, ...options } =
req.body;
@@ -63,10 +62,15 @@ router.post("/token", async (req, res) => {
);
break;
case "vnc":
token = tokenService.createVncToken(hostname, password, {
port: port || 5900,
...options,
});
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port: port || 5900,
...options,
},
);
break;
case "telnet":
token = tokenService.createTelnetToken(hostname, username, password, {
@@ -129,7 +133,7 @@ router.post(
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = parseInt(req.params.hostId, 10);
const hostId = Number.parseInt(String(req.params.hostId), 10);
if (!hostId || isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
@@ -206,10 +210,15 @@ router.post(
});
break;
case "vnc":
token = tokenService.createVncToken(hostname, password, {
port: port || 5900,
...guacConfig,
});
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port: port || 5900,
...guacConfig,
},
);
break;
case "telnet":
token = tokenService.createTelnetToken(hostname, username, password, {
+2
View File
@@ -141,6 +141,7 @@ export class GuacamoleTokenService {
createVncToken(
hostname: string,
username?: string,
password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
): string {
@@ -149,6 +150,7 @@ export class GuacamoleTokenService {
type: "vnc",
settings: {
hostname,
...(username ? { username } : {}),
password,
port: 5900,
...options,
+20 -13
View File
@@ -1,6 +1,5 @@
import { Client as SSHClient } from "ssh2";
import { WebSocketServer, WebSocket } from "ws";
import { parse as parseUrl } from "url";
import { AuthManager } from "../utils/auth-manager.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import { and, eq } from "drizzle-orm";
@@ -27,14 +26,18 @@ const wss = new WebSocketServer({
port: 30009,
verifyClient: async (info) => {
try {
const url = parseUrl(info.req.url || "", true);
let token = url.query.token as string;
let token: string | undefined;
const cookieHeader = info.req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
}
if (!token) {
const cookieHeader = info.req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
const authHeader = info.req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
}
@@ -239,14 +242,18 @@ async function createJumpHostChain(
}
wss.on("connection", async (ws: WebSocket, req) => {
const url = parseUrl(req.url || "", true);
let token = url.query.token as string;
let token: string | undefined;
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
}
if (!token) {
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
}
+1 -1
View File
@@ -971,7 +971,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
userId,
});
let errorStage: ConnectionStage = "error";
let errorStage: ConnectionStage;
if (
err.message.includes("ENOTFOUND") ||
err.message.includes("getaddrinfo")
+347 -131
View File
@@ -361,6 +361,31 @@ async function createJumpHostChain(
}
}
// Serializes SSH channel open requests so only one channel negotiation is
// in-flight at a time per session. Once the channel is established the slot
// is released immediately so the next open can proceed; the channels
// themselves remain open concurrently (one exec per command is short-lived,
// the SFTP channel is long-lived but only opened once).
class ChannelOpenSerializer {
private tail: Promise<void> = Promise.resolve();
// Enqueue an action that opens a channel. The action runs after the previous
// one completes (success or failure). Returns a promise that resolves with
// the action's result.
run<T>(action: () => Promise<T>): Promise<T> {
const next = this.tail.then(
() => action(),
() => action(), // run even if the previous open failed
);
// Advance tail past this slot (swallow result so the chain keeps going)
this.tail = next.then(
() => {},
() => {},
);
return next;
}
}
interface SSHSession {
client: SSHClient;
isConnected: boolean;
@@ -369,6 +394,8 @@ interface SSHSession {
activeOperations: number;
sudoPassword?: string;
sftp?: import("ssh2").SFTPWrapper;
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
channelOpener: ChannelOpenSerializer;
poolKey?: string;
userId?: string;
}
@@ -393,9 +420,11 @@ interface PendingTOTPSession {
const sshSessions: Record<string, SSHSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
// Keyed by "sessionId:path" to prevent concurrent requests for the same path
const activeListRequests: Record<string, boolean> = {};
function execWithSudo(
client: SSHClient,
session: SSHSession,
command: string,
sudoPassword: string,
): Promise<{ stdout: string; stderr: string; code: number }> {
@@ -403,7 +432,7 @@ function execWithSudo(
const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`;
client.exec(sudoCommand, (err, stream) => {
execChannel(session, sudoCommand, (err, stream) => {
if (err) {
resolve({ stdout: "", stderr: err.message, code: 1 });
return;
@@ -438,21 +467,76 @@ function getSessionSftp(
if (session.sftp) {
return Promise.resolve(session.sftp);
}
return new Promise((resolve, reject) => {
session.client.sftp((err, sftp) => {
if (err) {
return reject(err);
// Serialization: if a channel open is already in flight, join it
if (session.sftpPending) {
return session.sftpPending;
}
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
session.channelOpener.run(
() =>
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
session.client.sftp((err, sftp) => {
if (err) return reject(err);
session.sftp = sftp;
sftp.on("error", () => {
session.sftp = undefined;
});
sftp.on("close", () => {
session.sftp = undefined;
});
resolve(sftp);
});
}),
);
session.sftpPending = openOnce()
.catch((err: Error) => {
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
// Single retry after 500ms for transient server-side rate limiting
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
setTimeout(() => openOnce().then(resolve, reject), 500),
);
}
session.sftp = sftp;
sftp.on("error", () => {
session.sftp = undefined;
});
sftp.on("close", () => {
session.sftp = undefined;
});
resolve(sftp);
return Promise.reject(err);
})
.finally(() => {
session.sftpPending = undefined;
});
});
return session.sftpPending;
}
// Wraps client.exec through the channel serializer so only one SSH channel
// negotiation is in-flight at a time. The serializer slot is released as soon
// as the channel is established (not when it closes), so channels run
// concurrently once open — we only serialize the *open handshake*.
function execChannel(
session: SSHSession,
command: string,
callback: (
err: Error | undefined,
stream: import("ssh2").ClientChannel,
) => void,
): void {
session.channelOpener
.run(
() =>
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) return reject(err);
resolve(stream);
});
}),
)
.then(
(stream) => callback(undefined, stream),
(err: Error) => callback(err, undefined as never),
);
}
function cleanupSession(sessionId: string) {
@@ -840,11 +924,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
port,
username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
keepaliveInterval: 10000,
keepaliveCountMax: 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
tcpKeepAliveInitialDelay: 5000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
hostId,
ip,
@@ -1108,6 +1192,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
};
scheduleSessionCleanup(sessionId);
@@ -1173,7 +1258,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
error: err.message,
});
let errorStage: ConnectionStage = "error";
let errorStage: ConnectionStage;
if (
err.message.includes("ENOTFOUND") ||
err.message.includes("getaddrinfo")
@@ -1752,6 +1837,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
};
scheduleSessionCleanup(sessionId);
@@ -1954,6 +2040,7 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
};
scheduleSessionCleanup(sessionId);
@@ -2048,6 +2135,10 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
const { sessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const session = sshSessions[sessionId];
if (session && !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
fileLogger.info("File manager disconnection requested", {
operation: "file_disconnect_request",
sessionId,
@@ -2149,7 +2240,7 @@ app.get("/ssh/file_manager/ssh/status", (req, res) => {
* 400:
* description: Session ID is required or session not found.
*/
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
app.post("/ssh/file_manager/ssh/keepalive", async (req, res) => {
const { sessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
@@ -2173,6 +2264,18 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Probe the cached SFTP channel. If stale, clear it so the next operation
// opens a fresh one via the serialized getSessionSftp.
if (session.sftp && !session.sftpPending) {
try {
await new Promise<void>((resolve, reject) => {
session.sftp!.stat("/", (err) => (err ? reject(err) : resolve()));
});
} catch {
session.sftp = undefined;
}
}
res.json({
status: "success",
connected: true,
@@ -2226,6 +2329,17 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
return res.status(403).json({ error: "Session access denied" });
}
// Drop concurrent requests for the same session+path — each would open
// a new SSH channel and can exceed the server's per-connection channel limit.
const listKey = `${sessionId}:${sshPath}`;
if (activeListRequests[listKey]) {
return res.status(409).json({ error: "List request already in progress" });
}
activeListRequests[listKey] = true;
res.on("finish", () => {
delete activeListRequests[listKey];
});
sshConn.lastActive = Date.now();
sshConn.activeOperations++;
const trySFTP = () => {
@@ -2327,6 +2441,13 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
fileLogger.warn(
`SFTP failed for listFiles, trying fallback: ${err.message}`,
);
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
sshConn.isConnected = false;
sshConn.sftp = undefined;
}
tryFallbackMethod();
});
} catch (sftpErr: unknown) {
@@ -2340,11 +2461,14 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
const tryFallbackMethod = () => {
if (!sshConn?.isConnected) {
sshConn.activeOperations--;
return res.status(500).json({ error: "SSH session disconnected" });
return res
.status(503)
.json({ error: "SSH session disconnected", disconnected: true });
}
try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(
execChannel(
sshConn,
`command ls -la --color=never '${escapedPath}'`,
(err, stream) => {
if (err) {
@@ -2472,7 +2596,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
sshConn.client.exec(sudoCommand, (err, stream) => {
execChannel(sshConn, sudoCommand, (err, stream) => {
if (err) {
sshConn.activeOperations--;
fileLogger.error("SSH sudo listFiles error:", err);
@@ -2628,6 +2752,7 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const linkPath = decodeURIComponent(req.query.path as string);
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
@@ -2637,6 +2762,10 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!linkPath) {
return res.status(400).json({ error: "Link path is required" });
}
@@ -2646,7 +2775,7 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;
sshConn.client.exec(command, (err, stream) => {
execChannel(sshConn, command, (err, stream) => {
if (err) {
fileLogger.error("SSH identifySymlink error:", err);
return res.status(500).json({ error: err.message });
@@ -2722,6 +2851,7 @@ app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const rawPath = decodeURIComponent(req.query.path as string);
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
@@ -2731,21 +2861,26 @@ app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!rawPath) {
return res.status(400).json({ error: "Path is required" });
}
sshConn.lastActive = Date.now();
let expandPath = rawPath;
if (expandPath.startsWith("~")) {
expandPath = "$HOME" + expandPath.substring(1);
let command: string;
if (rawPath.startsWith("~")) {
const rest = rawPath.substring(1).replace(/'/g, "'\"'\"'");
command = `echo ~'${rest}'`;
} else {
const escapedPath = rawPath.replace(/'/g, "'\"'\"'");
command = `echo '${escapedPath}'`;
}
const escapedPath = expandPath.replace(/"/g, '\\"');
const command = `echo "${escapedPath}"`;
sshConn.client.exec(command, (err, stream) => {
execChannel(sshConn, command, (err, stream) => {
if (err) {
fileLogger.error("SSH resolvePath error:", err);
return res.status(500).json({ error: err.message });
@@ -2826,6 +2961,10 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) {
return res.status(400).json({ error: "File path is required" });
}
@@ -2841,7 +2980,8 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
const MAX_READ_SIZE = 500 * 1024 * 1024;
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(
execChannel(
sshConn,
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
(sizeErr, sizeStream) => {
if (sizeErr) {
@@ -2899,7 +3039,7 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
});
}
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
execChannel(sshConn, `cat '${escapedPath}'`, (err, stream) => {
if (err) {
fileLogger.error("SSH readFile error:", err);
return res.status(500).json({ error: err.message });
@@ -3006,6 +3146,10 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) {
return res.status(400).json({ error: "File path is required" });
}
@@ -3067,7 +3211,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
const chmodCommand = `chmod ${permissions} '${escapedPath}' && echo "SUCCESS"`;
sshConn.client.exec(chmodCommand, (err, stream) => {
execChannel(sshConn, chmodCommand, (err, stream) => {
if (err) {
fileLogger.warn("Failed to restore file permissions after save", {
operation: "file_write_restore_permissions",
@@ -3170,83 +3314,90 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
}
sftp.stat(filePath, (statErr, stats) => {
if (statErr) {
fileLogger.warn(
"Failed to read existing file permissions before save",
{
operation: "file_write_stat",
try {
if (statErr) {
fileLogger.warn(
"Failed to read existing file permissions before save",
{
operation: "file_write_stat",
sessionId,
userId,
path: filePath,
error: statErr.message,
},
);
} else if (stats.isFile()) {
preservedMode = stats.mode & 0o7777;
}
const writeStream = sftp.createWriteStream(filePath);
let hasError = false;
let hasFinished = false;
let isFinalizing = false;
const finalizeSuccess = () => {
if (hasError || hasFinished) return;
hasFinished = true;
isFinalizing = false;
fileLogger.success("File written successfully", {
operation: "file_write_success",
sessionId,
userId,
path: filePath,
error: statErr.message,
},
);
} else if (stats.isFile()) {
preservedMode = stats.mode & 0o7777;
}
const writeStream = sftp.createWriteStream(filePath);
let hasError = false;
let hasFinished = false;
let isFinalizing = false;
const finalizeSuccess = () => {
if (hasError || hasFinished) return;
hasFinished = true;
isFinalizing = false;
fileLogger.success("File written successfully", {
operation: "file_write_success",
sessionId,
userId,
path: filePath,
bytes: fileBuffer.length,
});
if (!res.headersSent) {
res.json({
message: "File written successfully",
path: filePath,
toast: {
type: "success",
message: `File written: ${filePath}`,
},
bytes: fileBuffer.length,
});
if (!res.headersSent) {
res.json({
message: "File written successfully",
path: filePath,
toast: {
type: "success",
message: `File written: ${filePath}`,
},
});
}
};
writeStream.on("error", (streamErr) => {
if (hasError || hasFinished || isFinalizing) return;
hasError = true;
isFinalizing = false;
fileLogger.warn(
`SFTP write failed, trying fallback method: ${streamErr.message}`,
);
tryFallbackMethod();
});
const finishWrite = () => {
if (hasError || hasFinished || isFinalizing) return;
isFinalizing = true;
restoreOriginalMode(sftp, finalizeSuccess);
};
writeStream.on("finish", () => {
finishWrite();
});
writeStream.on("close", () => {
finishWrite();
});
try {
writeStream.write(fileBuffer);
writeStream.end();
} catch (writeErr) {
if (hasError || hasFinished) return;
hasError = true;
isFinalizing = false;
fileLogger.warn(
`SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`,
);
tryFallbackMethod();
}
};
writeStream.on("error", (streamErr) => {
if (hasError || hasFinished || isFinalizing) return;
hasError = true;
isFinalizing = false;
} catch (callbackErr) {
fileLogger.warn(
`SFTP write failed, trying fallback method: ${streamErr.message}`,
);
tryFallbackMethod();
});
const finishWrite = () => {
if (hasError || hasFinished || isFinalizing) return;
isFinalizing = true;
restoreOriginalMode(sftp, finalizeSuccess);
};
writeStream.on("finish", () => {
finishWrite();
});
writeStream.on("close", () => {
finishWrite();
});
try {
writeStream.write(fileBuffer);
writeStream.end();
} catch (writeErr) {
if (hasError || hasFinished) return;
hasError = true;
isFinalizing = false;
fileLogger.warn(
`SFTP write operation failed, trying fallback method: ${writeErr.message}`,
`SFTP stat callback error, trying fallback method: ${(callbackErr as Error).message}`,
);
tryFallbackMethod();
}
@@ -3268,8 +3419,10 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
const tryFallbackMethod = () => {
if (!sshConn?.isConnected) {
sshConn.activeOperations--;
return res.status(500).json({ error: "SSH session disconnected" });
if (!res.headersSent) {
return res.status(500).json({ error: "SSH session disconnected" });
}
return;
}
try {
let contentBuffer: Buffer;
@@ -3292,7 +3445,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => {
execChannel(sshConn, writeCommand, (err, stream) => {
if (err) {
fileLogger.error("Fallback write command failed:", err);
if (!res.headersSent) {
@@ -3318,6 +3471,10 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
errorData += chunk.toString();
});
stream.stderr.on("error", (stderrErr) => {
fileLogger.error("Fallback write stderr error:", stderrErr);
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
restoreOriginalMode(null, () => {
@@ -3357,9 +3514,9 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
} catch (fallbackErr) {
fileLogger.error("Fallback method failed:", fallbackErr);
if (!res.headersSent) {
res
.status(500)
.json({ error: `All write methods failed: ${fallbackErr.message}` });
res.status(500).json({
error: `All write methods failed: ${(fallbackErr as Error).message}`,
});
}
}
};
@@ -3411,6 +3568,10 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath || !fileName || content === undefined) {
return res
.status(400)
@@ -3560,8 +3721,10 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
const tryFallbackMethod = () => {
if (!sshConn?.isConnected) {
sshConn.activeOperations--;
return res.status(500).json({ error: "SSH session disconnected" });
if (!res.headersSent) {
return res.status(500).json({ error: "SSH session disconnected" });
}
return;
}
try {
let contentBuffer: Buffer;
@@ -3606,7 +3769,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => {
execChannel(sshConn, writeCommand, (err, stream) => {
if (err) {
fileLogger.error("Fallback upload command failed:", err);
if (!res.headersSent) {
@@ -3680,7 +3843,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
writeCommand += ` && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => {
execChannel(sshConn, writeCommand, (err, stream) => {
if (err) {
fileLogger.error("Chunked fallback upload failed:", err);
if (!res.headersSent) {
@@ -3797,6 +3960,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
const { sessionId, path: filePath, fileName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
@@ -3806,6 +3970,10 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath || !fileName) {
return res.status(400).json({ error: "File path and name are required" });
}
@@ -3819,7 +3987,7 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(createCommand, (err, stream) => {
execChannel(sshConn, createCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH createFile error:", err);
if (!res.headersSent) {
@@ -3939,6 +4107,10 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!folderPath || !folderName) {
return res.status(400).json({ error: "Folder path and name are required" });
}
@@ -3958,7 +4130,7 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(createCommand, (err, stream) => {
execChannel(sshConn, createCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH createFolder error:", err);
if (!res.headersSent) {
@@ -4090,6 +4262,10 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!itemPath) {
return res.status(400).json({ error: "Item path is required" });
}
@@ -4111,7 +4287,7 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
const executeDelete = (useSudo: boolean): Promise<void> => {
return new Promise((resolve) => {
if (useSudo && sshConn.sudoPassword) {
execWithSudo(sshConn.client, deleteCommand, sshConn.sudoPassword).then(
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
(result) => {
if (
result.code === 0 ||
@@ -4137,7 +4313,8 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
return;
}
sshConn.client.exec(
execChannel(
sshConn,
`${deleteCommand} && echo "SUCCESS"`,
(err, stream) => {
if (err) {
@@ -4259,6 +4436,10 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newName) {
return res
.status(400)
@@ -4281,7 +4462,7 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(renameCommand, (err, stream) => {
execChannel(sshConn, renameCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH renameItem error:", err);
if (!res.headersSent) {
@@ -4412,6 +4593,7 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
const { sessionId, oldPath, newPath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
@@ -4421,6 +4603,10 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newPath) {
return res
.status(400)
@@ -4446,7 +4632,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
}
}, 60000);
sshConn.client.exec(moveCommand, (err, stream) => {
execChannel(sshConn, moveCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH moveItem error:", err);
@@ -4566,7 +4752,8 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
* description: Failed to download file.
*/
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
const { sessionId, path: filePath, hostId, userId } = req.body;
const { sessionId, path: filePath, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const downloadStartTime = Date.now();
if (!sessionId || !filePath) {
@@ -4597,6 +4784,10 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
fileLogger.info("Opening SFTP channel", {
@@ -4713,7 +4904,8 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
* description: Failed to copy item.
*/
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
const { sessionId, sourcePath, targetDir, hostId, userId } = req.body;
const { sessionId, sourcePath, targetDir, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !sourcePath || !targetDir) {
return res.status(400).json({ error: "Missing required parameters" });
@@ -4726,6 +4918,10 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
@@ -4757,7 +4953,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
}
}, 60000);
sshConn.client.exec(copyCommand, (err, stream) => {
execChannel(sshConn, copyCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem error:", err);
@@ -4905,6 +5101,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const { sessionId, filePath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
@@ -4919,6 +5116,10 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) {
return res.status(400).json({ error: "File path is required" });
}
@@ -4927,7 +5128,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
if (checkErr) {
fileLogger.error("SSH executeFile check error:", checkErr);
return res
@@ -4947,7 +5148,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
sshConn.client.exec(executeCommand, (err, stream) => {
execChannel(sshConn, executeCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH executeFile error:", err);
return res.status(500).json({ error: "Failed to execute file" });
@@ -5034,6 +5235,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
@@ -5048,6 +5250,10 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!path) {
return res.status(400).json({ error: "File path is required" });
}
@@ -5086,7 +5292,7 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
}
}, 10000);
sshConn.client.exec(command, (err, stream) => {
execChannel(sshConn, command, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions exec error:", err, {
@@ -5232,6 +5438,7 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
*/
app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
const { sessionId, archivePath, extractPath } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !archivePath) {
return res.status(400).json({ error: "Missing required parameters" });
@@ -5242,13 +5449,17 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
return res.status(400).json({ error: "SSH session not connected" });
}
if (!verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const fileName = archivePath.split("/").pop() || "";
const fileExt = fileName.toLowerCase();
let extractCommand = "";
let extractCommand: string;
const targetPath =
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
@@ -5290,7 +5501,7 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
command: extractCommand,
});
session.client.exec(extractCommand, (err, stream) => {
execChannel(session, extractCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during extract:", err, {
operation: "extract_archive",
@@ -5438,6 +5649,7 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
*/
app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
const { sessionId, paths, archiveName, format } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (
!sessionId ||
@@ -5454,11 +5666,15 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
return res.status(400).json({ error: "SSH session not connected" });
}
if (!verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const compressionFormat = format || "zip";
let compressCommand = "";
let compressCommand: string;
const firstPath = paths[0];
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
@@ -5509,7 +5725,7 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
command: compressCommand,
});
session.client.exec(compressCommand, (err, stream) => {
execChannel(session, compressCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during compress:", err, {
operation: "compress_files",
-2
View File
@@ -1,7 +1,6 @@
import { spawn, ChildProcess } from "child_process";
import { randomUUID } from "crypto";
import { WebSocket } from "ws";
import { IncomingMessage } from "http";
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
import { sshLogger } from "../utils/logger.js";
import { getDb } from "../database/db/index.js";
@@ -13,7 +12,6 @@ import { promises as fs } from "fs";
import path from "path";
import axios from "axios";
import yaml from "js-yaml";
import { getRequestOrigin } from "../utils/request-origin.js";
const AUTH_TIMEOUT = 60 * 1000;
+74 -12
View File
@@ -4,12 +4,64 @@
// DER → SSH wire format, and patches Protocol.authPK to use the base
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
import type { Client, ConnectConfig } from "ssh2";
import type {
AnyAuthMethod,
AuthHandlerMiddleware,
AuthenticationType,
Client,
ConnectConfig,
PublicKeyAuthMethod,
} from "ssh2";
interface OPKSSHToken {
privateKey: string;
sshCert: string;
}
type SignCallback = (
data: Buffer,
callback: (signature: Buffer) => void,
) => void;
interface ParsedPrivateKey {
type: string;
sign: (data: Buffer, algo?: string) => Buffer | Error;
getPublicSSH: () => Buffer;
[key: symbol]: unknown;
}
interface OPKSSHProtocol {
authPK: (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => unknown;
_kex: {
sessionID: Buffer;
};
_packetRW: {
write: {
alloc: (payloadLength: number) => Buffer;
allocStart: number;
finalize: (packet: Buffer) => Buffer;
};
};
_authsQueue: string[];
_debug?: (message: string) => void;
_cipher: {
encrypt: (packet: Buffer) => void;
};
}
type OPKSSHClient = Client & {
_protocol?: OPKSSHProtocol;
};
type OPKSSHNextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false,
) => void;
export async function setupOPKSSHCertAuth(
config: ConnectConfig,
client: Client,
@@ -26,7 +78,9 @@ export async function setupOPKSSHCertAuth(
if (parsed instanceof Error || !parsed) {
throw new Error("Failed to parse OPKSSH private key");
}
const privKey: any = Array.isArray(parsed) ? parsed[0] : parsed;
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
// Extract cert type and blob from the stored certificate
const certParts = token.sshCert.trim().split(/\s+/);
@@ -78,36 +132,43 @@ export async function setupOPKSSHCertAuth(
// Set up authHandler to bypass ssh2's cert type rejection
let certAuthAttempted = false;
config.authHandler = (
const authHandler: AuthHandlerMiddleware = (
methodsLeft: string[],
_partialSuccess: boolean,
callback: (authInfo: any) => void,
callback,
) => {
const next = callback as OPKSSHNextAuthHandler;
if (
!certAuthAttempted &&
(!methodsLeft || methodsLeft.includes("publickey"))
) {
certAuthAttempted = true;
callback({ type: "publickey", username, key: privKey });
next({
type: "publickey",
username,
key: privKey as unknown as PublicKeyAuthMethod["key"],
});
} else {
callback(false);
next(false);
}
};
config.authHandler = authHandler;
// Monkey-patch Protocol.authPK after connect() to fix the signature
// wrapper algorithm for cert types.
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
const origConnect = client.connect.bind(client);
(client as any).connect = (cfg: any) => {
origConnect(cfg);
const proto = (client as any)._protocol;
if (!proto) return;
const patchedClient = client as OPKSSHClient;
patchedClient.connect = (cfg: ConnectConfig) => {
const connectedClient = origConnect(cfg);
const proto = patchedClient._protocol;
if (!proto) return connectedClient;
const origAuthPK = proto.authPK.bind(proto);
proto.authPK = (
user: string,
pubKey: any,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: Function,
cbSign?: SignCallback,
) => {
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
if (!isCertAuth) {
@@ -232,5 +293,6 @@ export async function setupOPKSSHCertAuth(
proto._cipher.encrypt(finalized);
});
};
return connectedClient;
};
}
+7 -7
View File
@@ -1088,8 +1088,6 @@ class PollingManager {
for (const { host, viewerUserId } of hostsToRefresh) {
await this.startPollingForHost(host, { statusOnly: true, viewerUserId });
}
const skipped = this.pollingConfigs.size - hostsToRefresh.length;
}
registerViewer(hostId: number, sessionId: string, userId: string): void {
@@ -1331,9 +1329,8 @@ async function resolveHostCredentials(
const isSharedHost = userId !== ownerId;
if (isSharedHost) {
const { SharedCredentialManager } = await import(
"../utils/shared-credential-manager.js"
);
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id as number,
@@ -1552,7 +1549,9 @@ async function buildSshConfig(
statsLogger.error(
`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
);
throw new Error(`Invalid SSH key format for host ${host.ip}`);
throw new Error(`Invalid SSH key format for host ${host.ip}`, {
cause: keyError,
});
}
} else if (host.authType === "none") {
// no credentials needed
@@ -1654,6 +1653,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
(proxyError instanceof Error
? proxyError.message
: "Unknown error"),
{ cause: proxyError },
);
}
}
@@ -2581,7 +2581,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
const errorMessage =
error instanceof Error ? error.message : String(error);
let errorStage: ConnectionStage = "error";
let errorStage: ConnectionStage;
if (
errorMessage.includes("ENOTFOUND") ||
+162 -31
View File
@@ -3,7 +3,6 @@ import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import net from "net";
import dgram from "dgram";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { parse as parseUrl } from "url";
import axios from "axios";
import { getDb } from "../database/db/index.js";
import { sshCredentials, hosts } from "../database/db/schema.js";
@@ -367,14 +366,18 @@ const wss = new WebSocketServer({
port: 30002,
verifyClient: async (info) => {
try {
const url = parseUrl(info.req.url!, true);
let token = url.query.token as string;
let token: string | undefined;
const cookieHeader = info.req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
}
if (!token) {
const cookieHeader = info.req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
const authHeader = info.req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
}
@@ -414,14 +417,18 @@ wss.on("connection", async (ws: WebSocket, req) => {
let sessionId: string | undefined;
try {
const url = parseUrl(req.url!, true);
let token = url.query.token as string;
let token: string | undefined;
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
}
if (!token) {
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]);
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
}
@@ -487,6 +494,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
let isConnecting = false;
let isConnected = false;
let isCleaningUp = false;
let cwdPending = false;
let cwdBuffer = "";
let isShellInitializing = false;
let warpgateAuthPromptSent = false;
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
@@ -590,6 +599,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
connectData.hostConfig.userId = userId;
}
handleConnectToHost(connectData).catch((error) => {
const errMsg =
error instanceof Error ? error.message : "Unknown error";
if (
errMsg.includes("Cannot parse privateKey") &&
errMsg.includes("no passphrase")
) {
isAwaitingAuthCredentials = true;
ws.send(
JSON.stringify({
type: "passphrase_required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
}),
);
return;
}
sshLogger.error("Failed to connect to host", error, {
operation: "ssh_connect",
userId,
@@ -599,9 +624,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect to host: " +
(error instanceof Error ? error.message : "Unknown error"),
message: "Failed to connect to host: " + errMsg,
}),
);
});
@@ -729,6 +752,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshStream = null;
break;
case "get_cwd": {
const activeStream =
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
if (!activeStream) {
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
break;
}
cwdPending = true;
cwdBuffer = "";
// Split the sentinel across shell variables so the echoed command
// itself never contains "TERMIX_CWD:" — only the output line does.
activeStream.write('a=TERMIX_CWD; echo "$a:$(pwd)"\r');
break;
}
case "input": {
const inputData = data as string;
const inputStream =
@@ -898,6 +936,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
credentialsData.hostConfig.key = credentialsData.sshKey;
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
credentialsData.hostConfig.authType = "key";
} else if (credentialsData.keyPassword) {
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
}
isAwaitingAuthCredentials = false;
@@ -916,6 +956,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
};
handleConnectToHost(reconnectData).catch((error) => {
const errMsg =
error instanceof Error ? error.message : "Unknown error";
if (
errMsg.includes("Cannot parse privateKey") &&
errMsg.includes("no passphrase")
) {
isAwaitingAuthCredentials = true;
ws.send(
JSON.stringify({
type: "passphrase_required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
}),
);
return;
}
sshLogger.error("Failed to reconnect with credentials", error, {
operation: "ssh_reconnect_with_credentials",
userId,
@@ -925,9 +981,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect with provided credentials: " +
(error instanceof Error ? error.message : "Unknown error"),
message: "Failed to connect with provided credentials: " + errMsg,
}),
);
});
@@ -1196,7 +1250,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
username: resolvedHost.username || username,
password: resolvedHost.password,
key: resolvedHost.key,
keyPassword: resolvedHost.keyPassword,
keyPassword: keyPassword || resolvedHost.keyPassword,
keyType: resolvedHost.keyType,
authType: resolvedHost.authType,
};
@@ -1222,7 +1276,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
username: resolvedHost.username || username,
password: resolvedHost.password,
key: resolvedHost.key,
keyPassword: resolvedHost.keyPassword,
// Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value
keyPassword: keyPassword || resolvedHost.keyPassword,
keyType: resolvedHost.keyType,
authType: resolvedHost.authType,
};
@@ -1439,9 +1494,47 @@ wss.on("connection", async (ws: WebSocket, req) => {
const boundSessionId = currentSessionId;
const CWD_SENTINEL = "TERMIX_CWD:";
stream.on("data", (data: Buffer) => {
try {
const utf8String = data.toString("utf-8");
let utf8String = data.toString("utf-8");
if (cwdPending) {
cwdBuffer += utf8String;
const sentinelIdx = cwdBuffer.indexOf(CWD_SENTINEL);
if (sentinelIdx !== -1) {
const afterSentinel = cwdBuffer.slice(
sentinelIdx + CWD_SENTINEL.length,
);
const newlineIdx = afterSentinel.search(/[\r\n]/);
if (newlineIdx !== -1) {
const cwd =
afterSentinel.slice(0, newlineIdx).trim() || "/";
cwdPending = false;
// Strip the sentinel line from output sent to terminal
const beforeSentinel = cwdBuffer.slice(0, sentinelIdx);
const afterNewline = afterSentinel.slice(newlineIdx);
utf8String = beforeSentinel + afterNewline;
cwdBuffer = "";
const attachedWs =
sessionManager.getSession(boundSessionId)?.attachedWs ??
ws;
if (attachedWs.readyState === WebSocket.OPEN) {
attachedWs.send(
JSON.stringify({ type: "cwd", path: cwd }),
);
}
} else {
return;
}
} else {
return;
}
}
if (!utf8String) return;
const session = sessionManager.getSession(boundSessionId);
if (session) {
sessionManager.bufferOutput(boundSessionId!, utf8String);
@@ -1472,15 +1565,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
});
stream.on("close", () => {
stream.on("close", (code: number | null) => {
const session = sessionManager.getSession(boundSessionId);
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
session.attachedWs.send(
JSON.stringify({
type: "disconnected",
message: "Connection lost",
}),
);
if (code != null) {
session.attachedWs.send(
JSON.stringify({
type: "session_ended",
code,
}),
);
} else {
session.attachedWs.send(
JSON.stringify({
type: "disconnected",
message: "Connection lost",
}),
);
}
}
if (boundSessionId) {
sessionManager.destroySession(boundSessionId);
@@ -1740,6 +1842,31 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
if (
err.message.includes("Cannot parse privateKey") &&
err.message.includes("no passphrase")
) {
sendLog(
"auth",
"error",
"SSH key is encrypted but no passphrase was provided",
);
isAwaitingAuthCredentials = true;
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
ws.send(
JSON.stringify({
type: "passphrase_required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
}),
);
return;
}
if (
authMethodNotAvailable &&
resolvedCredentials.authType === "none" &&
@@ -1913,7 +2040,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}),
);
}
} else if (!sshStream) {
} else {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
@@ -2102,6 +2229,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (resolvedCredentials.keyPassword) {
connectConfig.passphrase = resolvedCredentials.keyPassword;
}
if (resolvedCredentials.password) {
connectConfig.password = resolvedCredentials.password;
}
} catch (keyError) {
sshLogger.error("SSH key format error: " + keyError.message);
ws.send(
@@ -2191,7 +2322,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
{ operation: "port_knock", hostId: hostConfig.id },
);
await performPortKnocking(hostConfig.ip, hostConfig.portKnockSequence);
} catch (err) {
} catch {
sshLogger.warn("Port knocking failed, attempting connection anyway", {
operation: "port_knock",
hostId: hostConfig.id,
+1240 -256
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -79,7 +79,6 @@ export async function collectCpuMetrics(client: Client): Promise<{
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch {
cpuPercent = null;
cores = null;
loadTriplet = null;
}
+1
View File
@@ -98,6 +98,7 @@ import {
const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeEncryptionKey();
await systemCrypto.initializeInternalAuthToken();
await AutoSSLSetup.initialize();
+8 -3
View File
@@ -1,15 +1,20 @@
import swaggerJSDoc from "swagger-jsdoc";
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
import path from "path";
import { fileURLToPath } from "url";
import { promises as fs } from "fs";
import { systemLogger } from "./utils/logger.js";
interface SwaggerOptions {
definition: object;
apis: string[];
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.join(__dirname, "..", "..", "..");
const swaggerOptions: swaggerJSDoc.Options = {
const swaggerOptions: SwaggerOptions = {
definition: {
openapi: "3.0.3",
info: {
@@ -130,7 +135,7 @@ async function generateOpenAPISpec() {
operation: "openapi_generate_start",
});
const swaggerSpec = swaggerJSDoc(swaggerOptions);
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
const outputPath = path.join(projectRoot, "openapi.json");
+299 -22
View File
@@ -1,15 +1,13 @@
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { UserCrypto } from "./user-crypto.js";
import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger, authLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express";
import {
db,
getSqlite,
saveMemoryDatabaseToFile,
} from "../database/db/index.js";
import { sessions, trustedDevices } from "../database/db/schema.js";
import bcrypt from "bcryptjs";
import { db } from "../database/db/index.js";
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import type { DeviceType } from "./user-agent-parser.js";
@@ -29,12 +27,21 @@ interface JWTPayload {
userId: string;
sessionId?: string;
pendingTOTP?: boolean;
dataKeyWrap?: WrappedDataKey;
iat?: number;
exp?: number;
}
interface WrappedDataKey {
version: "v1";
iv: string;
tag: string;
data: string;
}
interface AuthenticatedRequest extends Request {
userId?: string;
sessionId?: string;
pendingTOTP?: boolean;
dataKey?: Buffer;
}
@@ -198,6 +205,113 @@ class AuthManager {
}
}
private getDataKeyAAD(userId: string, sessionId?: string): Buffer {
return Buffer.from(`${userId}:${sessionId || ""}`, "utf8");
}
private async wrapUserDataKey(
userId: string,
sessionId: string | undefined,
dataKey: Buffer,
): Promise<WrappedDataKey> {
const encryptionKey = await this.systemCrypto.getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv);
cipher.setAAD(this.getDataKeyAAD(userId, sessionId));
const encrypted = Buffer.concat([cipher.update(dataKey), cipher.final()]);
const tag = cipher.getAuthTag();
return {
version: "v1",
iv: iv.toString("base64url"),
tag: tag.toString("base64url"),
data: encrypted.toString("base64url"),
};
}
private async unwrapUserDataKey(
userId: string,
sessionId: string | undefined,
wrapped: WrappedDataKey,
): Promise<Buffer> {
if (wrapped.version !== "v1") {
throw new Error(
`Unsupported wrapped data key version: ${wrapped.version}`,
);
}
const encryptionKey = await this.systemCrypto.getEncryptionKey();
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
encryptionKey,
Buffer.from(wrapped.iv, "base64url"),
);
decipher.setAAD(this.getDataKeyAAD(userId, sessionId));
decipher.setAuthTag(Buffer.from(wrapped.tag, "base64url"));
return Buffer.concat([
decipher.update(Buffer.from(wrapped.data, "base64url")),
decipher.final(),
]);
}
private async addWrappedDataKey(payload: JWTPayload): Promise<void> {
if (payload.pendingTOTP) {
return;
}
const dataKey = this.userCrypto.getUserDataKey(payload.userId);
if (!dataKey) {
return;
}
payload.dataKeyWrap = await this.wrapUserDataKey(
payload.userId,
payload.sessionId,
dataKey,
);
}
private async restoreDataKeyFromPayload(
payload: JWTPayload,
sessionExpiresAt?: string,
): Promise<void> {
if (
!payload.dataKeyWrap ||
this.userCrypto.getUserDataKey(payload.userId)
) {
return;
}
const expiresAt = sessionExpiresAt
? new Date(sessionExpiresAt).getTime()
: payload.exp
? payload.exp * 1000
: Date.now();
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
return;
}
try {
const dataKey = await this.unwrapUserDataKey(
payload.userId,
payload.sessionId,
payload.dataKeyWrap,
);
this.userCrypto.restoreUserDataKey(payload.userId, dataKey, expiresAt);
dataKey.fill(0);
} catch (error) {
databaseLogger.warn("Failed to restore data key from session token", {
operation: "session_data_key_restore_failed",
userId: payload.userId,
sessionId: payload.sessionId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
async generateJWTToken(
userId: string,
options: {
@@ -234,6 +348,7 @@ class AuthManager {
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
const sessionId = nanoid();
payload.sessionId = sessionId;
await this.addWrappedDataKey(payload);
const token = jwt.sign(payload, jwtSecret, {
expiresIn,
@@ -281,6 +396,7 @@ class AuthManager {
return token;
}
await this.addWrappedDataKey(payload);
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
}
@@ -327,6 +443,11 @@ class AuthManager {
});
return null;
}
await this.restoreDataKeyFromPayload(
payload,
sessionRecords[0].expiresAt,
);
} catch (dbError) {
databaseLogger.error(
"Failed to check session in database during JWT verification",
@@ -338,6 +459,8 @@ class AuthManager {
);
return null;
}
} else {
await this.restoreDataKeyFromPayload(payload);
}
return payload;
} catch (error) {
@@ -350,12 +473,63 @@ class AuthManager {
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async refreshSessionToken(
userId: string,
sessionId: string,
): Promise<{ token: string; maxAge: number } | null> {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (sessionRecords.length === 0 || sessionRecords[0].userId !== userId) {
return null;
}
const expiresAt = new Date(sessionRecords[0].expiresAt).getTime();
const maxAge = expiresAt - Date.now();
if (!Number.isFinite(maxAge) || maxAge <= 0) {
return null;
}
const payload: JWTPayload = { userId, sessionId };
await this.addWrappedDataKey(payload);
const token = jwt.sign(payload, await this.systemCrypto.getJWTSecret(), {
expiresIn: Math.ceil(maxAge / 1000),
} as jwt.SignOptions);
await db
.update(sessions)
.set({
jwtToken: token,
lastActiveAt: new Date().toISOString(),
})
.where(eq(sessions.id, sessionId));
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session token refresh",
saveError,
{
operation: "session_token_refresh_db_save_failed",
sessionId,
},
);
}
return { token, maxAge };
}
invalidateJWTToken(_token: string): void {
// expected - no-op, JWT tokens are stateless
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
invalidateUserTokens(_userId: string): void {
// expected - no-op, handled by session management
}
@@ -537,9 +711,9 @@ class AuthManager {
maxAge: number = 24 * 60 * 60 * 1000,
) {
return {
httpOnly: false,
httpOnly: true,
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
sameSite: "strict" as const,
sameSite: "lax" as const,
maxAge: maxAge,
path: "/",
};
@@ -547,13 +721,88 @@ class AuthManager {
getClearCookieOptions(req: RequestWithHeaders) {
return {
httpOnly: false,
httpOnly: true,
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
sameSite: "strict" as const,
sameSite: "lax" as const,
path: "/",
};
}
private async handleApiKeyAuth(
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
token: string,
requireAdmin = false,
): Promise<void> {
try {
const tokenPrefix = token.substring(0, 12);
const candidates = await db
.select()
.from(apiKeys)
.where(
and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
);
if (candidates.length === 0) {
res.status(401).json({ error: "Invalid API key" });
return;
}
let matchedKey: (typeof candidates)[0] | null = null;
for (const candidate of candidates) {
if (await bcrypt.compare(token, candidate.tokenHash)) {
matchedKey = candidate;
break;
}
}
if (!matchedKey) {
res.status(401).json({ error: "Invalid API key" });
return;
}
if (matchedKey.expiresAt && new Date(matchedKey.expiresAt) < new Date()) {
res.status(401).json({ error: "API key has expired" });
return;
}
if (requireAdmin) {
const { users } = await import("../database/db/schema.js");
const userRows = await db
.select()
.from(users)
.where(eq(users.id, matchedKey.userId))
.limit(1);
if (!userRows[0]?.isAdmin) {
res.status(403).json({ error: "Admin access required" });
return;
}
}
db.update(apiKeys)
.set({ lastUsedAt: new Date().toISOString() })
.where(eq(apiKeys.id, matchedKey.id))
.then(() => {})
.catch((err) => {
databaseLogger.warn("Failed to update API key lastUsedAt", {
operation: "api_key_update_last_used",
keyId: matchedKey!.id,
error: err instanceof Error ? err.message : "Unknown",
});
});
req.userId = matchedKey.userId;
next();
} catch (error) {
databaseLogger.error("API key authentication failed", error, {
operation: "api_key_auth_failed",
});
res.status(500).json({ error: "API key authentication failed" });
}
}
createAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const authReq = req as AuthenticatedRequest;
@@ -570,10 +819,17 @@ class AuthManager {
return res.status(401).json({ error: "Missing authentication token" });
}
if (token.startsWith("tmx_")) {
return this.handleApiKeyAuth(authReq, res, next, token);
}
const payload = await this.verifyJWTToken(token);
if (!payload) {
return res.status(401).json({ error: "Invalid token" });
return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({ error: "Invalid token" });
}
if (payload.pendingTOTP) {
@@ -597,10 +853,13 @@ class AuthManager {
sessionId: payload.sessionId,
userId: payload.userId,
});
return res.status(401).json({
error: "Session not found",
code: "SESSION_NOT_FOUND",
});
return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({
error: "Session not found",
code: "SESSION_NOT_FOUND",
});
}
const session = sessionRecords[0];
@@ -657,10 +916,13 @@ class AuthManager {
);
});
return res.status(401).json({
error: "Session has expired",
code: "SESSION_EXPIRED",
});
return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({
error: "Session has expired",
code: "SESSION_EXPIRED",
});
}
db.update(sessions)
@@ -684,6 +946,7 @@ class AuthManager {
}
authReq.userId = payload.userId;
authReq.sessionId = payload.sessionId;
authReq.pendingTOTP = payload.pendingTOTP;
next();
};
@@ -718,10 +981,23 @@ class AuthManager {
return res.status(401).json({ error: "Missing authentication token" });
}
if (token.startsWith("tmx_")) {
return this.handleApiKeyAuth(
req as AuthenticatedRequest,
res,
next,
token,
true,
);
}
const payload = await this.verifyJWTToken(token);
if (!payload) {
return res.status(401).json({ error: "Invalid token" });
return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({ error: "Invalid token" });
}
if (payload.pendingTOTP) {
@@ -755,6 +1031,7 @@ class AuthManager {
const authReq = req as AuthenticatedRequest;
authReq.userId = payload.userId;
authReq.sessionId = payload.sessionId;
authReq.pendingTOTP = payload.pendingTOTP;
next();
} catch (error) {
+1
View File
@@ -171,6 +171,7 @@ IP.3 = 0.0.0.0
} catch (error) {
throw new Error(
`SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
);
}
}
-1
View File
@@ -53,7 +53,6 @@ export function createCorsMiddleware(
return callback(null, true);
const configured = getAllowedOrigins();
if (configured.length === 0) return callback(null, true);
if (configured.includes("*") || configured.includes(origin))
return callback(null, true);
@@ -102,6 +102,7 @@ class DatabaseFileEncryption {
});
throw new Error(
`Database buffer encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
);
}
}
@@ -197,6 +198,7 @@ class DatabaseFileEncryption {
});
throw new Error(
`Database file encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
);
}
}
@@ -237,6 +239,7 @@ class DatabaseFileEncryption {
if (!fs.existsSync(metadataPath)) {
throw new Error(
`Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`,
{ cause: singleFileError },
);
}
@@ -247,6 +250,7 @@ class DatabaseFileEncryption {
} catch (twoFileError) {
throw new Error(
`Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`,
{ cause: twoFileError },
);
}
}
@@ -358,6 +362,7 @@ class DatabaseFileEncryption {
`- .env file readable: ${envFileReadable}\n` +
`- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` +
`Original error: ${errorMessage}`,
{ cause: error },
);
}
@@ -366,7 +371,9 @@ class DatabaseFileEncryption {
encryptedPath,
errorMessage,
});
throw new Error(`Database buffer decryption failed: ${errorMessage}`);
throw new Error(`Database buffer decryption failed: ${errorMessage}`, {
cause: error,
});
}
}
@@ -398,6 +405,7 @@ class DatabaseFileEncryption {
});
throw new Error(
`Database file decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
);
}
}
+3 -2
View File
@@ -50,8 +50,8 @@ export class DatabaseMigration {
}
}
let needsMigration = false;
let reason = "";
let needsMigration: boolean;
let reason: string;
if (hasEncryptedDb && hasUnencryptedDb) {
const unencryptedSize = fs.statSync(this.unencryptedDbPath).size;
@@ -119,6 +119,7 @@ export class DatabaseMigration {
});
throw new Error(
`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
);
}
}
+13 -2
View File
@@ -1,4 +1,5 @@
import chalk from "chalk";
import type { ChalkInstance } from "chalk";
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
@@ -78,7 +79,17 @@ export class Logger {
}
private getTimeStamp(): string {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const now = new Date();
const format = process.env.LOG_TIMESTAMP_FORMAT?.toLowerCase();
let time: string;
if (format === "iso") {
time = now.toISOString();
} else if (format === "24h") {
time = now.toLocaleTimeString("en-GB", { hour12: false });
} else {
time = now.toLocaleTimeString();
}
return chalk.gray(`[${time}]`);
}
private sanitizeContext(context: LogContext): LogContext {
@@ -149,7 +160,7 @@ export class Logger {
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
}
private getLevelColor(level: LogLevel): chalk.Chalk {
private getLevelColor(level: LogLevel): ChalkInstance {
switch (level) {
case "debug":
return chalk.magenta;
+4 -4
View File
@@ -1,7 +1,7 @@
import { HttpsProxyAgent } from "https-proxy-agent";
import type { Agent } from "http";
import { ProxyAgent } from "undici";
import type { Dispatcher } from "undici-types";
export function getProxyAgent(targetUrl?: string): Agent | undefined {
export function getProxyAgent(targetUrl?: string): Dispatcher | undefined {
const proxyUrl =
process.env.https_proxy ||
process.env.HTTPS_PROXY ||
@@ -26,5 +26,5 @@ export function getProxyAgent(targetUrl?: string): Agent | undefined {
}
}
return new HttpsProxyAgent(proxyUrl);
return new ProxyAgent(proxyUrl) as unknown as Dispatcher;
}
+66 -4
View File
@@ -7,6 +7,7 @@ class SystemCrypto {
private static instance: SystemCrypto;
private jwtSecret: string | null = null;
private databaseKey: Buffer | null = null;
private encryptionKey: Buffer | null = null;
private internalAuthToken: string | null = null;
private credentialSharingKey: Buffer | null = null;
@@ -61,7 +62,7 @@ class SystemCrypto {
databaseLogger.error("Failed to initialize JWT secret", error, {
operation: "jwt_init_failed",
});
throw new Error("JWT secret initialization failed");
throw new Error("JWT secret initialization failed", { cause: error });
}
}
@@ -103,7 +104,7 @@ class SystemCrypto {
operation: "db_key_init_failed",
dataDir: process.env.DATA_DIR || "./db/data",
});
throw new Error("Database key initialization failed");
throw new Error("Database key initialization failed", { cause: error });
}
}
@@ -114,6 +115,46 @@ class SystemCrypto {
return this.databaseKey!;
}
async initializeEncryptionKey(): Promise<void> {
try {
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
const envKey = process.env.ENCRYPTION_KEY;
if (envKey && envKey.length >= 64) {
this.encryptionKey = Buffer.from(envKey, "hex");
return;
}
try {
const envContent = await fs.readFile(envPath, "utf8");
const keyMatch = envContent.match(/^ENCRYPTION_KEY=(.+)$/m);
if (keyMatch && keyMatch[1] && keyMatch[1].length >= 64) {
this.encryptionKey = Buffer.from(keyMatch[1], "hex");
process.env.ENCRYPTION_KEY = keyMatch[1];
return;
}
} catch {
// expected - env file may not exist
}
await this.generateAndGuideEncryptionKey();
} catch (error) {
databaseLogger.error("Failed to initialize encryption key", error, {
operation: "encryption_key_init_failed",
dataDir: process.env.DATA_DIR || "./db/data",
});
throw new Error("Encryption key initialization failed", { cause: error });
}
}
async getEncryptionKey(): Promise<Buffer> {
if (!this.encryptionKey) {
await this.initializeEncryptionKey();
}
return this.encryptionKey!;
}
async initializeInternalAuthToken(): Promise<void> {
try {
const envToken = process.env.INTERNAL_AUTH_TOKEN;
@@ -142,7 +183,9 @@ class SystemCrypto {
databaseLogger.error("Failed to initialize internal auth token", error, {
operation: "internal_auth_init_failed",
});
throw new Error("Internal auth token initialization failed");
throw new Error("Internal auth token initialization failed", {
cause: error,
});
}
}
@@ -186,7 +229,9 @@ class SystemCrypto {
dataDir: process.env.DATA_DIR || "./db/data",
},
);
throw new Error("Credential sharing key initialization failed");
throw new Error("Credential sharing key initialization failed", {
cause: error,
});
}
}
@@ -230,6 +275,23 @@ class SystemCrypto {
});
}
private async generateAndGuideEncryptionKey(): Promise<void> {
const newKey = crypto.randomBytes(32);
const newKeyHex = newKey.toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
this.encryptionKey = newKey;
await this.updateEnvFile("ENCRYPTION_KEY", newKeyHex);
databaseLogger.success("Encryption key auto-generated and saved to .env", {
operation: "encryption_key_auto_generated",
instanceId,
envVarName: "ENCRYPTION_KEY",
note: "Used to wrap session data keys - no restart required",
});
}
private async generateAndGuideInternalAuthToken(): Promise<void> {
const newToken = crypto.randomBytes(32).toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
+6 -10
View File
@@ -255,16 +255,12 @@ function parseMacVersion(userAgent: string): string {
* Ignores minor version numbers to handle browser auto-updates.
*/
export function generateDeviceFingerprint(deviceInfo: DeviceInfo): string {
let fingerprintString = "";
if (deviceInfo.type === "desktop") {
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`;
} else if (deviceInfo.type === "mobile") {
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`;
} else {
const browserMajor = deviceInfo.version.split(".")[0];
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser} ${browserMajor}|${deviceInfo.os}`;
}
const fingerprintString =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`
: `${deviceInfo.type}|${deviceInfo.browser} ${
deviceInfo.version.split(".")[0]
}|${deviceInfo.os}`;
return crypto.createHash("sha256").update(fingerprintString).digest("hex");
}
+13
View File
@@ -265,6 +265,19 @@ class UserCrypto {
return session.dataKey;
}
restoreUserDataKey(userId: string, dataKey: Buffer, expiresAt: number): void {
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
this.userSessions.set(userId, {
dataKey: Buffer.from(dataKey),
expiresAt,
lastActivity: Date.now(),
});
}
logoutUser(userId: string): void {
const session = this.userSessions.get(userId);
if (session) {
+1 -1
View File
@@ -482,7 +482,7 @@ class UserDataImport {
return await this.importUserData(targetUserId, exportData, options);
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error("Invalid JSON format in import data");
throw new Error("Invalid JSON format in import data", { cause: error });
}
throw error;
}
+677
View File
@@ -0,0 +1,677 @@
"use client";
import React, {
useState,
useCallback,
createContext,
useContext,
useRef,
useEffect,
} from "react";
import {
motion,
AnimatePresence,
easeInOut,
type Variants,
} from "motion/react";
import {
ChevronRight,
Folder,
FolderOpen,
File,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
const animationVariants: Variants = {
rootInitial: { opacity: 0, y: 20 },
rootAnimate: { opacity: 1, y: 0 },
itemInitial: { opacity: 0, x: -10 },
itemAnimate: { opacity: 1, x: 0 },
contentHidden: { opacity: 0, height: 0 },
contentVisible: { opacity: 1, height: "auto" },
chevronClosed: { rotate: 0 },
chevronOpen: { rotate: 90 },
};
const transitions = {
root: { duration: 0.4 },
item: { duration: 0.2 },
content: { duration: 0.3, ease: easeInOut },
chevron: { duration: 0.2 },
};
interface ExpansionContextType {
expandedIds: Set<string>;
toggleExpanded: (id: string) => void;
}
interface SelectionContextType {
selectedId: string | null;
setSelected: (id: string) => void;
onSelect?: (id: string, label: string) => void;
}
interface TreeContextType {
focusedId: string | null;
setFocusedId: (id: string | null) => void;
treeId: string;
setKeyboardMode: (mode: boolean) => void;
keyboardMode: boolean;
}
interface LevelContextType {
level: number;
}
const ExpansionContext = createContext<ExpansionContextType | null>(null);
const SelectionContext = createContext<SelectionContextType | null>(null);
const TreeContext = createContext<TreeContextType | null>(null);
const LevelContext = createContext<LevelContextType>({ level: 0 });
const useExpansion = () => {
const context = useContext(ExpansionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useSelection = () => {
const context = useContext(SelectionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useTree = () => {
const context = useContext(TreeContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useLevel = () => {
return useContext(LevelContext);
};
const getPaddingClass = (level: number): string => {
const paddingMap: Record<number, string> = {
0: "pl-3",
1: "pl-8",
2: "pl-12",
3: "pl-16",
4: "pl-20",
5: "pl-24",
6: "pl-28",
7: "pl-32",
};
return paddingMap[level] || `pl-[${Math.min(level * 4 + 12, 48)}px]`;
};
interface CustomBadge {
content: React.ReactNode;
className?: string;
ariaLabel?: string;
}
interface RootProps {
defaultExpanded?: string[];
defaultSelected?: string;
selectedId?: string | null;
expandedIds?: Set<string>;
onSelect?: (id: string, label: string) => void;
className?: string;
children: React.ReactNode;
id?: string;
}
interface ItemProps {
id: string;
label: string;
icon?: LucideIcon;
badge?: string | number;
modified?: boolean | CustomBadge;
untracked?: boolean | CustomBadge;
className?: string;
children?: React.ReactNode;
}
interface TriggerProps {
className?: string;
}
interface ContentProps {
children: React.ReactNode;
className?: string;
}
const Root: React.FC<RootProps> = ({
defaultExpanded = [],
defaultSelected,
selectedId: controlledSelectedId,
expandedIds: additionalExpandedIds,
onSelect,
className = "",
children,
id = "folder-tree",
}) => {
const [expandedIds, setExpandedIds] = useState<Set<string>>(
new Set(defaultExpanded),
);
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(
defaultSelected || null,
);
const selectedId =
controlledSelectedId !== undefined
? controlledSelectedId
: internalSelectedId;
const [focusedId, setFocusedId] = useState<string | null>(null);
const [keyboardMode, setKeyboardMode] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!additionalExpandedIds || additionalExpandedIds.size === 0) return;
setExpandedIds((prev) => {
const merged = new Set(prev);
let changed = false;
for (const id of additionalExpandedIds) {
if (!merged.has(id)) {
merged.add(id);
changed = true;
}
}
return changed ? merged : prev;
});
}, [additionalExpandedIds]);
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const setSelected = useCallback((id: string) => {
setInternalSelectedId(id);
}, []);
const getVisibleItemIds = useCallback(() => {
const items = Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
);
return items
.filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
})
.map((item) => item.getAttribute("data-id"))
.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(() => {
if (!treeHasFocus) {
setTreeHasFocus(true);
setKeyboardMode(true);
}
}, [treeHasFocus]);
const handleTreeBlur = useCallback((e: React.FocusEvent) => {
if (!treeRef.current?.contains(e.relatedTarget as Node)) {
setTreeHasFocus(false);
setFocusedId(null);
setKeyboardMode(false);
}
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const getVisibleItems = () => {
return Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
).filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
});
};
if (e.key === "Tab") {
if (treeHasFocus && !focusedId) {
const visibleItemIds = getVisibleItemIds();
if (visibleItemIds.length > 0) {
setFocusedId(visibleItemIds[0]);
e.preventDefault();
return;
}
}
if (focusedId) {
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
if (e.shiftKey) {
if (currentIndex === 0) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.max(0, currentIndex - 1);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
} else {
if (currentIndex === visibleItems.length - 1) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.min(
visibleItems.length - 1,
currentIndex + 1,
);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
}
}
return;
}
if (!keyboardMode || !focusedId) return;
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (currentIndex < visibleItems.length - 1) {
const nextItem = visibleItems[currentIndex + 1] as HTMLElement;
const nextId = nextItem.getAttribute("data-id");
if (nextId) setFocusedId(nextId);
}
break;
case "ArrowUp":
e.preventDefault();
if (currentIndex > 0) {
const prevItem = visibleItems[currentIndex - 1] as HTMLElement;
const prevId = prevItem.getAttribute("data-id");
if (prevId) setFocusedId(prevId);
}
break;
case "ArrowRight":
e.preventDefault();
if (!expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "ArrowLeft":
e.preventDefault();
if (expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "Enter":
case " ":
e.preventDefault();
setSelected(focusedId);
if (onSelect) {
const currentItem = visibleItems[currentIndex] as HTMLElement;
const label =
currentItem.querySelector("span:nth-of-type(2)")?.textContent ||
"";
onSelect(focusedId, label);
}
break;
}
},
[
focusedId,
keyboardMode,
expandedIds,
toggleExpanded,
setSelected,
onSelect,
getVisibleItemIds,
treeHasFocus,
],
);
useEffect(() => {
const handleMouseDown = () => setKeyboardMode(false);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, []);
const expansionValue: ExpansionContextType = {
expandedIds,
toggleExpanded,
};
const selectionValue: SelectionContextType = {
selectedId,
setSelected,
onSelect,
};
const treeValue: TreeContextType = {
focusedId,
setFocusedId,
treeId: id,
setKeyboardMode,
keyboardMode,
};
return (
<ExpansionContext.Provider value={expansionValue}>
<SelectionContext.Provider value={selectionValue}>
<TreeContext.Provider value={treeValue}>
<LevelContext.Provider value={{ level: 0 }}>
<motion.div
ref={treeRef}
variants={animationVariants}
initial="rootInitial"
animate="rootAnimate"
transition={transitions.root}
className={cn(
"bg-canvas border border-edge rounded-lg overflow-hidden",
className,
)}
role="tree"
aria-labelledby={`${id}-label`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={handleTreeFocus}
onBlur={handleTreeBlur}
>
<div className="w-full overflow-y-auto bg-canvas text-sm">
{children}
</div>
</motion.div>
</LevelContext.Provider>
</TreeContext.Provider>
</SelectionContext.Provider>
</ExpansionContext.Provider>
);
};
const ItemContext = createContext<{
itemId: string;
hasChildren: boolean;
isExpanded: boolean;
toggleExpanded: () => void;
} | null>(null);
const Item: React.FC<ItemProps> = ({
id,
label,
icon,
badge,
modified,
untracked,
className = "",
children,
}) => {
const expansionContext = useExpansion();
const selectionContext = useSelection();
const treeContext = useTree();
const { level } = useLevel();
const itemRef = useRef<HTMLDivElement>(null);
const keyboardMode = treeContext.keyboardMode;
const hasChildren = React.Children.count(children) > 0;
const isExpanded = expansionContext.expandedIds.has(id);
const isSelected = selectionContext.selectedId === id;
const isFocused = treeContext.focusedId === id;
const handleItemClick = useCallback(() => {
treeContext.setKeyboardMode(false);
selectionContext.setSelected(id);
treeContext.setFocusedId(id);
if (selectionContext.onSelect) {
selectionContext.onSelect(id, label);
}
}, [id, label, selectionContext, treeContext]);
const toggleExpanded = useCallback(() => {
if (hasChildren) {
expansionContext.toggleExpanded(id);
}
}, [id, hasChildren, expansionContext]);
const handleFocus = useCallback(() => {
treeContext.setFocusedId(id);
}, [id, treeContext]);
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.focus();
}
}, [isFocused]);
const IconComponent =
icon || (hasChildren ? (isExpanded ? FolderOpen : Folder) : File);
const itemContextValue = {
itemId: id,
hasChildren,
isExpanded,
toggleExpanded,
};
const renderBadge = (
badgeData: boolean | CustomBadge | undefined,
defaultContent: string,
defaultClassName: string,
) => {
if (!badgeData) return null;
if (typeof badgeData === "boolean") {
return (
<span
className={defaultClassName}
aria-label={`${defaultContent} status`}
>
{defaultContent}
</span>
);
}
return (
<span
className={cn(
"ml-auto text-xs px-2 py-0.5 rounded-full",
badgeData.className,
)}
aria-label={badgeData.ariaLabel || `Custom badge: ${badgeData.content}`}
>
{badgeData.content}
</span>
);
};
return (
<ItemContext.Provider value={itemContextValue}>
<LevelContext.Provider value={{ level: level + 1 }}>
<div>
<motion.div
ref={itemRef}
variants={animationVariants}
initial="itemInitial"
animate="itemAnimate"
transition={{ ...transitions.item, delay: level * 0.05 }}
data-selected={isSelected ? "true" : "false"}
data-id={id}
className={cn(
"flex items-center gap-2 py-1.5 text-sm transition-colors cursor-pointer select-none",
getPaddingClass(level),
className,
isSelected
? "bg-accent text-accent-foreground border-r-2 border-ring"
: "",
!isSelected && "hover:bg-hover",
keyboardMode && isFocused
? "focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-inset"
: "focus:outline-hidden",
)}
onClick={(e: React.MouseEvent) => {
handleItemClick();
e.stopPropagation();
toggleExpanded();
}}
onFocus={handleFocus}
role="treeitem"
tabIndex={isFocused ? 0 : -1}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
aria-label={`${hasChildren ? "Folder" : "File"}: ${label}`}
aria-level={level + 1}
>
{hasChildren && (
<motion.span
className="shrink-0 cursor-pointer"
variants={animationVariants}
animate={isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
aria-hidden="true"
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
)}
{!hasChildren && <span className="w-3 mr-2" aria-hidden="true" />}
{IconComponent && (
<IconComponent
size={16}
data-selected={isSelected ? "true" : "false"}
data-child={hasChildren ? "true" : "false"}
className={cn(
"mr-1 shrink-0 text-muted-foreground data-[child=true]:text-primary data-[selected=true]:text-accent-foreground",
)}
aria-hidden="true"
/>
)}
<span className="flex-1">{label}</span>
{badge && (
<span
className="ml-auto text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full"
aria-label={`Badge: ${badge}`}
>
{badge}
</span>
)}
{renderBadge(
modified,
"M",
"ml-auto text-xs bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded-full",
)}
{renderBadge(
untracked,
"U",
"ml-auto text-xs bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-0.5 rounded-full",
)}
</motion.div>
{children}
</div>
</LevelContext.Provider>
</ItemContext.Provider>
);
};
const Trigger: React.FC<TriggerProps> = ({ className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext || !itemContext.hasChildren) {
return null;
}
return (
<motion.span
className={cn("mr-2 shrink-0 cursor-pointer", className)}
variants={animationVariants}
animate={itemContext.isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
itemContext.toggleExpanded();
}}
role="button"
aria-label={itemContext.isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
);
};
const Content: React.FC<ContentProps> = ({ children, className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext) {
return <>{children}</>;
}
const hasContent = React.Children.count(children) > 0;
return (
<AnimatePresence>
{hasContent && itemContext.isExpanded && (
<motion.div
variants={animationVariants}
initial="contentHidden"
animate="contentVisible"
exit="contentHidden"
transition={transitions.content}
style={{ overflow: "hidden" }}
className={className}
role="group"
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
const FolderTree = {
Root,
Item,
Trigger,
Content,
};
export default FolderTree;
+55 -24
View File
@@ -1,28 +1,59 @@
import { type ComponentProps, type ReactNode } from "react";
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className,
)}
{...props}
/>
);
}
export type KbdProps = ComponentProps<"span"> & {
children: ReactNode;
};
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
export const Kbd = ({ className, children, ...props }: KbdProps) => (
<span
className={cn(
"inline-flex select-none items-center rounded-md border px-2 py-1 text-[10px] font-mono font-medium relative",
"bg-linear-to-b from-gray-100 to-gray-200 border-gray-300 shadow-[0_2px_0_#ccc,0_3px_2px_rgba(0,0,0,0.25)]",
"dark:from-zinc-800 dark:to-zinc-900 dark:border-zinc-700 dark:shadow-[0_2px_0_#222,0_3px_2px_rgba(0,0,0,0.4)]",
"dark:text-zinc-200",
className,
)}
{...props}
>
{children}
</span>
);
export { Kbd, KbdGroup };
export type KbdKeyProps = ComponentProps<"span"> & {
"aria-label"?: string;
className?: string;
};
export const KbdKey = ({ className, children, ...props }: KbdKeyProps) => (
<span
className={cn(
"px-1 py-px rounded-sm select-none text-[10px] font-mono font-medium bg-transparent",
className,
)}
{...props}
>
{children}
</span>
);
export type KbdSeparatorProps = ComponentProps<"span"> & {
children?: ReactNode;
className?: string;
};
export const KbdSeparator = ({
className,
children = "+",
...props
}: KbdSeparatorProps) => (
<span
className={cn(
"text-muted-foreground/70 text-[10px] mx-0.5 select-none pointer-events-none",
className,
)}
{...props}
>
{children}
</span>
);
+12 -8
View File
@@ -1,15 +1,19 @@
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import {
Group as ResizableGroup,
Panel as ResizablePrimitivePanel,
Separator as ResizableSeparator,
} from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
}: React.ComponentProps<typeof ResizableGroup>) {
return (
<ResizablePrimitive.PanelGroup
<ResizableGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
@@ -22,19 +26,19 @@ function ResizablePanelGroup({
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}: React.ComponentProps<typeof ResizablePrimitivePanel>) {
return <ResizablePrimitivePanel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: React.ComponentProps<typeof ResizableSeparator> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
<ResizableSeparator
data-slot="resizable-handle"
className={cn(
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-edge-hover hover:bg-interact active:bg-pressed transition-colors duration-150",
@@ -47,7 +51,7 @@ function ResizableHandle({
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</ResizableSeparator>
);
}
+2 -2
View File
@@ -598,9 +598,9 @@ function SidebarMenuSkeleton({
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
});
return (
<div
+17 -2
View File
@@ -1,13 +1,13 @@
import React from "react";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { Button } from "@/components/ui/button.tsx";
import { ExternalLink, Download, AlertTriangle } from "lucide-react";
import { ExternalLink, Download, AlertTriangle, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
interface VersionAlertProps {
updateInfo: {
success: boolean;
status?: "up_to_date" | "requires_update";
status?: "up_to_date" | "requires_update" | "beta";
localVersion?: string;
remoteVersion?: string;
latest_release?: {
@@ -53,6 +53,21 @@ export function VersionAlert({ updateInfo, onDownload }: VersionAlertProps) {
);
}
if (updateInfo.status === "beta") {
return (
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>{t("versionCheck.betaVersion")}</AlertTitle>
<AlertDescription>
{t("versionCheck.betaVersionDesc", {
current: updateInfo.localVersion,
latest: updateInfo.remoteVersion,
})}
</AlertDescription>
</Alert>
);
}
if (updateInfo.status === "requires_update") {
return (
<Alert variant="destructive">
+1 -1
View File
@@ -144,7 +144,7 @@ export function useConfirmation() {
setPendingConfirmCallback(null);
setPendingResolve(null);
},
} as any);
} as NonNullable<Parameters<typeof toast>[1]>);
if (confirmOnEnter) {
setActiveToastId(toastId);
+4 -4
View File
@@ -3,8 +3,9 @@ import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
const [isMobile, setIsMobile] = React.useState<boolean>(
() =>
typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT,
);
React.useEffect(() => {
@@ -13,9 +14,8 @@ export function useIsMobile() {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
return isMobile;
}
+29 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from "react";
import { isElectron } from "@/ui/main-axios";
import { isElectron } from "@/lib/electron";
import { getBasePath } from "@/lib/base-path";
interface ServiceWorkerState {
@@ -40,27 +40,54 @@ export function useServiceWorker(): ServiceWorkerState {
if (!isSupported) return;
const shouldReloadOnControllerChange = Boolean(
navigator.serviceWorker.controller,
);
let hasReloadedForUpdate = false;
const handleControllerChange = () => {
if (!shouldReloadOnControllerChange || hasReloadedForUpdate) {
return;
}
hasReloadedForUpdate = true;
window.location.reload();
};
const registerSW = async () => {
try {
const registration = await navigator.serviceWorker.register(
`${getBasePath()}/sw.js`,
{ updateViaCache: "none" },
);
setState((prev) => ({ ...prev, isRegistered: true }));
registration.addEventListener("updatefound", () =>
handleUpdateFound(registration),
);
await registration.update();
} catch (error) {
console.error("[SW] Registration failed:", error);
}
};
navigator.serviceWorker.addEventListener(
"controllerchange",
handleControllerChange,
);
if (document.readyState === "complete") {
registerSW();
} else {
window.addEventListener("load", registerSW);
return () => window.removeEventListener("load", registerSW);
}
return () => {
window.removeEventListener("load", registerSW);
navigator.serviceWorker.removeEventListener(
"controllerchange",
handleControllerChange,
);
};
}, [handleUpdateFound]);
return state;
+71 -174
View File
@@ -1,84 +1,82 @@
import i18n from "i18next";
import i18n, { type BackendModule, type ResourceKey } from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from "../locales/en.json";
import afTranslation from "../locales/translated/af_ZA.json";
import arTranslation from "../locales/translated/ar_SA.json";
import bnTranslation from "../locales/translated/bn_BD.json";
import bgTranslation from "../locales/translated/bg_BG.json";
import caTranslation from "../locales/translated/ca_ES.json";
import csTranslation from "../locales/translated/cs_CZ.json";
import daTranslation from "../locales/translated/da_DK.json";
import deTranslation from "../locales/translated/de_DE.json";
import elTranslation from "../locales/translated/el_GR.json";
import esESTranslation from "../locales/translated/es_ES.json";
import fiTranslation from "../locales/translated/fi_FI.json";
import frTranslation from "../locales/translated/fr_FR.json";
import heTranslation from "../locales/translated/he_IL.json";
import hiTranslation from "../locales/translated/hi_IN.json";
import huTranslation from "../locales/translated/hu_HU.json";
import idTranslation from "../locales/translated/id_ID.json";
import itTranslation from "../locales/translated/it_IT.json";
import jaTranslation from "../locales/translated/ja_JP.json";
import koTranslation from "../locales/translated/ko_KR.json";
import nlTranslation from "../locales/translated/nl_NL.json";
import noTranslation from "../locales/translated/no_NO.json";
import plTranslation from "../locales/translated/pl_PL.json";
import ptPTTranslation from "../locales/translated/pt_PT.json";
import ptBRTranslation from "../locales/translated/pt_BR.json";
import roTranslation from "../locales/translated/ro_RO.json";
import ruTranslation from "../locales/translated/ru_RU.json";
import srTranslation from "../locales/translated/sr_SP.json";
import svSETranslation from "../locales/translated/sv_SE.json";
import thTranslation from "../locales/translated/th_TH.json";
import trTranslation from "../locales/translated/tr_TR.json";
import ukTranslation from "../locales/translated/uk_UA.json";
import viTranslation from "../locales/translated/vi_VN.json";
import zhCNTranslation from "../locales/translated/zh_CN.json";
import zhTWTranslation from "../locales/translated/zh_TW.json";
type LocaleModule = { default: ResourceKey };
const localeLoaders = {
af: () => import("../locales/translated/af_ZA.json"),
ar: () => import("../locales/translated/ar_SA.json"),
bn: () => import("../locales/translated/bn_BD.json"),
bg: () => import("../locales/translated/bg_BG.json"),
ca: () => import("../locales/translated/ca_ES.json"),
cs: () => import("../locales/translated/cs_CZ.json"),
da: () => import("../locales/translated/da_DK.json"),
de: () => import("../locales/translated/de_DE.json"),
el: () => import("../locales/translated/el_GR.json"),
"es-ES": () => import("../locales/translated/es_ES.json"),
fi: () => import("../locales/translated/fi_FI.json"),
fr: () => import("../locales/translated/fr_FR.json"),
he: () => import("../locales/translated/he_IL.json"),
hi: () => import("../locales/translated/hi_IN.json"),
hu: () => import("../locales/translated/hu_HU.json"),
id: () => import("../locales/translated/id_ID.json"),
it: () => import("../locales/translated/it_IT.json"),
ja: () => import("../locales/translated/ja_JP.json"),
ko: () => import("../locales/translated/ko_KR.json"),
nl: () => import("../locales/translated/nl_NL.json"),
no: () => import("../locales/translated/no_NO.json"),
pl: () => import("../locales/translated/pl_PL.json"),
"pt-PT": () => import("../locales/translated/pt_PT.json"),
"pt-BR": () => import("../locales/translated/pt_BR.json"),
ro: () => import("../locales/translated/ro_RO.json"),
ru: () => import("../locales/translated/ru_RU.json"),
sr: () => import("../locales/translated/sr_SP.json"),
"sv-SE": () => import("../locales/translated/sv_SE.json"),
th: () => import("../locales/translated/th_TH.json"),
tr: () => import("../locales/translated/tr_TR.json"),
uk: () => import("../locales/translated/uk_UA.json"),
vi: () => import("../locales/translated/vi_VN.json"),
"zh-CN": () => import("../locales/translated/zh_CN.json"),
"zh-TW": () => import("../locales/translated/zh_TW.json"),
} satisfies Record<string, () => Promise<LocaleModule>>;
const supportedLngs = ["en", ...Object.keys(localeLoaders)];
const localeBackend: BackendModule = {
type: "backend",
init: () => {},
read: (language, _namespace, callback) => {
if (language === "en") {
callback(null, enTranslation);
return;
}
const loadLocale = localeLoaders[language];
if (!loadLocale) {
callback(new Error(`Unsupported language: ${language}`), false);
return;
}
loadLocale()
.then((module) => callback(null, module.default))
.catch((error: unknown) => {
callback(
error instanceof Error ? error : new Error(String(error)),
false,
);
});
},
};
i18n
.use(localeBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
supportedLngs: [
"en",
"af",
"ar",
"bn",
"bg",
"ca",
"cs",
"da",
"de",
"el",
"es-ES",
"fi",
"fr",
"he",
"hi",
"hu",
"id",
"it",
"ja",
"ko",
"nl",
"no",
"pl",
"pt-PT",
"pt-BR",
"ro",
"ru",
"sr",
"sv-SE",
"th",
"tr",
"uk",
"vi",
"zh-CN",
"zh-TW",
],
supportedLngs,
fallbackLng: "en",
debug: false,
@@ -94,109 +92,8 @@ i18n
en: {
translation: enTranslation,
},
af: {
translation: afTranslation,
},
ar: {
translation: arTranslation,
},
bn: {
translation: bnTranslation,
},
bg: {
translation: bgTranslation,
},
ca: {
translation: caTranslation,
},
cs: {
translation: csTranslation,
},
da: {
translation: daTranslation,
},
de: {
translation: deTranslation,
},
el: {
translation: elTranslation,
},
"es-ES": {
translation: esESTranslation,
},
fi: {
translation: fiTranslation,
},
fr: {
translation: frTranslation,
},
he: {
translation: heTranslation,
},
hi: {
translation: hiTranslation,
},
hu: {
translation: huTranslation,
},
id: {
translation: idTranslation,
},
it: {
translation: itTranslation,
},
ja: {
translation: jaTranslation,
},
ko: {
translation: koTranslation,
},
nl: {
translation: nlTranslation,
},
no: {
translation: noTranslation,
},
pl: {
translation: plTranslation,
},
"pt-PT": {
translation: ptPTTranslation,
},
"pt-BR": {
translation: ptBRTranslation,
},
ro: {
translation: roTranslation,
},
ru: {
translation: ruTranslation,
},
sr: {
translation: srTranslation,
},
"sv-SE": {
translation: svSETranslation,
},
th: {
translation: thTranslation,
},
tr: {
translation: trTranslation,
},
uk: {
translation: ukTranslation,
},
vi: {
translation: viTranslation,
},
"zh-CN": {
translation: zhCNTranslation,
},
"zh-TW": {
translation: zhTWTranslation,
},
},
partialBundledLanguages: true,
interpolation: {
escapeValue: false,
+4
View File
@@ -553,3 +553,7 @@
.skinny-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
.h-fade {
mask-image: linear-gradient(transparent, #000 6%, #000 94%, transparent);
}
+45
View File
@@ -0,0 +1,45 @@
const CLIENT_CACHE_VERSION_KEY = "termix_client_cache_version";
const CURRENT_CLIENT_VERSION = import.meta.env.VITE_APP_VERSION || "0.0.0";
async function clearCacheStorage(): Promise<void> {
if (!("caches" in window)) return;
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
}
async function clearServiceWorkers(): Promise<void> {
if (!("serviceWorker" in navigator)) return;
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(
registrations.map((registration) => registration.unregister()),
);
}
function storeCurrentVersion(): void {
try {
localStorage.setItem(CLIENT_CACHE_VERSION_KEY, CURRENT_CLIENT_VERSION);
} catch {
// expected - storage can be unavailable in restricted contexts
}
}
export async function prepareClientCacheVersion(): Promise<void> {
if (typeof window === "undefined") return;
let storedVersion: string | null = null;
try {
storedVersion = localStorage.getItem(CLIENT_CACHE_VERSION_KEY);
} catch {
storedVersion = null;
}
if (storedVersion === CURRENT_CLIENT_VERSION) {
return;
}
await Promise.allSettled([clearCacheStorage(), clearServiceWorkers()]);
storeCurrentVersion();
}
+13 -5
View File
@@ -12,6 +12,12 @@ export class RobustClipboardProvider implements IClipboardProvider {
if (this.pendingWrite !== null) {
const text = this.pendingWrite;
this.pendingWrite = null;
if (window.electronClipboard) {
window.electronClipboard.writeText(text).catch(() => {
this.pendingWrite = text;
});
return;
}
navigator.clipboard.writeText(text).catch(() => {
this.pendingWrite = text;
});
@@ -25,18 +31,20 @@ export class RobustClipboardProvider implements IClipboardProvider {
this.pendingWrite = null;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
readText(selection: ClipboardSelectionType): string {
return "";
readText(_selection: ClipboardSelectionType): string | Promise<string> {
if (window.electronClipboard) {
return window.electronClipboard.readText();
}
return navigator.clipboard?.readText?.() ?? "";
}
async writeText(
selection: ClipboardSelectionType,
_selection: ClipboardSelectionType,
text: string,
): Promise<void> {
try {
if (window.electronClipboard) {
window.electronClipboard.writeText(text);
await window.electronClipboard.writeText(text);
return;
}
await navigator.clipboard.writeText(text);
+19 -6
View File
@@ -14,7 +14,18 @@
* to reflect the current UX contract: users can keep working regardless
* of backend hiccups and are simply informed via a toast.
*/
type EventListener = (...args: any[]) => void;
type EventListener = (...args: unknown[]) => void;
interface HttpLikeError {
message?: string;
code?: string;
response?: {
data?: {
error?: string;
code?: string;
};
};
}
class DatabaseHealthMonitor {
private static instance: DatabaseHealthMonitor;
@@ -47,7 +58,7 @@ class DatabaseHealthMonitor {
}
}
private emit(event: string, ...args: any[]): void {
private emit(event: string, ...args: unknown[]): void {
const eventListeners = this.listeners.get(event);
if (eventListeners) {
eventListeners.forEach((listener) => listener(...args));
@@ -58,9 +69,11 @@ class DatabaseHealthMonitor {
this.emit("session-expired", { timestamp: Date.now() });
}
reportDatabaseError(error: any, _wasAuthenticated: boolean = false) {
const errorMessage = error?.response?.data?.error || error?.message || "";
const errorCode = error?.response?.data?.code || error?.code;
reportDatabaseError(error: unknown) {
const errorLike = error as HttpLikeError;
const errorMessage =
errorLike.response?.data?.error || errorLike.message || "";
const errorCode = errorLike.response?.data?.code || errorLike.code;
const lowerMessage = errorMessage.toLowerCase();
const isDatabaseError =
@@ -78,7 +91,7 @@ class DatabaseHealthMonitor {
errorCode === "ETIMEDOUT" ||
errorCode === "ERR_CANCELED" ||
(lowerMessage.includes("network error") &&
error?.response === undefined) ||
errorLike.response === undefined) ||
lowerMessage.includes("request aborted") ||
lowerMessage.includes("timeout");
+18
View File
@@ -0,0 +1,18 @@
type ElectronWindow = Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: {
isElectron?: boolean;
};
};
export function isElectron(): boolean {
if (typeof window === "undefined") return false;
const win = window as ElectronWindow;
const hasISElectron = win.IS_ELECTRON === true;
const hasElectronAPI = !!win.electronAPI;
const isElectronProp = win.electronAPI?.isElectron === true;
return hasISElectron || hasElectronAPI || isElectronProp;
}
+147 -29
View File
@@ -402,6 +402,8 @@
"currentVersion": "You are running version {{version}}",
"updateAvailable": "Update Available",
"newVersionAvailable": "A new version is available! You are running {{current}}, but {{latest}} is available.",
"betaVersion": "Beta Version",
"betaVersionDesc": "You are running {{current}}, which is newer than the latest stable release {{latest}}.",
"releasedOn": "Released on {{date}}",
"downloadUpdate": "Download Update",
"dismiss": "Dismiss",
@@ -426,6 +428,8 @@
"warning": "Warning",
"info": "Info",
"success": "Success",
"unsavedChanges": "Unsaved changes",
"dismiss": "Dismiss",
"loading": "Loading...",
"required": "Required",
"optional": "Optional",
@@ -471,6 +475,7 @@
"chinese": "Chinese",
"german": "German",
"cancel": "Cancel",
"done": "Done",
"username": "Username",
"name": "Name",
"login": "Login",
@@ -486,6 +491,7 @@
"save": "Save",
"saving": "Saving...",
"delete": "Delete",
"rename": "Rename",
"edit": "Edit",
"add": "Add",
"search": "Search",
@@ -559,7 +565,8 @@
"passwordCopied": "Password copied to clipboard",
"sudoPasswordCopied": "Sudo password copied to clipboard",
"noPasswordAvailable": "No password available",
"failedToCopyPassword": "Failed to copy password"
"failedToCopyPassword": "Failed to copy password",
"openFileManager": "Open File Manager"
},
"admin": {
"title": "Admin Settings",
@@ -876,7 +883,42 @@
"passwordMinLength": "Password must be at least 6 characters",
"currentRoles": "Current Roles",
"noRolesAssigned": "No roles assigned",
"assignNewRole": "Assign New Role"
"assignNewRole": "Assign New Role",
"apiKeys": {
"tabLabel": "API Keys",
"title": "API Keys",
"createApiKey": "Create API Key",
"createApiKeyDescription": "Create a new API key scoped to a specific user. The token is shown only once.",
"keyCreated": "API Key Created",
"keyCreatedDescription": "Copy this key now — it will not be shown again.",
"keyName": "Key Name",
"keyNamePlaceholder": "e.g. CI/CD Pipeline",
"scopedUser": "Scoped User",
"selectUser": "Select a user...",
"searchUsers": "Search users...",
"noUsersFound": "No users found.",
"expiresAt": "Expires At",
"optional": "optional",
"expiresAtHelp": "Leave empty for a key that never expires.",
"copyWarningTitle": "Save your API key",
"copyWarningDescription": "This key will only be shown once. Store it in a safe place.",
"apiKey": "API Key",
"tokenCopied": "Token copied to clipboard",
"creating": "Creating...",
"nameRequired": "Key name is required",
"userRequired": "Please select a user",
"failedToCreate": "Failed to create API key",
"noKeys": "No API keys found.",
"name": "Name",
"prefix": "Prefix",
"lastUsed": "Last Used",
"never": "Never",
"revokeKey": "Revoke key",
"confirmRevoke": "Are you sure you want to revoke the API key \"{{name}}\"? This cannot be undone.",
"revokedSuccessfully": "API key revoked successfully",
"failedToRevoke": "Failed to revoke API key",
"failedToFetch": "Failed to fetch API keys"
}
},
"hosts": {
"title": "Host Manager",
@@ -970,28 +1012,8 @@
"enableDocker": "Enable Docker",
"defaultPath": "Default Path",
"defaultPathDesc": "Default directory when opening file manager for this host",
"tunnelConnections": "Tunnel Connections",
"connection": "Connection",
"remove": "Remove",
"sourcePort": "Source Port",
"sourcePortDesc": " (Source refers to the Current Connection Details in the General tab)",
"endpointPort": "Endpoint Port",
"endpointSshConfig": "Endpoint SSH Configuration",
"tunnelForwardDescription": "This tunnel will forward traffic from port {{sourcePort}} on the source machine (current connection details in general tab) to port {{endpointPort}} on the endpoint machine.",
"maxRetries": "Max Retries",
"maxRetriesDescription": "Maximum number of retry attempts for tunnel connection.",
"retryInterval": "Retry Interval (seconds)",
"retryIntervalDescription": "Time to wait between retry attempts.",
"autoStartContainer": "Auto Start on Container Launch",
"autoStartDesc": "Automatically start this tunnel when the container launches",
"addConnection": "Add Tunnel Connection",
"tunnelType": "Tunnel Type",
"tunnelTypeLocal": "Local (-L)",
"tunnelTypeRemote": "Remote (-R)",
"tunnelTypeLocalDesc": "Forward local port to remote endpoint",
"tunnelTypeRemoteDesc": "Forward remote port to local machine",
"tunnelForwardDescriptionLocal": "This tunnel will forward traffic from local port {{sourcePort}} to port {{endpointPort}} on the endpoint machine.",
"tunnelForwardDescriptionRemote": "This tunnel will forward traffic from port {{sourcePort}} on the source machine (current connection details in general tab) to port {{endpointPort}} on the endpoint machine.",
"sshpassRequired": "Sshpass Required For Password Authentication",
"sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.",
"otherInstallMethods": "Other installation methods:",
@@ -1656,6 +1678,7 @@
"automaticFallback": "Automatically trying {{method}} authentication...",
"totpTimeout": "TOTP verification timeout. Please reconnect.",
"passwordTimeout": "Password verification timeout. Please reconnect.",
"sessionEnded": "Session ended.",
"connectionRejected": "Connection rejected by server. Please check your authentication and network configuration.",
"hostKeyRejected": "SSH host key verification rejected. Connection cancelled.",
"sessionTakenOver": "Session was opened in another tab. Reconnecting...",
@@ -1993,12 +2016,10 @@
"ascending": "Ascending",
"descending": "Descending"
},
"tunnel": {
"noTunnelsConfigured": "No Tunnels Configured",
"configureTunnelsInHostSettings": "Configure tunnel connections in the Host Manager to get started"
},
"tunnels": {
"title": "SSH Tunnels",
"noTunnelsConfigured": "No Tunnels Configured",
"configureTunnelsInHostSettings": "Configure tunnel connections in the Host Manager to get started",
"noSshTunnels": "No SSH Tunnels",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"connected": "Connected",
@@ -2019,27 +2040,100 @@
"disconnect": "Disconnect",
"cancel": "Cancel",
"port": "Port",
"localPort": "Local Port",
"remotePort": "Remote Port",
"currentHostPort": "Current Host Port",
"endpointPort": "Endpoint Port",
"bindIp": "Local IP",
"currentHostIp": "Current Host IP",
"endpointSshConfig": "Endpoint SSH Configuration",
"endpointSshConfigRequired": "Endpoint SSH configuration is required",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"attempt": "Attempt {{current}} of {{max}}",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"checkDockerLogs": "Check your Docker logs for the error reason, join the",
"orCreate": "or create a ",
"noTunnelConnections": "No tunnel connections configured",
"tunnelConnections": "Tunnel Connections",
"serverTunnels": "Server Tunnels",
"serverTunnelsDesc": "Backend-managed tunnels stored with this host.",
"clientTunnels": "Client Tunnels",
"clientTunnelsUnavailable": "Client tunnels require a desktop client.",
"serverTunnel": "Server Tunnel",
"clientTunnel": "Client Tunnel",
"addServerTunnel": "Add Server Tunnel",
"addClientTunnel": "Add Client Tunnel",
"noServerTunnels": "No server tunnels configured.",
"noClientTunnels": "No client tunnels configured on this desktop.",
"manageClientTunnels": "Manage Client Tunnels",
"addTunnel": "Add Tunnel",
"editTunnel": "Edit Tunnel",
"deleteTunnel": "Delete Tunnel",
"tunnelName": "Tunnel Name",
"localPort": "Local Port",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"autoStart": "Auto Start",
"autoStartContainer": "Auto Start on Launch",
"autoStartContainerDesc": "Automatically start this tunnel when your Termix server launches.",
"autoStartEnableFailed": "Host saved, but failed to start auto-start tunnels for {{name}}.",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"tunnelTestSucceeded": "Tunnel test succeeded",
"tunnelTestFailed": "Tunnel test failed",
"localSaved": "Client tunnels saved",
"localSaveError": "Failed to save local client tunnels",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidCurrentHostIp": "Current Host IP must be a valid IPv4 address.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"manualControlError": "Failed to update tunnel state.",
"saveHostBeforeManualControl": "Save this host before starting or stopping its server tunnels.",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"start": "Start",
"stop": "Stop",
"test": "Test",
"restart": "Restart",
"connectionType": "Connection Type",
"type": "Tunnel Type",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"typeDynamic": "Dynamic (-D)",
"typeServerLocalDesc": "Current host to endpoint.",
"typeServerRemoteDesc": "Endpoint back to current host.",
"typeClientLocalDesc": "Local computer to endpoint.",
"typeClientRemoteDesc": "Endpoint back to local computer.",
"typeClientDynamicDesc": "SOCKS on local computer.",
"typeDynamicDesc": "Forward SOCKS5 CONNECT traffic through SSH",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{endpoint}}:{{remotePort}}",
"summaryClientRemote": "{{endpoint}}:{{remotePort}} → {{localHost}}:{{localPort}}",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"route": "Route:",
"lastStarted": "Last started",
"lastTested": "Last tested",
"lastError": "Last error",
"maxRetries": "Max Retries",
"maxRetriesDescription": "Maximum amount of retry attempts.",
"retryInterval": "Retry Interval (seconds)",
"retryIntervalDescription": "Time to wait between retry attempts.",
"local": "Local",
"remote": "Remote",
"dynamic": "Dynamic",
@@ -2223,6 +2317,8 @@
"sshProvideCredentialsDescription": "Please provide your SSH credentials to connect to this server.",
"sshPasswordDescription": "Enter the password for this SSH connection.",
"sshKeyPasswordDescription": "If your SSH key is encrypted, enter the passphrase here.",
"passphraseRequired": "Passphrase Required",
"passphraseRequiredDescription": "The SSH key is encrypted. Please enter the passphrase to unlock it.",
"step1ScanQR": "Step 1: Scan the QR code with your authenticator app",
"manualEntryCode": "Manual Entry Code",
"cannotScanQRText": "If you can't scan the QR code, enter this code manually in your authenticator app",
@@ -2297,7 +2393,7 @@
"failedCompleteReset": "Failed to complete password reset",
"invalidTotpCode": "Invalid TOTP code",
"failedOidcLogin": "Failed to start OIDC login",
"failedUserInfo": "Failed to get user info after OIDC login",
"failedUserInfo": "Failed to get user info after login",
"oidcAuthFailed": "OIDC authentication failed",
"noTokenReceived": "No token received from login",
"invalidAuthUrl": "Invalid authorization URL received from backend",
@@ -2373,6 +2469,23 @@
"showHostTagsDesc": "Display tags under each host in the sidebar. Disable to hide all tags.",
"account": "Account",
"appearance": "Appearance",
"c2sTunnelConfigDesc": "Local desktop tunnels targeting configured SSH hosts.",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsDesc": "Save this desktop client's local tunnel list as a named server preset, or load a preset back into this client.",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetToLoad": "Preset To Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sLoadPreset": "Load",
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"languageLocalization": "Language & Localization",
"fileManagerSettings": "File Manager",
"terminalSettings": "Terminal",
@@ -2421,6 +2534,10 @@
"description": "SSH credential description",
"searchCredentials": "Search credentials by name, username, or tags...",
"sshConfig": "endpoint ssh configuration",
"bindLocalhost": "127.0.0.1 (bind to localhost)",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"socksListenerHost": "127.0.0.1 (SOCKS listener)",
"homePath": "/home",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
@@ -2600,6 +2717,7 @@
"version": "Version",
"upToDate": "Up to Date",
"updateAvailable": "Update Available",
"beta": "Beta",
"uptime": "Uptime",
"database": "Database",
"healthy": "Healthy",
+1 -1
View File
@@ -2246,7 +2246,7 @@
"failedCompleteReset": "Échec de la réinitialisation du mot de passe",
"invalidTotpCode": "Invalid TOTP code",
"failedOidcLogin": "Impossible de démarrer la connexion OIDC",
"failedUserInfo": "Impossible d'obtenir les informations de l'utilisateur après la connexion OIDC",
"failedUserInfo": "Impossible d'obtenir les informations de l'utilisateur après la connexion",
"oidcAuthFailed": "Échec de l'authentification OIDC",
"noTokenReceived": "Aucun jeton reçu de la connexion",
"invalidAuthUrl": "URL d'autorisation invalide reçue du backend",
+56 -25
View File
@@ -1,20 +1,44 @@
/* eslint-disable react-refresh/only-export-components */
import { StrictMode, useEffect, useState, useRef } from "react";
import { prepareClientCacheVersion } from "@/lib/client-cache-version";
import { StrictMode, Suspense, lazy, useEffect, useState, useRef } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import DesktopApp from "@/ui/desktop/DesktopApp.tsx";
import { MobileApp } from "@/ui/mobile/MobileApp.tsx";
import { ThemeProvider } from "@/components/theme-provider";
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
import "./i18n/i18n";
import { isElectron } from "./ui/main-axios.ts";
import HostManagerApp from "./ui/desktop/apps/host-manager/HostManagerApp.tsx";
import TerminalApp from "./ui/desktop/apps/features/terminal/TerminalApp.tsx";
import FileManagerApp from "./ui/desktop/apps/features/file-manager/FileManagerApp.tsx";
import TunnelApp from "./ui/desktop/apps/features/tunnel/TunnelApp.tsx";
import ServerStatsApp from "./ui/desktop/apps/features/server-stats/ServerStatsApp.tsx";
import DockerApp from "./ui/desktop/apps/features/docker/DockerApp.tsx";
import GuacamoleApp from "@/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx";
import { isElectron } from "@/lib/electron";
const DesktopApp = lazy(() => import("@/ui/desktop/DesktopApp.tsx"));
const MobileApp = lazy(() =>
import("@/ui/mobile/MobileApp.tsx").then((module) => ({
default: module.MobileApp,
})),
);
const HostManagerApp = lazy(
() => import("./ui/desktop/apps/host-manager/HostManagerApp.tsx"),
);
const TerminalApp = lazy(
() => import("./ui/desktop/apps/features/terminal/TerminalApp.tsx"),
);
const FileManagerApp = lazy(
() => import("./ui/desktop/apps/features/file-manager/FileManagerApp.tsx"),
);
const TunnelApp = lazy(
() => import("./ui/desktop/apps/features/tunnel/TunnelApp.tsx"),
);
const ServerStatsApp = lazy(
() => import("./ui/desktop/apps/features/server-stats/ServerStatsApp.tsx"),
);
const DockerApp = lazy(
() => import("./ui/desktop/apps/features/docker/DockerApp.tsx"),
);
const GuacamoleApp = lazy(
() => import("@/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx"),
);
const ElectronVersionCheck = lazy(() =>
import("@/ui/desktop/user/ElectronVersionCheck.tsx").then((module) => ({
default: module.ElectronVersionCheck,
})),
);
const FullscreenApp: React.FC = () => {
const searchParams = new URLSearchParams(window.location.search);
@@ -96,7 +120,10 @@ function RootApp() {
useServiceWorker();
const userAgent =
navigator.userAgent || navigator.vendor || (window as any).opera || "";
navigator.userAgent ||
navigator.vendor ||
(window as Window & { opera?: string }).opera ||
"";
const isTermixMobile = /Termix-Mobile/.test(userAgent);
const searchParams = new URLSearchParams(window.location.search);
@@ -141,22 +168,26 @@ function RootApp() {
)}
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
{isElectron() && showVersionCheck && !isFullscreen ? (
<ElectronVersionCheck
onContinue={() => setShowVersionCheck(false)}
isAuthenticated={false}
/>
<Suspense fallback={null}>
<ElectronVersionCheck
onContinue={() => setShowVersionCheck(false)}
isAuthenticated={false}
/>
</Suspense>
) : (
renderApp()
<Suspense fallback={null}>{renderApp()}</Suspense>
)}
</div>
</>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<RootApp />
</ThemeProvider>
</StrictMode>,
);
prepareClientCacheVersion().finally(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<RootApp />
</ThemeProvider>
</StrictMode>,
);
});
+1 -1
View File
@@ -32,7 +32,7 @@ export type LogEntry = {
type: "info" | "success" | "warning" | "error";
stage: ConnectionStage;
message: string;
details?: Record<string, any>;
details?: Record<string, unknown>;
};
export interface ConnectionLogResponse {
+42 -2
View File
@@ -32,6 +32,46 @@ export interface ElectronAPI {
getServerConfig: () => Promise<ServerConfig>;
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
testServerConnection: (serverUrl: string) => Promise<ConnectionTestResult>;
getC2STunnelConfig: () => Promise<unknown[]>;
saveC2STunnelConfig: (
config: unknown[],
) => Promise<{ success: boolean; error?: string }>;
checkLocalPortAvailable: (
host: string,
port: number,
) => Promise<{ available: boolean; error?: string }>;
getC2STunnelPresetDefaultName: () => Promise<string>;
startC2STunnel: (
tunnel: unknown,
index: number,
) => Promise<{ success: boolean; tunnelName?: string; error?: string }>;
testC2STunnel: (
tunnel: unknown,
index: number,
) => Promise<{ success: boolean; message?: string; error?: string }>;
stopC2STunnel: (
tunnelName: string,
) => Promise<{ success: boolean; error?: string }>;
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
onC2STunnelStatuses?: (
callback: (statuses: Record<string, unknown>) => void,
) => () => void;
startC2SAutoStartTunnels: () => Promise<{
success: boolean;
started: number;
errors: string[];
}>;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (
name: string,
targetUrl?: string,
) => Promise<string | null>;
waitForSessionCookie: (
name: string,
targetUrl?: string,
previousValue?: string | null,
timeoutMs?: number,
) => Promise<{ success: boolean; value?: string; error?: string }>;
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
@@ -89,8 +129,8 @@ declare global {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
electronClipboard?: {
writeText(text: string): void;
readText(): string;
writeText(text: string): Promise<boolean>;
readText(): Promise<string>;
};
}
}
+39 -3
View File
@@ -1,5 +1,6 @@
import type { Client } from "ssh2";
import type { Request } from "express";
import type { RefObject } from "react";
// ============================================================================
// HOST TYPES (SSH, RDP, VNC, Telnet)
@@ -248,11 +249,20 @@ export interface CredentialData {
// TUNNEL TYPES
// ============================================================================
export type TunnelScope = "s2s" | "c2s";
export type TunnelMode = "local" | "remote" | "dynamic";
export interface TunnelConnection {
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
bindHost?: string;
sourceHostId?: number;
sourceHostName?: string;
sourcePort: number;
endpointPort: number;
endpointHost: string;
endpointHost?: string;
targetHost?: string;
endpointPassword?: string;
endpointKey?: string;
@@ -267,7 +277,11 @@ export interface TunnelConnection {
export interface TunnelConfig {
name: string;
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
bindHost?: string;
targetHost?: string;
sourceHostId: number;
tunnelIndex: number;
@@ -311,6 +325,17 @@ export interface TunnelConfig {
socks5ProxyChain?: ProxyNode[];
}
export interface C2STunnelPreset {
id: number;
userId: string;
name: string;
config: TunnelConnection[];
platform?: string | null;
computerName?: string | null;
createdAt: string;
updatedAt: string;
}
export interface TunnelStatus {
connected: boolean;
status: ConnectionState;
@@ -325,7 +350,7 @@ export interface TunnelStatus {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, any>;
details?: Record<string, unknown>;
}>;
}
@@ -468,12 +493,22 @@ export interface TabContextTab {
| "telnet";
title: string;
hostConfig?: SSHHost;
terminalRef?: any;
terminalRef?: RefObject<TerminalRefHandle | null>;
initialTab?: string;
_updateTimestamp?: number;
connectionConfig?: Record<string, unknown>;
}
export interface TerminalRefHandle {
disconnect?: () => void;
reconnect?: () => void;
fit?: () => void;
sendInput?: (data: string) => void;
notifyResize?: () => void;
refresh?: () => void;
openFileManager?: () => void;
}
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
export interface SplitConfiguration {
@@ -715,6 +750,7 @@ export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface AuthenticatedRequest extends Request {
userId: string;
sessionId?: string;
user?: {
id: string;
username: string;
+2 -2
View File
@@ -80,7 +80,7 @@ export function ServerStatusProvider({
return prev;
});
return enabled;
} catch (error) {
} catch {
return new Set<number>();
}
}, [isAuthenticated]);
@@ -111,7 +111,7 @@ export function ServerStatusProvider({
}
setStatuses(newStatuses);
} catch (error) {
} catch {
if (mountedRef.current) {
setStatuses((prev) => {
const updated = new Map(prev);
+190 -62
View File
@@ -1,16 +1,15 @@
import React, {
useState,
useEffect,
useCallback,
useRef,
Component,
type ErrorInfo,
Suspense,
lazy,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager.tsx";
import {
TabProvider,
useTabs,
@@ -18,16 +17,47 @@ import {
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { ServerStatusProvider } from "@/ui/contexts/ServerStatusContext";
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
import { Toaster } from "@/components/ui/sonner.tsx";
import { toast } from "sonner";
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
import {
getUserInfo,
logoutUser,
isCurrentAuthInvalidationError,
} from "@/ui/main-axios.ts";
import { useTheme } from "@/components/theme-provider";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
import { useTranslation } from "react-i18next";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
const Dashboard = lazy(() =>
import("@/ui/desktop/apps/dashboard/Dashboard.tsx").then((module) => ({
default: module.Dashboard,
})),
);
const HostManager = lazy(() =>
import("@/ui/desktop/apps/host-manager/hosts/HostManager.tsx").then(
(module) => ({
default: module.HostManager,
}),
),
);
const AdminSettings = lazy(() =>
import("@/ui/desktop/apps/admin/AdminSettings.tsx").then((module) => ({
default: module.AdminSettings,
})),
);
const UserProfile = lazy(() =>
import("@/ui/desktop/user/UserProfile.tsx").then((module) => ({
default: module.UserProfile,
})),
);
const CommandPalette = lazy(() =>
import("@/ui/desktop/apps/command-palette/CommandPalette.tsx").then(
(module) => ({
default: module.CommandPalette,
}),
),
);
function AppContent({
onAuthStateChange,
@@ -52,6 +82,7 @@ function AppContent({
const { theme, setTheme } = useTheme();
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
const isAuthenticatedRef = useRef(false);
const isDarkMode =
theme === "dark" ||
@@ -96,6 +127,8 @@ function AppContent({
const handleSessionExpired = () => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
};
dbHealthMonitor.on(
@@ -177,9 +210,8 @@ function AppContent({
if (hostIdentifier) {
const openTerminal = async () => {
try {
const { getSSHHostById, getSSHHosts } = await import(
"@/ui/main-axios.ts"
);
const { getSSHHostById, getSSHHosts } =
await import("@/ui/main-axios.ts");
let host = null;
if (/^\d+$/.test(hostIdentifier)) {
@@ -211,6 +243,22 @@ function AppContent({
}, [addTab]);
const isCheckingAuth = useRef(false);
const clientTunnelAutoStartStarted = useRef(false);
const startClientTunnelAutoStart = useCallback(() => {
if (
clientTunnelAutoStartStarted.current ||
!window.electronAPI?.isElectron
) {
return;
}
clientTunnelAutoStartStarted.current = true;
window.electronAPI.startC2SAutoStartTunnels?.().catch((error) => {
clientTunnelAutoStartStarted.current = false;
console.error("Failed to start client tunnel auto-start entries:", error);
});
}, []);
useEffect(() => {
const checkAuth = () => {
@@ -227,16 +275,22 @@ function AppContent({
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
startClientTunnelAutoStart();
}
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
if (isCurrentAuthInvalidationError(err)) {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
console.warn("Session expired - please log in again");
return;
}
if (!isAuthenticatedRef.current) {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
}
})
.finally(() => {
@@ -251,7 +305,7 @@ function AppContent({
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
}, [startClientTunnelAutoStart]);
useEffect(() => {
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
@@ -259,6 +313,7 @@ function AppContent({
useEffect(() => {
onAuthStateChange?.(isAuthenticated);
isAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated, onAuthStateChange]);
const handleAuthSuccess = useCallback(
@@ -274,6 +329,7 @@ function AppContent({
setIsAuthenticated(true);
setIsAdmin(authData.isAdmin);
setUsername(authData.username);
startClientTunnelAutoStart();
setTransitionPhase("fadeIn");
setTimeout(() => {
@@ -282,7 +338,7 @@ function AppContent({
}, 800);
}, 1200);
},
[],
[startClientTunnelAutoStart],
);
const handleLogout = useCallback(async () => {
@@ -347,18 +403,22 @@ function AppContent({
return (
<div className="h-screen w-screen overflow-hidden bg-background">
<CommandPalette
isOpen={isCommandPaletteOpen}
setIsOpen={setIsCommandPaletteOpen}
/>
<Suspense fallback={null}>
<CommandPalette
isOpen={isCommandPaletteOpen}
setIsOpen={setIsCommandPaletteOpen}
/>
</Suspense>
{!isAuthenticated && (
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
<Dashboard
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
<Suspense fallback={null}>
<Dashboard
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</Suspense>
</div>
)}
@@ -382,56 +442,124 @@ function AppContent({
{showHome && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<Dashboard
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
<Suspense
fallback={
<div
className="bg-canvas rounded-lg border-2 border-edge relative"
style={{
margin: "74px 17px 8px 8px",
height: "calc(100vh - 82px)",
}}
>
<SimpleLoader
visible={true}
message={t("common.loading")}
/>
</div>
}
>
<Dashboard
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
</Suspense>
</div>
)}
{showSshManager && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<HostManager
isTopbarOpen={isTopbarOpen}
initialTab={currentTabData?.initialTab}
hostConfig={currentTabData?.hostConfig}
_updateTimestamp={currentTabData?._updateTimestamp}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
currentTabId={currentTab}
updateTab={updateTab}
/>
<Suspense
fallback={
<div
className="bg-canvas rounded-lg border-2 border-edge relative"
style={{
margin: "74px 17px 8px 8px",
height: "calc(100vh - 82px)",
}}
>
<SimpleLoader
visible={true}
message={t("common.loading")}
/>
</div>
}
>
<HostManager
isTopbarOpen={isTopbarOpen}
initialTab={currentTabData?.initialTab}
hostConfig={currentTabData?.hostConfig}
_updateTimestamp={currentTabData?._updateTimestamp}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
currentTabId={currentTab}
updateTab={updateTab}
/>
</Suspense>
</div>
)}
{showAdmin && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<AdminSettings
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
<Suspense
fallback={
<div
className="bg-canvas rounded-lg border-2 border-edge relative"
style={{
margin: "74px 17px 8px 8px",
height: "calc(100vh - 82px)",
}}
>
<SimpleLoader
visible={true}
message={t("common.loading")}
/>
</div>
}
>
<AdminSettings
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
</Suspense>
</div>
)}
{showProfile && (
<div className="h-screen w-full visible pointer-events-auto static overflow-auto thin-scrollbar">
<UserProfile
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
<Suspense
fallback={
<div
className="bg-canvas rounded-lg border-2 border-edge relative"
style={{
margin: "74px 17px 8px 8px",
height: "calc(100vh - 82px)",
}}
>
<SimpleLoader
visible={true}
message={t("common.loading")}
/>
</div>
}
>
<UserProfile
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
initialTab={currentTabData?.initialTab}
/>
</Suspense>
</div>
)}
<TopNavbar
isTopbarOpen={isTopbarOpen}
setIsTopbarOpen={setIsTopbarOpen}
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
onRightSidebarStateChange={(isOpen, width) => {
setRightSidebarOpen(isOpen);
setRightSidebarWidth(width);
@@ -643,7 +771,7 @@ class TabErrorBoundary extends Component<
throw error;
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
if (error.message?.includes("useTabs must be used within a TabProvider")) {
console.warn(
"TabProvider mounting race condition detected, recovering...",
+15 -9
View File
@@ -6,6 +6,7 @@ import { getSSHHosts, getUserInfo } from "@/ui/main-axios.ts";
import type { SSHHost } from "@/types";
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
import { Toaster } from "@/components/ui/sonner.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
interface FullScreenAppWrapperProps {
hostId?: string;
@@ -20,7 +21,18 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
const [loading, setLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authLoading, setAuthLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [, setIsAdmin] = useState(false);
useEffect(() => {
const handleSessionExpired = () => {
setIsAuthenticated(false);
setIsAdmin(false);
setHostConfig(null);
};
dbHealthMonitor.on("session-expired", handleSessionExpired);
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
}, []);
useEffect(() => {
const checkAuth = async () => {
@@ -28,9 +40,8 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
const userInfo = await getUserInfo();
if (userInfo) {
setIsAuthenticated(true);
setIsAdmin(userInfo.isAdmin || false);
}
} catch (error) {
} catch {
setIsAuthenticated(false);
} finally {
setAuthLoading(false);
@@ -65,13 +76,8 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
}
}, [hostId, isAuthenticated, authLoading]);
const handleAuthSuccess = (authData: {
isAdmin: boolean;
username: string | null;
userId: string | null;
}) => {
const handleAuthSuccess = () => {
setIsAuthenticated(true);
setIsAdmin(authData.isAdmin);
window.location.reload();
};
+53 -30
View File
@@ -7,7 +7,7 @@ import {
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Shield, Users, Database, Clock } from "lucide-react";
import { Shield, Users, Database, Clock, Key } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
@@ -22,12 +22,14 @@ import {
getSessions,
unlinkOIDCFromPasswordAccount,
} from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { RolesTab } from "@/ui/desktop/apps/admin/tabs/RolesTab.tsx";
import { GeneralSettingsTab } from "@/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx";
import { OIDCSettingsTab } from "@/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx";
import { UserManagementTab } from "@/ui/desktop/apps/admin/tabs/UserManagementTab.tsx";
import { SessionManagementTab } from "@/ui/desktop/apps/admin/tabs/SessionManagementTab.tsx";
import { DatabaseSecurityTab } from "@/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx";
import { ApiKeysTab } from "@/ui/desktop/apps/admin/tabs/ApiKeysTab.tsx";
import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx";
import { UserEditDialog } from "./dialogs/UserEditDialog.tsx";
import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx";
@@ -47,6 +49,7 @@ export function AdminSettings({
const { confirmWithToast } = useConfirmation();
const { state: sidebarState } = useSidebar();
const [loading, setLoading] = React.useState(true);
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
const [allowPasswordReset, setAllowPasswordReset] = React.useState(true);
@@ -102,8 +105,8 @@ export function AdminSettings({
createdAt: string;
expiresAt: string;
lastActiveAt: string;
jwtToken: string;
isRevoked?: boolean;
isCurrentSession?: boolean;
}>
>([]);
const [sessionsLoading, setSessionsLoading] = React.useState(false);
@@ -119,36 +122,45 @@ export function AdminSettings({
const serverUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (!serverUrl) {
setLoading(false);
return;
}
}
getAdminOIDCConfig()
.then((res) => {
if (res) setOidcConfig(res);
})
.catch((err) => {
if (!err.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchOidcConfig"));
}
});
getUserInfo()
.then((info) => {
if (info) {
setCurrentUser({
id: info.userId,
username: info.username,
is_admin: info.is_admin,
is_oidc: info.is_oidc,
});
}
})
.catch((err) => {
if (!err?.message?.includes("No server configured")) {
console.warn("Failed to fetch current user info", err);
}
});
fetchSessions();
Promise.allSettled([
getAdminOIDCConfig()
.then((res) => {
if (res) setOidcConfig(res);
})
.catch((err) => {
if (!err.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchOidcConfig"));
}
}),
getUserInfo()
.then((info) => {
if (info) {
setCurrentUser({
id: info.userId,
username: info.username,
is_admin: info.is_admin,
is_oidc: info.is_oidc,
});
}
})
.catch((err) => {
if (!err?.message?.includes("No server configured")) {
console.warn("Failed to fetch current user info", err);
}
}),
getSessions()
.then((data) => setSessions(data.sessions || []))
.catch((err) => {
if (!err?.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchSessions"));
}
}),
]).finally(() => setLoading(false));
}, []);
React.useEffect(() => {
@@ -332,6 +344,7 @@ export function AdminSettings({
style={wrapperStyle}
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"
>
<SimpleLoader visible={loading} message={t("common.loading")} />
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">{t("admin.title")}</h1>
@@ -391,6 +404,13 @@ export function AdminSettings({
<Database className="h-4 w-4" />
{t("admin.databaseSecurity")}
</TabsTrigger>
<TabsTrigger
value="api-keys"
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
<Key className="h-4 w-4" />
{t("admin.apiKeys.tabLabel")}
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
@@ -441,7 +461,11 @@ export function AdminSettings({
</TabsContent>
<TabsContent value="security" className="space-y-6">
<DatabaseSecurityTab currentUser={currentUser} />
<DatabaseSecurityTab />
</TabsContent>
<TabsContent value="api-keys" className="space-y-6">
<ApiKeysTab />
</TabsContent>
</Tabs>
</div>
@@ -459,7 +483,6 @@ export function AdminSettings({
user={selectedUserForEdit}
currentUser={currentUser}
onSuccess={handleEditUserSuccess}
allowPasswordLogin={allowPasswordLogin}
/>
<LinkAccountDialog
@@ -5,7 +5,6 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
@@ -20,7 +19,6 @@ import {
Plus,
AlertCircle,
Shield,
Key,
Clock,
} from "lucide-react";
import { toast } from "sonner";
@@ -32,7 +30,6 @@ import {
removeRoleFromUser,
makeUserAdmin,
removeAdminStatus,
initiatePasswordReset,
revokeAllUserSessions,
deleteUser,
type UserRole,
@@ -53,7 +50,6 @@ interface UserEditDialogProps {
user: User | null;
currentUser: { id: string; username: string } | null;
onSuccess: () => void;
allowPasswordLogin: boolean;
}
export function UserEditDialog({
@@ -62,13 +58,11 @@ export function UserEditDialog({
user,
currentUser,
onSuccess,
allowPasswordLogin,
}: UserEditDialogProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [adminLoading, setAdminLoading] = useState(false);
const [passwordResetLoading, setPasswordResetLoading] = useState(false);
const [sessionLoading, setSessionLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [rolesLoading, setRolesLoading] = useState(false);
@@ -160,42 +154,6 @@ export function UserEditDialog({
}
};
const handlePasswordReset = async () => {
if (!user) return;
const userToReset = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("admin.resetUserPassword"),
description: `${t("admin.passwordResetWarning")} (${userToReset.username})`,
confirmText: t("admin.resetUserPassword"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
setPasswordResetLoading(true);
try {
await initiatePasswordReset(userToReset.username);
toast.success(
t("admin.passwordResetInitiated", { username: userToReset.username }),
);
onSuccess();
onOpenChange(true);
} catch (error) {
console.error("Failed to reset password:", error);
toast.error(t("admin.failedToResetPassword"));
onOpenChange(true);
} finally {
setPasswordResetLoading(false);
}
};
const handleAssignRole = async (roleId: number) => {
if (!user) return;
@@ -342,9 +300,6 @@ export function UserEditDialog({
if (!user) return null;
const showPasswordReset =
allowPasswordLogin && (user.passwordHash || !user.isOidc);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl bg-canvas border-2 border-edge">
@@ -0,0 +1,507 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import {
Key,
Plus,
Trash2,
Copy,
Check,
ChevronsUpDown,
AlertCircle,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getApiKeys,
createApiKey,
deleteApiKey,
getUserList,
type ApiKey,
type CreatedApiKey,
} from "@/ui/main-axios.ts";
interface UserOption {
id: string;
username: string;
}
function UserCombobox({
users,
value,
onChange,
disabled,
}: {
users: UserOption[];
value: string;
onChange: (id: string) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const selected = users.find((u) => u.id === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
disabled={disabled}
>
{selected ? selected.username : t("admin.apiKeys.selectUser")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder={t("admin.apiKeys.searchUsers")} />
<CommandList>
<CommandEmpty>{t("admin.apiKeys.noUsersFound")}</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-y-auto thin-scrollbar">
{users.map((user) => (
<CommandItem
key={user.id}
value={user.username}
onSelect={() => {
onChange(user.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === user.id ? "opacity-100" : "opacity-0",
)}
/>
{user.username}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function CreateApiKeyDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = React.useState("");
const [selectedUserId, setSelectedUserId] = React.useState("");
const [expiresAt, setExpiresAt] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [usersLoading, setUsersLoading] = React.useState(false);
const [users, setUsers] = React.useState<UserOption[]>([]);
const [createdKey, setCreatedKey] = React.useState<CreatedApiKey | null>(
null,
);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!open) return;
if (createdKey) return;
setUsersLoading(true);
getUserList()
.then((res) =>
setUsers(
res.users.map((u) => ({
id: (u as unknown as { id: string }).id,
username: u.username,
})),
),
)
.catch(() => toast.error(t("admin.failedToFetchUsers")))
.finally(() => setUsersLoading(false));
}, [open]);
const handleClose = () => {
setCreatedKey(null);
setName("");
setSelectedUserId("");
setExpiresAt("");
setCopied(false);
onOpenChange(false);
onCreated();
};
const handleCreate = async () => {
if (!name.trim()) {
toast.error(t("admin.apiKeys.nameRequired"));
return;
}
if (!selectedUserId) {
toast.error(t("admin.apiKeys.userRequired"));
return;
}
setLoading(true);
try {
const result = await createApiKey(
name.trim(),
selectedUserId,
expiresAt || undefined,
);
setCreatedKey(result);
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } };
toast.error(
e?.response?.data?.error || t("admin.apiKeys.failedToCreate"),
);
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
if (!createdKey) return;
await navigator.clipboard.writeText(createdKey.token);
setCopied(true);
toast.success(t("admin.apiKeys.tokenCopied"));
setTimeout(() => setCopied(false), 2000);
};
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
{createdKey
? t("admin.apiKeys.keyCreated")
: t("admin.apiKeys.createApiKey")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{createdKey
? t("admin.apiKeys.keyCreatedDescription")
: t("admin.apiKeys.createApiKeyDescription")}
</DialogDescription>
</DialogHeader>
{!createdKey ? (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{t("admin.apiKeys.keyName")}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("admin.apiKeys.keyNamePlaceholder")}
disabled={loading}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>{t("admin.apiKeys.scopedUser")}</Label>
{usersLoading ? (
<p className="text-sm text-muted-foreground">
{t("admin.loading")}
</p>
) : (
<UserCombobox
users={users}
value={selectedUserId}
onChange={setSelectedUserId}
disabled={loading}
/>
)}
</div>
<div className="space-y-2">
<Label>
{t("admin.apiKeys.expiresAt")}{" "}
<span className="text-muted-foreground text-xs">
({t("admin.apiKeys.optional")})
</span>
</Label>
<Input
type="date"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
disabled={loading}
min={new Date().toISOString().split("T")[0]}
/>
<p className="text-xs text-muted-foreground">
{t("admin.apiKeys.expiresAtHelp")}
</p>
</div>
</div>
) : (
<div className="space-y-4 py-4">
<Alert className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-900 dark:text-yellow-200">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<AlertTitle>{t("admin.apiKeys.copyWarningTitle")}</AlertTitle>
<AlertDescription>
{t("admin.apiKeys.copyWarningDescription")}
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label>{t("admin.apiKeys.apiKey")}</Label>
<div className="flex gap-2 items-start">
<code className="flex-1 block rounded bg-muted px-3 py-2 text-xs font-mono break-all border border-edge">
{createdKey.token}
</code>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
)}
<DialogFooter>
{!createdKey ? (
<>
<Button
variant="outline"
onClick={handleClose}
disabled={loading}
>
{t("common.cancel")}
</Button>
<Button onClick={handleCreate} disabled={loading || usersLoading}>
{loading
? t("admin.apiKeys.creating")
: t("admin.apiKeys.createApiKey")}
</Button>
</>
) : (
<Button onClick={handleClose}>{t("common.done")}</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ApiKeysTab(): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [keys, setKeys] = React.useState<ApiKey[]>([]);
const [loading, setLoading] = React.useState(false);
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
const fetchKeys = React.useCallback(async () => {
setLoading(true);
try {
const data = await getApiKeys();
setKeys(data.apiKeys);
} catch {
toast.error(t("admin.apiKeys.failedToFetch"));
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
fetchKeys();
}, [fetchKeys]);
const handleDelete = (keyId: string, keyName: string) => {
confirmWithToast(
t("admin.apiKeys.confirmRevoke", { name: keyName }),
async () => {
try {
await deleteApiKey(keyId);
toast.success(t("admin.apiKeys.revokedSuccessfully"));
fetchKeys();
} catch {
toast.error(t("admin.apiKeys.failedToRevoke"));
}
},
"destructive",
);
};
const formatDate = (iso: string | null) => {
if (!iso) return t("admin.apiKeys.never");
const d = new Date(iso);
return (
d.toLocaleDateString() +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
};
const isExpired = (expiresAt: string | null) =>
expiresAt ? new Date(expiresAt) < new Date() : false;
return (
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t("admin.apiKeys.title")}</h3>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/api-keys", "_blank")
}
>
{t("common.documentation")}
</Button>
<Button
onClick={fetchKeys}
disabled={loading}
variant="outline"
size="sm"
>
<RefreshCw
className={cn("h-4 w-4 mr-1", loading && "animate-spin")}
/>
{loading ? t("admin.loading") : t("admin.refresh")}
</Button>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t("admin.apiKeys.createApiKey")}
</Button>
</div>
</div>
{loading && keys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("admin.loading")}
</div>
) : keys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("admin.apiKeys.noKeys")}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.apiKeys.name")}</TableHead>
<TableHead>{t("admin.user")}</TableHead>
<TableHead>{t("admin.apiKeys.prefix")}</TableHead>
<TableHead>{t("admin.created")}</TableHead>
<TableHead>{t("admin.expires")}</TableHead>
<TableHead>{t("admin.apiKeys.lastUsed")}</TableHead>
<TableHead>{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.id}>
<TableCell className="px-4 font-medium">
{key.name}
{!key.isActive && (
<Badge variant="destructive" className="ml-2 text-xs">
{t("admin.revoked")}
</Badge>
)}
</TableCell>
<TableCell className="px-4">
{key.username || key.userId}
</TableCell>
<TableCell className="px-4">
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{key.tokenPrefix}
</code>
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(key.createdAt)}
</TableCell>
<TableCell className="px-4 text-sm">
<span
className={
isExpired(key.expiresAt)
? "text-red-500"
: "text-muted-foreground"
}
>
{formatDate(key.expiresAt)}
</span>
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(key.lastUsedAt)}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
title={t("admin.apiKeys.revokeKey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<CreateApiKeyDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onCreated={fetchKeys}
/>
</div>
);
}
@@ -6,15 +6,7 @@ import { toast } from "sonner";
import { isElectron } from "@/ui/main-axios.ts";
import { getBasePath } from "@/lib/base-path";
interface DatabaseSecurityTabProps {
currentUser: {
is_oidc: boolean;
} | null;
}
export function DatabaseSecurityTab({
currentUser,
}: DatabaseSecurityTabProps): React.ReactElement {
export function DatabaseSecurityTab(): React.ReactElement {
const { t } = useTranslation();
const [exportLoading, setExportLoading] = React.useState(false);
@@ -42,12 +34,6 @@ export function DatabaseSecurityTab({
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (isElectron()) {
const token = localStorage.getItem("jwt");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
}
const response = await fetch(apiUrl, {
method: "POST",
@@ -110,17 +96,8 @@ export function DatabaseSecurityTab({
const formData = new FormData();
formData.append("file", importFile);
const importHeaders: Record<string, string> = {};
if (isElectron()) {
const token = localStorage.getItem("jwt");
if (token) {
importHeaders["Authorization"] = `Bearer ${token}`;
}
}
const response = await fetch(apiUrl, {
method: "POST",
headers: importHeaders,
credentials: "include",
body: formData,
});
@@ -73,7 +73,7 @@ export function GeneralSettingsTab({
const [logLevel, setLogLevel] = React.useState("info");
const [logLevelLoading, setLogLevelLoading] = React.useState(false);
const [sessionTimeoutHours, setSessionTimeoutHours] = React.useState(24);
const [, setSessionTimeoutHours] = React.useState(24);
const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24");
const [sessionTimeoutLoading, setSessionTimeoutLoading] =
React.useState(false);
+2 -2
View File
@@ -108,7 +108,7 @@ export function RolesTab(): React.ReactElement {
setRoleDialogOpen(false);
loadRoles();
} catch (error) {
} catch {
toast.error(t("rbac.failedToSaveRole"));
}
};
@@ -129,7 +129,7 @@ export function RolesTab(): React.ReactElement {
await deleteRole(role.id);
toast.success(t("rbac.roleDeletedSuccessfully"));
loadRoles();
} catch (error) {
} catch {
toast.error(t("rbac.failedToDeleteRole"));
}
};
@@ -12,11 +12,7 @@ import { Monitor, Smartphone, Globe, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getCookie,
revokeSession,
revokeAllUserSessions,
} from "@/ui/main-axios.ts";
import { revokeSession, revokeAllUserSessions } from "@/ui/main-axios.ts";
interface Session {
id: string;
@@ -27,8 +23,8 @@ interface Session {
createdAt: string;
expiresAt: string;
lastActiveAt: string;
jwtToken: string;
isRevoked?: boolean;
isCurrentSession?: boolean;
}
interface SessionManagementTabProps {
@@ -46,9 +42,9 @@ export function SessionManagementTab({
const { confirmWithToast } = useConfirmation();
const handleRevokeSession = async (sessionId: string) => {
const currentJWT = getCookie("jwt");
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
const isCurrentSession = currentSession?.id === sessionId;
const isCurrentSession = sessions.some(
(session) => session.id === sessionId && session.isCurrentSession,
);
confirmWithToast(
t("admin.confirmRevokeSession"),
@@ -8,13 +8,12 @@ import {
} from "@/components/ui/command.tsx";
import React, { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { Kbd, KbdKey, KbdSeparator } from "@/components/ui/kbd";
import {
Key,
Server,
Settings,
User,
Github,
Terminal,
Monitor,
Eye,
@@ -28,6 +27,7 @@ import {
import { useTranslation } from "react-i18next";
import { BiMoney, BiSupport } from "react-icons/bi";
import { BsDiscord } from "react-icons/bs";
import { FaGithub } from "react-icons/fa";
import { GrUpdate } from "react-icons/gr";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import {
@@ -38,7 +38,6 @@ import {
logActivity,
} from "@/ui/main-axios.ts";
import type { RecentActivityItem } from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import {
DropdownMenu,
@@ -76,7 +75,7 @@ interface SSHHost {
domain?: string;
security?: string;
ignoreCert?: boolean;
guacamoleConfig?: any;
guacamoleConfig?: unknown;
showTerminalInSidebar?: boolean;
showFileManagerInSidebar?: boolean;
showTunnelInSidebar?: boolean;
@@ -84,6 +83,28 @@ interface SSHHost {
showServerStatsInSidebar?: boolean;
}
function shouldShowMetrics(host: SSHHost): boolean {
try {
const statsConfig = host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
return statsConfig.metricsEnabled !== false;
} catch {
return true;
}
}
function hasTunnelConnections(host: SSHHost): boolean {
try {
const tunnelConnections = Array.isArray(host.tunnelConnections)
? host.tunnelConnections
: JSON.parse(host.tunnelConnections as string);
return Array.isArray(tunnelConnections) && tunnelConnections.length > 0;
} catch {
return false;
}
}
export function CommandPalette({
isOpen,
setIsOpen,
@@ -306,9 +327,6 @@ export function CommandPalette({
};
const handleHostEditClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "ssh_manager",
title: t("commandPalette.hostManager"),
@@ -391,32 +409,10 @@ export function CommandPalette({
? host.name
: `${host.username}@${host.ip}:${host.port}`;
let shouldShowMetrics = true;
try {
const statsConfig = host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
shouldShowMetrics = statsConfig.metricsEnabled !== false;
} catch {
shouldShowMetrics = true;
}
const isSSH =
!host.connectionType || host.connectionType === "ssh";
let hasTunnelConnections = false;
try {
const tunnelConnections = Array.isArray(
host.tunnelConnections,
)
? host.tunnelConnections
: JSON.parse(host.tunnelConnections as string);
hasTunnelConnections =
Array.isArray(tunnelConnections) &&
tunnelConnections.length > 0;
} catch {
hasTunnelConnections = false;
}
const showMetrics = shouldShowMetrics(host);
const hasTunnels = hasTunnelConnections(host);
const visibleButtons = [
host.enableTerminal && (host.showTerminalInSidebar ?? true),
@@ -425,13 +421,13 @@ export function CommandPalette({
(host.showFileManagerInSidebar ?? false),
isSSH &&
host.enableTunnel &&
hasTunnelConnections &&
hasTunnels &&
(host.showTunnelInSidebar ?? false),
isSSH &&
host.enableDocker &&
(host.showDockerInSidebar ?? false),
isSSH &&
shouldShowMetrics &&
showMetrics &&
(host.showServerStatsInSidebar ?? false),
].filter(Boolean).length;
@@ -493,7 +489,7 @@ export function CommandPalette({
{isSSH &&
host.enableTunnel &&
hasTunnelConnections &&
hasTunnels &&
(host.showTunnelInSidebar ?? false) && (
<Button
variant="outline"
@@ -523,7 +519,7 @@ export function CommandPalette({
)}
{isSSH &&
shouldShowMetrics &&
showMetrics &&
(host.showServerStatsInSidebar ?? false) && (
<Button
variant="outline"
@@ -580,7 +576,7 @@ export function CommandPalette({
</DropdownMenuItem>
)}
{isSSH &&
shouldShowMetrics &&
showMetrics &&
!(host.showServerStatsInSidebar ?? false) && (
<DropdownMenuItem
onClick={(e) => {
@@ -613,7 +609,7 @@ export function CommandPalette({
)}
{isSSH &&
host.enableTunnel &&
hasTunnelConnections &&
hasTunnels &&
!(host.showTunnelInSidebar ?? false) && (
<DropdownMenuItem
onClick={(e) => {
@@ -666,7 +662,7 @@ export function CommandPalette({
)}
<CommandGroup heading={t("commandPalette.links")}>
<CommandItem onSelect={handleGitHub}>
<Github />
<FaGithub />
<span>{t("commandPalette.github")}</span>
</CommandItem>
<CommandItem onSelect={handleSupport}>
@@ -686,10 +682,11 @@ export function CommandPalette({
<div className="border-t border-edge px-4 py-2 bg-hover/50 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t("commandPalette.press")}</span>
<KbdGroup>
<Kbd>Shift</Kbd>
<Kbd>Shift</Kbd>
</KbdGroup>
<Kbd>
<KbdKey>Shift</KbdKey>
<KbdSeparator />
<KbdKey>Shift</KbdKey>
</Kbd>
<span>{t("commandPalette.toToggle")}</span>
</div>
<div className="flex items-center gap-2">
+37 -41
View File
@@ -1,11 +1,10 @@
import React, { useEffect, useState, useContext } from "react";
import React, { useEffect, useState } from "react";
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
getUserInfo,
getDatabaseHealth,
getCookie,
getUptime,
getVersionInfo,
getSSHHosts,
@@ -18,6 +17,7 @@ import {
sendMetricsHeartbeat,
getGuacamoleDpi,
getGuacamoleTokenFromHost,
isCurrentAuthInvalidationError,
type RecentActivityItem,
} from "@/ui/main-axios.ts";
import { useSidebar } from "@/components/ui/sidebar.tsx";
@@ -64,11 +64,11 @@ export function Dashboard({
const [isAdmin, setIsAdmin] = useState(false);
const [, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(initialDbError);
const [, setDbError] = useState<string | null>(initialDbError);
const [uptime, setUptime] = useState<string>("0d 0h 0m");
const [versionStatus, setVersionStatus] = useState<
"up_to_date" | "requires_update"
"up_to_date" | "requires_update" | "beta"
>("up_to_date");
const [versionText, setVersionText] = useState<string>("");
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
@@ -93,7 +93,7 @@ export function Dashboard({
);
const [initialLoading, setInitialLoading] = useState(true);
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
const {
layout,
loading: preferencesLoading,
@@ -102,12 +102,12 @@ export function Dashboard({
} = useDashboardPreferences(loggedIn);
let sidebarState: "expanded" | "collapsed" = "expanded";
let sidebarAvailable = false;
try {
const sidebar = useSidebar();
sidebarState = sidebar.state;
sidebarAvailable = true;
} catch {}
} catch {
// Sidebar context is not available on every dashboard mount path.
}
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
@@ -120,40 +120,36 @@ export function Dashboard({
useEffect(() => {
if (isAuthenticated) {
if (getCookie("jwt")) {
getUserInfo()
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
})
.catch((err) => {
getUserInfo()
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
})
.catch((err) => {
if (isCurrentAuthInvalidationError(err)) {
setIsAdmin(false);
setUsername(null);
setUserId(null);
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");
setDbError("Session expired - please log in again");
} else {
setDbError(null);
}
});
getDatabaseHealth()
.then(() => {
console.warn("Session expired - please log in again");
setDbError("Session expired - please log in again");
} else {
setDbError(null);
})
.catch((err) => {
if (err?.response?.data?.error?.includes("Database")) {
setDbError(
"Could not connect to the database. Please try again later.",
);
}
});
}
}
});
getDatabaseHealth()
.then(() => {
setDbError(null);
})
.catch((err) => {
if (err?.response?.data?.error?.includes("Database")) {
setDbError(
"Could not connect to the database. Please try again later.",
);
}
});
}
}, [isAuthenticated]);
@@ -173,7 +169,8 @@ export function Dashboard({
setVersionText(`v${versionInfo.localVersion}`);
if (
versionInfo.status === "up_to_date" ||
versionInfo.status === "requires_update"
versionInfo.status === "requires_update" ||
versionInfo.status === "beta"
) {
setVersionStatus(versionInfo.status);
}
@@ -602,7 +599,6 @@ export function Dashboard({
setUserId={setUserId}
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/>
@@ -636,7 +632,7 @@ export function Dashboard({
<div className="flex flex-row gap-3 flex-wrap min-w-0">
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
<p className="text-muted-foreground text-sm whitespace-nowrap">
Press <Kbd>LShift</Kbd> twice to open the command palette
Press <Kbd>L Shift</Kbd> twice to open the command palette
</p>
</div>
<Button
@@ -1,17 +1,10 @@
import React, { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet.tsx";
import { Sheet, SheetContent } from "@/components/ui/sheet.tsx";
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import { BookOpen, X } from "lucide-react";
import { X } from "lucide-react";
interface UpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
@@ -48,8 +41,9 @@ interface RSSResponse {
}
interface VersionResponse {
status: "up_to_date" | "requires_update";
status: "up_to_date" | "requires_update" | "beta";
version: string;
localVersion?: string;
latest_release: {
name: string;
published_at: string;
@@ -136,6 +130,19 @@ export function UpdateLog({ loggedIn }: UpdateLogProps) {
</AlertDescription>
</Alert>
)}
{versionInfo && versionInfo.status === "beta" && (
<Alert className="bg-elevated border-edge text-foreground mb-3">
<AlertTitle className="text-foreground">
{t("versionCheck.betaVersion")}
</AlertTitle>
<AlertDescription className="text-foreground-secondary">
{t("versionCheck.betaVersionDesc", {
current: versionInfo.localVersion,
latest: versionInfo.version,
})}
</AlertDescription>
</Alert>
)}
{loading && (
<div className="flex items-center justify-center h-32">
@@ -71,7 +71,7 @@ export function AlertCard({
alert,
onDismiss,
onClose,
}: AlertCardProps): React.ReactElement {
}: AlertCardProps): React.ReactElement | null {
const { t } = useTranslation();
if (!alert) {
@@ -83,18 +83,6 @@ export function AlertCard({
onClose();
};
const formatExpiryDate = (expiryString: string) => {
const expiryDate = new Date(expiryString);
const now = new Date();
const diffTime = expiryDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return t("common.expired");
if (diffDays === 0) return t("common.expiresToday");
if (diffDays === 1) return t("common.expiresTomorrow");
return t("common.expiresInDays", { days: diffDays });
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader className="pb-3">
@@ -123,9 +111,6 @@ export function AlertCard({
{alert.type}
</Badge>
)}
<span className="text-sm text-muted-foreground">
{formatExpiryDate(alert.expiresAt)}
</span>
</div>
</CardHeader>
<CardContent className="pb-4">
@@ -136,7 +121,7 @@ export function AlertCard({
<CardFooter className="flex items-center justify-between pt-0">
<div className="flex gap-2">
<Button variant="outline" onClick={handleDismiss}>
Dismiss
{t("common.dismiss")}
</Button>
{alert.actionUrl && alert.actionText && (
<Button
@@ -144,10 +144,10 @@ export function AlertManager({
disabled={currentAlertIndex === 0}
className="h-8 px-3"
>
Previous
{t("common.previous")}
</Button>
<span className="text-sm text-muted-foreground">
{currentAlertIndex + 1} of {alerts.length}
{currentAlertIndex + 1} {t("common.of")} {alerts.length}
</span>
<Button
variant="outline"
@@ -156,7 +156,7 @@ export function AlertManager({
disabled={currentAlertIndex === alerts.length - 1}
className="h-8 px-3"
>
Next
{t("common.next")}
</Button>
</div>
)}
@@ -13,6 +13,8 @@ import {
getNetworkTopology,
saveNetworkTopology,
type SSHHostWithStatus,
type NetworkTopologyEdge,
type NetworkTopologyNode,
} from "@/ui/main-axios";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -34,7 +36,6 @@ import { Label } from "@/components/ui/label";
import {
Plus,
Trash2,
Move3D,
ZoomIn,
ZoomOut,
RotateCw,
@@ -46,7 +47,6 @@ import {
Edit,
FolderInput,
FolderMinus,
Settings2,
Terminal,
ArrowUp,
NetworkIcon,
@@ -104,13 +104,15 @@ interface NetworkGraphCardProps {
embedded?: boolean;
}
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
export function NetworkGraphCard({
embedded = true,
}: NetworkGraphCardProps): React.ReactElement {
const { t } = useTranslation();
const { addTab } = useTabs();
const [elements, setElements] = useState<any[]>([]);
const [elements, setElements] = useState<NetworkElement[]>([]);
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const [hostMap, setHostMap] = useState<HostMap>({});
const hostMapRef = useRef<HostMap>({});
@@ -205,8 +207,8 @@ export function NetworkGraphCard({
});
setHostMap(newHostMap);
let nodes: any[] = [];
let edges: any[] = [];
let nodes: NetworkTopologyNode[] = [];
let edges: NetworkTopologyEdge[] = [];
try {
const topologyData = await getNetworkTopology();
@@ -215,7 +217,7 @@ export function NetworkGraphCard({
topologyData.nodes &&
Array.isArray(topologyData.nodes)
) {
nodes = topologyData.nodes.map((node: any) => {
nodes = topologyData.nodes.map((node) => {
const host = newHostMap[node.data.id];
return {
data: {
@@ -232,12 +234,12 @@ export function NetworkGraphCard({
});
edges = topologyData.edges || [];
}
} catch (topologyError) {
} catch {
console.warn("Starting with empty topology");
}
const nodeIds = new Set(nodes.map((n: any) => n.data.id));
const validEdges = edges.filter((edge: any) => {
const nodeIds = new Set(nodes.map((n) => n.data.id));
const validEdges = edges.filter((edge) => {
const sourceExists = nodeIds.has(edge.data.source);
const targetExists = nodeIds.has(edge.data.target);
return sourceExists && targetExists;
@@ -315,7 +317,10 @@ export function NetworkGraphCard({
useEffect(() => {
if (!cyRef.current || loading || elements.length === 0) return;
const hasPositions = elements.some(
(el: any) => el.position && (el.position.x !== 0 || el.position.y !== 0),
(el) =>
"position" in el &&
el.position &&
(el.position.x !== 0 || el.position.y !== 0),
);
if (!hasPositions) {
@@ -633,7 +638,7 @@ export function NetworkGraphCard({
setElements([...cyRef.current.elements().jsons()]);
forceUpdate();
setShowAddNodeDialog(false);
} catch (err) {
} catch {
setError(t("networkGraph.failedToAddNode"));
}
};
@@ -891,7 +896,7 @@ export function NetworkGraphCard({
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
} catch (err) {
} catch {
setError(t("networkGraph.invalidFile"));
}
};
@@ -11,7 +11,6 @@ import {
Monitor,
Eye,
MessagesSquare,
Network,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { type RecentActivityItem } from "@/ui/main-axios";
@@ -14,7 +14,7 @@ import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog";
interface ServerOverviewCardProps {
loggedIn: boolean;
versionText: string;
versionStatus: "up_to_date" | "requires_update";
versionStatus: "up_to_date" | "requires_update" | "beta";
uptime: string;
dbHealth: "healthy" | "error";
totalServers: number;
@@ -61,11 +61,13 @@ export function ServerOverviewCard({
<Button
variant="outline"
size="sm"
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : versionStatus === "beta" ? "text-blue-400" : "text-yellow-400"}`}
>
{versionStatus === "up_to_date"
? t("dashboard.upToDate")
: t("dashboard.updateAvailable")}
: versionStatus === "beta"
? t("dashboard.beta")
: t("dashboard.updateAvailable")}
</Button>
<UpdateLog loggedIn={loggedIn} />
</>
@@ -35,7 +35,7 @@ export function useDashboardPreferences(enabled: boolean = true) {
} else {
setLayout(DEFAULT_LAYOUT);
}
} catch (error) {
} catch {
setLayout(DEFAULT_LAYOUT);
} finally {
setLoading(false);
@@ -1,14 +1,8 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
import {
connectDockerSession,
@@ -33,6 +27,7 @@ import {
useConnectionLog,
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
import type { LogEntry } from "@/types/connection-log.ts";
interface DockerManagerProps {
hostConfig?: SSHHost;
@@ -43,10 +38,11 @@ interface DockerManagerProps {
onClose?: () => void;
}
interface TabData {
id: number;
type: string;
[key: string]: unknown;
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
interface DockerConnectionError {
message?: string;
connectionLogs?: ConnectionLogInput[];
}
function DockerManagerInner({
@@ -76,7 +72,6 @@ function DockerManagerInner({
string | null
>(null);
const [isConnecting, setIsConnecting] = React.useState(false);
const [activeTab, setActiveTab] = React.useState("containers");
const [dockerValidation, setDockerValidation] =
React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false);
@@ -251,18 +246,19 @@ function DockerManagerInner({
logDockerActivity();
setTimeout(() => clearLogs(), 1000);
}
} catch (error: any) {
} catch (error) {
const dockerError = error as DockerConnectionError;
setIsConnecting(false);
setIsValidating(false);
setHasConnectionError(true);
if (error?.connectionLogs) {
setLogs(error.connectionLogs);
if (Array.isArray(dockerError.connectionLogs)) {
setLogs(dockerError.connectionLogs);
} else {
addLog({
type: "error",
stage: "connection",
message: error?.message || t("docker.connectionFailed"),
message: dockerError.message || t("docker.connectionFailed"),
});
}
} finally {
@@ -302,7 +298,7 @@ function DockerManagerInner({
try {
const data = await listDockerContainers(sessionId, true);
setContainers(data);
} catch (error) {
} catch {
// Silently handle polling errors
}
}, [sessionId]);
@@ -319,7 +315,7 @@ function DockerManagerInner({
if (!cancelled) {
setContainers(data);
}
} catch (error) {
} catch {
// Silently handle polling errors
} finally {
if (!cancelled) {
@@ -661,7 +657,7 @@ function DockerManagerInner({
<div className="flex-1 overflow-hidden min-h-0 relative">
{viewMode === "list" ? (
<div className="h-full min-h-0 px-4 py-4">
<div className="h-full min-h-0 px-4 pt-4">
{sessionId ? (
isLoadingContainers && containers.length === 0 ? (
<SimpleLoader
@@ -17,12 +17,11 @@ import { getBasePath } from "@/lib/base-path";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
import { isElectron } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { useTranslation } from "react-i18next";
interface ConsoleTerminalProps {
sessionId: string;
containerId: string;
containerName: string;
containerState: string;
@@ -30,7 +29,6 @@ interface ConsoleTerminalProps {
}
export function ConsoleTerminal({
sessionId,
containerId,
containerName,
containerState,
@@ -63,18 +61,32 @@ export function ConsoleTerminal({
terminal.options.fontSize = 14;
terminal.options.fontFamily = "monospace";
const readTextFromClipboard = async (): Promise<string> => {
if (window.electronClipboard) {
return window.electronClipboard.readText();
}
return navigator.clipboard.readText();
};
const writeTextToClipboard = async (text: string): Promise<void> => {
if (window.electronClipboard) {
await window.electronClipboard.writeText(text);
return;
}
await navigator.clipboard.writeText(text);
};
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
if (e.type !== "keydown") return true;
if (
((e.ctrlKey && !e.altKey && !e.metaKey) ||
(e.metaKey && !e.ctrlKey && !e.altKey)) &&
((e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) ||
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey)) &&
e.key.toLowerCase() === "v"
) {
e.preventDefault();
e.stopPropagation();
navigator.clipboard
.readText()
readTextFromClipboard()
.then((text) => {
if (text) terminal.paste(text);
})
@@ -96,7 +108,7 @@ export function ConsoleTerminal({
e.stopPropagation();
const selection = terminal.getSelection();
if (selection) {
navigator.clipboard.writeText(selection).catch(() => {
writeTextToClipboard(selection).catch(() => {
toast.error(t("terminal.clipboardWriteFailed"));
});
terminal.clearSelection();
@@ -105,23 +117,18 @@ export function ConsoleTerminal({
}
if (
((e.ctrlKey &&
e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key.toLowerCase() === "c") ||
(e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert")) &&
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert" &&
terminal.hasSelection()
) {
e.preventDefault();
e.stopPropagation();
const selection = terminal.getSelection();
if (selection) {
navigator.clipboard.writeText(selection).catch(() => {
writeTextToClipboard(selection).catch(() => {
toast.error(t("terminal.clipboardWriteFailed"));
});
}
@@ -137,8 +144,7 @@ export function ConsoleTerminal({
) {
e.preventDefault();
e.stopPropagation();
navigator.clipboard
.readText()
readTextFromClipboard()
.then((text) => {
if (text) terminal.paste(text);
})
@@ -192,20 +198,24 @@ export function ConsoleTerminal({
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) {}
} catch {
// Best-effort disconnect during cleanup.
}
wsRef.current.close();
wsRef.current = null;
}
terminal.dispose();
};
}, [terminal]);
}, [terminal, t]);
const disconnect = React.useCallback(() => {
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) {}
} catch {
// Best-effort disconnect.
}
wsRef.current.close();
wsRef.current = null;
}
@@ -213,9 +223,11 @@ export function ConsoleTerminal({
if (terminal) {
try {
terminal.clear();
} catch (error) {}
} catch {
// Terminal clear can fail after disposal.
}
}
}, [terminal, t]);
}, [terminal]);
const connect = React.useCallback(() => {
if (!terminal || containerState !== "running") {
@@ -226,15 +238,6 @@ export function ConsoleTerminal({
setIsConnecting(true);
try {
const token = isElectron()
? localStorage.getItem("jwt")
: getCookie("jwt");
if (!token) {
toast.error(t("docker.authenticationRequired"));
setIsConnecting(false);
return;
}
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
@@ -410,7 +413,9 @@ export function ConsoleTerminal({
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) {}
} catch {
// Best-effort disconnect during cleanup.
}
wsRef.current.close();
wsRef.current = null;
}
@@ -110,7 +110,6 @@ export function ContainerDetail({
className="flex-1 overflow-hidden px-3 pb-3 mt-3"
>
<ConsoleTerminal
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
containerState={container.state}
@@ -69,7 +69,7 @@ export function ContainerList({
}
return (
<div className="flex flex-col h-full min-h-0 gap-3">
<div className="flex flex-col h-full min-h-0">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -117,7 +117,7 @@ export function ContainerList({
</div>
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar pr-1">
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar h-fade pr-1 pb-2 pt-4">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full pb-2">
{filteredContainers.map((container) => (
<ContainerCard
@@ -54,6 +54,7 @@ import {
useConnectionLog,
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
import type { LogEntry } from "@/types/connection-log.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import {
listSSHFiles,
@@ -91,6 +92,20 @@ interface FileManagerProps {
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";
@@ -165,7 +180,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
>("no_keyboard");
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
const [isClosing, setIsClosing] = useState<boolean>(false);
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
const [contextMenu, setContextMenu] = useState<{
@@ -446,11 +460,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
} catch (dirError: unknown) {
console.error("Failed to load initial directory:", dirError);
}
} catch (error: any) {
} catch (error: unknown) {
const sshError = error as SSHConnectionError;
console.error("SSH connection failed:", error);
if (error?.connectionLogs) {
error.connectionLogs.forEach((log: any) => {
if (sshError.connectionLogs) {
sshError.connectionLogs.forEach((log) => {
addLog({
type: log.type,
stage: log.stage,
@@ -458,25 +473,25 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
details: log.details,
});
});
if (error.requires_totp) {
if (sshError.requires_totp) {
setTotpRequired(true);
setTotpSessionId(error.sessionId || currentHost.id.toString());
setTotpSessionId(sshError.sessionId || currentHost.id.toString());
setTotpPrompt(
error.prompt || t("fileManager.verificationCodePrompt"),
sshError.prompt || t("fileManager.verificationCodePrompt"),
);
setIsLoading(false);
return;
}
if (error.requires_warpgate) {
if (sshError.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(error.sessionId || currentHost.id.toString());
setWarpgateUrl(error.url || "");
setWarpgateSecurityKey(error.securityKey || "N/A");
setWarpgateSessionId(sshError.sessionId || currentHost.id.toString());
setWarpgateUrl(sshError.url || "");
setWarpgateSecurityKey(sshError.securityKey || "N/A");
setIsLoading(false);
return;
}
if (error.status === "auth_required") {
setAuthDialogReason(error.reason || "no_keyboard");
if (sshError.status === "auth_required") {
setAuthDialogReason(sshError.reason || "no_keyboard");
setShowAuthDialog(true);
setIsLoading(false);
return;
@@ -485,7 +500,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
addLog({
type: "error",
stage: "connection",
message: error?.message || t("fileManager.failedToConnect"),
message:
error instanceof Error
? error.message
: t("fileManager.failedToConnect"),
});
}
@@ -539,25 +557,36 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return true;
} catch (error: unknown) {
if (currentLoadingPathRef.current === resolvedPath) {
const axiosError = error as {
// ApiError has .status directly; raw axios errors have .response.status
const apiError = error as {
status?: number;
code?: string;
response?: {
status?: number;
data?: {
needsSudo?: boolean;
error?: string;
sudoFailed?: boolean;
disconnected?: boolean;
};
};
message?: string;
};
if (axiosError.response?.data?.needsSudo) {
const httpStatus = apiError.status ?? apiError.response?.status;
// 409 = concurrent request already in flight — silently drop
if (httpStatus === 409) {
return false;
}
if (apiError.response?.data?.needsSudo) {
if (!sudoDialogOpen) {
setPendingSudoOperation({ type: "navigate", path: resolvedPath });
setSudoDialogOpen(true);
}
if (axiosError.response.data.sudoFailed) {
if (apiError.response.data.sudoFailed) {
toast.error(t("fileManager.sudoAuthFailed"));
} else {
toast.error(t("fileManager.permissionDenied"));
@@ -568,24 +597,50 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
console.error("Failed to load directory:", error);
const errorMessage =
axiosError.response?.data?.error ||
axiosError.message ||
String(error);
apiError.response?.data?.error || apiError.message || String(error);
if (initialLoadDoneRef.current) {
const isConnectionError =
// 500s from the file manager are SSH channel/session errors
httpStatus === 500 ||
httpStatus === 503 ||
apiError.response?.data?.disconnected === true ||
errorMessage?.includes("channel open failure") ||
errorMessage?.includes("open failed") ||
errorMessage?.includes("SSH connection not established") ||
errorMessage?.includes("SSH session") ||
errorMessage?.toLowerCase().includes("not connected");
if (isConnectionError && sshSessionId && currentHost) {
setIsReconnecting(true);
setIsLoading(false);
setFiles([]);
currentLoadingPathRef.current = "";
void (async () => {
const delays = [1000, 2000, 3000, 5000, 5000];
for (let attempt = 0; attempt < delays.length; attempt++) {
await new Promise((r) => setTimeout(r, delays[attempt]));
try {
await ensureSSHConnection();
setIsReconnecting(false);
loadDirectory(resolvedPath);
return;
} catch {
// keep retrying
}
}
setIsReconnecting(false);
handleCloseWithError(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
})();
return false;
} else if (initialLoadDoneRef.current) {
toast.error(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
}
if (
errorMessage?.includes("connection") ||
errorMessage?.includes("SSH")
) {
handleCloseWithError(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
}
}
return false;
} finally {
@@ -595,7 +650,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
}
},
[sshSessionId, isLoading, clearSelection, t, sudoDialogOpen],
[sshSessionId, isLoading, clearSelection, t, sudoDialogOpen, currentHost],
);
const debouncedLoadDirectory = useCallback(
@@ -699,19 +754,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
reader.onerror = () => reject(reader.error);
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
const bytes = new Uint8Array(reader.result);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
if (typeof reader.result === "string") {
const base64 = reader.result.split(",")[1] || "";
resolve(base64);
} else {
reject(new Error("Failed to read file"));
}
};
reader.readAsArrayBuffer(file);
reader.readAsDataURL(file);
});
await uploadSSHFile(
@@ -1530,40 +1580,30 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
async function ensureSSHConnection() {
if (!sshSessionId || !currentHost || isReconnecting) return;
if (!sshSessionId || !currentHost) return;
try {
const status = await getSSHStatus(sshSessionId);
const status = await getSSHStatus(sshSessionId);
if (!status.connected && !isReconnecting) {
setIsReconnecting(true);
await connectSSH(sshSessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: currentHost.password,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword,
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
});
}
} catch (error) {
handleCloseWithError(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
);
throw error;
} finally {
setIsReconnecting(false);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: currentHost.password,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword,
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
});
}
}
@@ -20,7 +20,7 @@ import {
FileArchive,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdGroup } from "@/components/ui/kbd.tsx";
import { Kbd, KbdKey, KbdSeparator } from "@/components/ui/kbd.tsx";
interface FileItem {
name: string;
@@ -491,11 +491,14 @@ export function FileManagerContextMenu({
return <Kbd>{keys[0]}</Kbd>;
}
return (
<KbdGroup>
<Kbd>
{keys.map((key, index) => (
<Kbd key={index}>{key}</Kbd>
<>
<KbdKey key={`key-${index}`}>{key}</KbdKey>
{index < keys.length - 1 && <KbdSeparator key={`sep-${index}`} />}
</>
))}
</KbdGroup>
</Kbd>
);
};
@@ -1,15 +1,12 @@
import React, { useState, useEffect } from "react";
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
import { cn } from "@/lib/utils.ts";
import {
ChevronRight,
ChevronDown,
Folder,
File,
Star,
Clock,
Bookmark,
FolderOpen,
} from "lucide-react";
import { Star, Clock, Bookmark, File, Folder } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types";
import {
@@ -22,6 +19,9 @@ import {
removeFolderShortcut,
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
import FolderTree from "@/components/ui/folder.tsx";
// ─── Interfaces ────────────────────────────────────────────────────────────────
interface RecentFileData {
id: number;
@@ -71,6 +71,8 @@ interface FileManagerSidebarProps {
refreshTrigger?: number;
}
// ─── Component ─────────────────────────────────────────────────────────────────
export function FileManagerSidebar({
currentHost,
currentPath,
@@ -80,14 +82,22 @@ export function FileManagerSidebar({
refreshTrigger,
}: FileManagerSidebarProps) {
const { t } = useTranslation();
// ── Quick access state (API-backed) ──────────────────────────────────────────
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(["root"]),
);
// ── Directory tree state ──────────────────────────────────────────────────────
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
/**
* Tracks which folder paths have already been lazy-loaded so we don't
* re-fetch on every re-selection / collapse-reopen.
*/
const loadedFoldersRef = useRef<Set<string>>(new Set(["/"]));
// ── Context menu state ────────────────────────────────────────────────────────
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
@@ -100,16 +110,47 @@ export function FileManagerSidebar({
item: null,
});
// ─── 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 () => {
if (!currentHost?.id) return;
@@ -155,114 +196,13 @@ export function FileManagerSidebar({
}
};
const handleRemoveRecentFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
// ─── API: Directory tree ──────────────────────────────────────────────────────
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
} catch (error) {
console.error("Failed to remove recent file:", error);
toast.error(t("fileManager.removeFailed"));
}
};
const handleUnpinFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removePinnedFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFailed"));
}
};
const handleRemoveShortcut = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeFolderShortcut(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error("Failed to remove shortcut:", error);
toast.error(t("fileManager.removeShortcutFailed"));
}
};
const handleClearAllRecent = async () => {
if (!currentHost?.id || recentItems.length === 0) return;
try {
await Promise.all(
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData();
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) {
console.error("Failed to clear recent files:", error);
toast.error(t("fileManager.clearFailed"));
}
};
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
isVisible: true,
item,
});
};
const closeContextMenu = () => {
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
useEffect(() => {
if (!contextMenu.isVisible) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-sidebar-context-menu]");
if (!menuElement?.contains(target)) {
closeContextMenu();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeContextMenu();
}
};
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}, 50);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu.isVisible]);
const loadDirectoryTree = async () => {
const loadDirectoryTree = 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",
@@ -287,7 +227,15 @@ export function FileManagerSidebar({
children: rootTreeItems,
},
]);
} catch (error) {
} 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([
{
@@ -302,11 +250,116 @@ export function FileManagerSidebar({
}
};
const handleItemClick = (item: SidebarItem) => {
if (item.type === "folder") {
toggleFolder(item.id, item.path);
onPathChange(item.path);
} else if (item.type === "recent" || item.type === "pinned") {
/**
* Lazily fetches subdirectory contents and patches them into the tree state.
* Called the first time a folder is expanded via FolderTree's onSelect.
*/
const loadSubdirectory = useCallback(
async (folderId: string, folderPath: string) => {
if (!sshSessionId) return;
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
const subFiles = (subResponse.files || []) as DirectoryItemData[];
const subFolders = subFiles.filter(
(item: DirectoryItemData) => item.type === "directory",
);
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.path.replace(/\//g, "-")}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] =>
items.map((item) => {
if (item.id === folderId) {
return { ...item, children: subTreeItems };
}
if (item.children) {
return { ...item, children: updateChildren(item.children) };
}
return item;
});
return updateChildren(prevTree);
});
} catch (error: unknown) {
const status =
(error as { status?: number })?.status ||
(error as { response?: { status?: number } })?.response?.status;
if (status === 409) {
// Another request was listing this path — retry after the lock clears
setTimeout(() => loadSubdirectory(folderId, folderPath), 600);
return;
}
console.error("Failed to load subdirectory:", error);
}
},
[sshSessionId],
);
// ─── Quick-access mutation handlers ──────────────────────────────────────────
const handleRemoveRecentFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
} catch (error) {
console.error("Failed to remove recent file:", error);
toast.error(t("fileManager.removeFailed"));
}
};
const handleUnpinFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removePinnedFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFailed"));
}
};
const handleRemoveShortcut = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeFolderShortcut(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error("Failed to remove shortcut:", error);
toast.error(t("fileManager.removeShortcutFailed"));
}
};
const handleClearAllRecent = async () => {
if (!currentHost?.id || recentItems.length === 0) return;
try {
await Promise.all(
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData();
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) {
console.error("Failed to clear recent files:", error);
toast.error(t("fileManager.clearFailed"));
}
};
// ─── Quick-access item click ──────────────────────────────────────────────────
const handleQuickAccessClick = (item: SidebarItem) => {
if (item.type === "recent" || item.type === "pinned") {
if (onFileOpen) {
onFileOpen(item);
} else {
@@ -319,132 +372,180 @@ export function FileManagerSidebar({
}
};
const toggleFolder = async (folderId: string, folderPath?: string) => {
const newExpanded = new Set(expandedFolders);
// ─── FolderTree directory selection (onSelect callback) ──────────────────────
if (newExpanded.has(folderId)) {
newExpanded.delete(folderId);
} else {
newExpanded.add(folderId);
if (sshSessionId && folderPath && folderPath !== "/") {
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
const subFiles = (subResponse.files || []) as DirectoryItemData[];
const subFolders = subFiles.filter(
(item: DirectoryItemData) => item.type === "directory",
);
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.path.replace(/\//g, "-")}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
return items.map((item) => {
if (item.id === folderId) {
return { ...item, children: subTreeItems };
} else if (item.children) {
return { ...item, children: updateChildren(item.children) };
}
return item;
});
};
return updateChildren(prevTree);
});
} catch (error) {
console.error("Failed to load subdirectory:", error);
/**
* Called by FolderTree whenever the user selects (clicks) a tree item.
* We navigate to the folder and lazily load children on first visit.
*/
const handleDirectorySelect = useCallback(
async (id: string) => {
// Walk the tree to find the item by id
const findItem = (items: SidebarItem[]): SidebarItem | null => {
for (const item of items) {
if (item.id === id) return item;
if (item.children) {
const found = findItem(item.children);
if (found) return found;
}
}
}
}
return null;
};
setExpandedFolders(newExpanded);
const item = findItem(directoryTree);
if (!item) return;
// Navigate to path
onPathChange(item.path);
// Lazy-load children the first time this folder is expanded
if (
sshSessionId &&
item.path !== "/" &&
!loadedFoldersRef.current.has(item.path)
) {
loadedFoldersRef.current.add(item.path);
await loadSubdirectory(id, item.path);
}
},
[directoryTree, onPathChange, sshSessionId, loadSubdirectory],
);
// ─── Context menu ─────────────────────────────────────────────────────────────
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item });
};
const renderSidebarItem = (item: SidebarItem, level: number = 0) => {
const isExpanded = expandedFolders.has(item.id);
const isActive = currentPath === item.path;
const closeContextMenu = () => {
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
useEffect(() => {
if (!contextMenu.isVisible) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-sidebar-context-menu]");
if (!menuElement?.contains(target)) closeContextMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeContextMenu();
};
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}, 50);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu.isVisible]);
// ─── Derive selected tree node + ancestors from currentPath ──────────────────
const { selectedTreeId, ancestorIds } = useMemo(() => {
if (currentPath === "/")
return { selectedTreeId: "root", ancestorIds: new Set<string>() };
const ancestors: string[] = [];
const findByPath = (
items: SidebarItem[],
path: string[],
): string | null => {
for (const item of items) {
if (item.path === currentPath) {
ancestors.push(...path, "root");
return item.id;
}
if (item.children) {
const found = findByPath(item.children, [...path, item.id]);
if (found) return found;
}
}
return null;
};
const id = findByPath(directoryTree, []);
return { selectedTreeId: id, ancestorIds: new Set(ancestors) };
}, [currentPath, directoryTree]);
// ─── Render helpers ───────────────────────────────────────────────────────────
/**
* Recursively renders directory tree items using FolderTree.Item + Content.
*
* FolderTree.Item detects "has children" via React.Children.count > 0.
* By always wrapping children in <FolderTree.Content> (even when the
* children array is empty), every directory shows the expand chevron.
* FolderTree.Content internally shows nothing when its own children are
* absent, so an unloaded folder simply expands to an empty state while
* the async fetch fills it in.
*/
const renderFolderTreeItem = (item: SidebarItem): React.ReactNode => (
<FolderTree.Item key={item.id} id={item.id} label={item.name}>
<FolderTree.Content>
{item.children?.map((child) => renderFolderTreeItem(child))}
</FolderTree.Content>
</FolderTree.Item>
);
/**
* Styled quick-access row (recent / pinned / shortcut).
* Mirrors FolderTree.Item's visual language but adds onContextMenu support.
*/
const renderQuickAccessItem = (item: SidebarItem, icon: React.ReactNode) => {
const dirPath =
item.type === "shortcut"
? item.path
: item.path.substring(0, item.path.lastIndexOf("/")) || "/";
const isActive = currentPath === dirPath;
return (
<div key={item.id}>
<div
className={cn(
"flex items-center gap-2 py-1.5 text-sm cursor-pointer hover:bg-hover rounded",
isActive && "bg-primary/20 text-primary",
"text-foreground",
)}
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
onClick={() => handleItemClick(item)}
onContextMenu={(e) => {
if (
item.type === "recent" ||
item.type === "pinned" ||
item.type === "shortcut"
) {
handleContextMenu(e, item);
}
}}
>
{item.type === "folder" && (
<button
onClick={(e) => {
e.stopPropagation();
toggleFolder(item.id, item.path);
}}
className="p-0.5 hover:bg-hover rounded"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
{item.type === "folder" ? (
isExpanded ? (
<FolderOpen className="w-4 h-4" />
) : (
<Folder className="w-4 h-4" />
)
) : (
<File className="w-4 h-4" />
)}
<span className="truncate">{item.name}</span>
</div>
{item.type === "folder" && isExpanded && item.children && (
<div>
{item.children.map((child) => renderSidebarItem(child, level + 1))}
</div>
<div
key={item.id}
className={cn(
"flex items-center gap-2 py-1.5 pl-8 pr-3 text-sm cursor-pointer select-none transition-colors",
isActive
? "bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 border-r-2 border-blue-600"
: "hover:bg-gray-100 dark:hover:bg-slate-700/50 text-foreground",
)}
onClick={() => handleQuickAccessClick(item)}
onContextMenu={(e) => handleContextMenu(e, item)}
title={item.path}
>
{/* indent spacer matching FolderTree level-1 padding */}
<span className="w-3 shrink-0" aria-hidden="true" />
{icon}
<span className="flex-1 truncate">{item.name}</span>
</div>
);
};
/**
* Section header + items list.
* Returns null when items is empty so empty sections are hidden.
*/
const renderSection = (
title: string,
icon: React.ReactNode,
headerIcon: React.ReactNode,
items: SidebarItem[],
renderItem: (item: SidebarItem) => React.ReactNode,
) => {
if (items.length === 0) return null;
return (
<div className="mb-5">
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{icon}
<div className="mb-4">
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{headerIcon}
{title}
</div>
<div className="space-y-0.5">
{items.map((item) => renderSidebarItem(item))}
</div>
<div className="mt-0.5">{items.map((item) => renderItem(item))}</div>
</div>
);
};
@@ -452,53 +553,126 @@ export function FileManagerSidebar({
const hasQuickAccessItems =
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
// ─── Render ───────────────────────────────────────────────────────────────────
return (
<>
<div className="h-full flex flex-col bg-canvas border-r border-edge">
<div className="flex-1 relative overflow-hidden">
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-1">
{/* ── Recent files ──────────────────────────────────────── */}
{renderSection(
t("fileManager.recent"),
<Clock className="w-3 h-3" />,
recentItems,
(item) =>
renderQuickAccessItem(
item,
<File
size={15}
className={cn(
"shrink-0",
currentPath ===
(item.path.substring(0, item.path.lastIndexOf("/")) ||
"/")
? "text-blue-600 dark:text-blue-400"
: "text-gray-500 dark:text-gray-400",
)}
/>,
),
)}
{/* ── Pinned files ───────────────────────────────────────── */}
{renderSection(
t("fileManager.pinned"),
<Star className="w-3 h-3" />,
pinnedItems,
(item) =>
renderQuickAccessItem(
item,
<File
size={15}
className={cn(
"shrink-0",
currentPath ===
(item.path.substring(0, item.path.lastIndexOf("/")) ||
"/")
? "text-blue-600 dark:text-blue-400"
: "text-gray-500 dark:text-gray-400",
)}
/>,
),
)}
{/* ── Folder shortcuts ───────────────────────────────────── */}
{renderSection(
t("fileManager.folderShortcuts"),
<Bookmark className="w-3 h-3" />,
shortcuts,
(item) =>
renderQuickAccessItem(
item,
<Folder
size={15}
className={cn(
"shrink-0",
currentPath === item.path
? "text-blue-600 dark:text-blue-400"
: "text-blue-500 dark:text-blue-400",
)}
/>,
),
)}
{/* ── Directory tree ─────────────────────────────────────── */}
<div
className={cn(hasQuickAccessItems && "pt-4 border-t border-edge")}
className={cn(hasQuickAccessItems && "pt-3 border-t border-edge")}
>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<Folder className="w-3 h-3" />
{t("fileManager.directories")}
</div>
<div className="mt-2">
{directoryTree.map((item) => renderSidebarItem(item))}
<div className="mt-1">
{/*
* FolderTree.Root manages its own expansion state internally.
* We pass `onSelect` to be notified of clicks so we can:
* 1. Navigate to the selected path (onPathChange)
* 2. Lazy-load subdirectory children on first expand
*
* The root "/" folder is pre-expanded via defaultExpanded.
*
* className overrides strip the default card styling so the
* tree blends seamlessly into the sidebar panel.
*/}
<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>
</div>
</div>
{/* ── Context menu ─────────────────────────────────────────────── */}
{contextMenu.isVisible && contextMenu.item && (
<>
{/* Transparent backdrop to capture outside clicks */}
<div className="fixed inset-0 z-40" />
<div
data-sidebar-context-menu
className="fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[160px] z-50 overflow-hidden"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{/* Recent item actions */}
{contextMenu.item.type === "recent" && (
<>
<button
@@ -508,26 +682,23 @@ export function FileManagerSidebar({
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Clock className="w-4 h-4" />
</div>
<Clock className="w-4 h-4 shrink-0" />
<span className="flex-1">
{t("fileManager.removeFromRecentFiles")}
</span>
</button>
{recentItems.length > 1 && (
<>
<div className="border-t border-edge" />
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-red-400 hover:bg-red-500/10 first:rounded-t-lg last:rounded-b-lg"
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-red-500/10 text-red-400 first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleClearAllRecent();
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Clock className="w-4 h-4" />
</div>
<Clock className="w-4 h-4 shrink-0" />
<span className="flex-1">
{t("fileManager.clearAllRecentFiles")}
</span>
@@ -537,6 +708,7 @@ export function FileManagerSidebar({
</>
)}
{/* Pinned item actions */}
{contextMenu.item.type === "pinned" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-foreground first:rounded-t-lg last:rounded-b-lg"
@@ -545,13 +717,12 @@ export function FileManagerSidebar({
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Star className="w-4 h-4" />
</div>
<Star className="w-4 h-4 shrink-0" />
<span className="flex-1">{t("fileManager.unpinFile")}</span>
</button>
)}
{/* Shortcut item actions */}
{contextMenu.item.type === "shortcut" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-foreground first:rounded-t-lg last:rounded-b-lg"
@@ -560,9 +731,7 @@ export function FileManagerSidebar({
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Bookmark className="w-4 h-4" />
</div>
<Bookmark className="w-4 h-4 shrink-0" />
<span className="flex-1">
{t("fileManager.removeShortcut")}
</span>
@@ -10,7 +10,6 @@ import {
import { Button } from "@/components/ui/button.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { useTranslation } from "react-i18next";
import { ShieldAlert } from "lucide-react";
interface SudoPasswordDialogProps {
open: boolean;
@@ -0,0 +1,108 @@
import React from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { Music } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
size?: number;
}
interface AudioPreviewProps {
file: FileItem;
content: string;
color: string;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
function getAudioMimeType(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() || "";
switch (ext) {
case "mp3":
return "audio/mpeg";
case "wav":
return "audio/wav";
case "flac":
return "audio/flac";
case "ogg":
return "audio/ogg";
case "aac":
return "audio/aac";
case "m4a":
return "audio/mp4";
default:
return "audio/mpeg";
}
}
export function AudioPreview({
file,
content,
color,
onMediaDimensionsChange,
}: AudioPreviewProps) {
const { t } = useTranslation();
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const audioUrl = `data:${getAudioMimeType(file.name)};base64,${content}`;
return (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-2xl">
<div className="space-y-4">
<div className="flex justify-center">
<div
className={cn(
"w-32 h-32 rounded-lg bg-gradient-to-br from-pink-100 to-purple-100 flex items-center justify-center shadow-lg",
color,
)}
>
<Music className="w-16 h-16 text-pink-600" />
</div>
</div>
<div className="text-center">
<h3 className="font-semibold text-foreground text-lg mb-1">
{file.name.replace(/\.[^/.]+$/, "")}
</h3>
<p className="text-sm text-muted-foreground">
{ext.toUpperCase()} {formatFileSize(file.size, t)}
</p>
</div>
<div className="rounded-lg overflow-hidden">
<AudioPlayer
src={audioUrl}
onLoadedMetadata={() => {
onMediaDimensionsChange?.({
width: 600,
height: 400,
});
}}
onError={(e) => {
console.error("Audio playback error:", e);
}}
showJumpControls={false}
showSkipControls={false}
showDownloadProgress={true}
customAdditionalControls={[]}
customVolumeControls={[]}
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,160 @@
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
import {
defaultKeymap,
history,
historyKeymap,
toggleComment,
} from "@codemirror/commands";
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
export interface CodeEditorHandle {
openSearchPanel: () => void;
}
interface CodeEditorProps {
fileName: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
onFocus: () => void;
onBlur: () => void;
}
function getLanguageExtension(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName);
}
const langMap: Record<string, string> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
py: "python",
java: "java",
cpp: "cpp",
c: "c",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "sass",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
sql: "sql",
sh: "shell",
bash: "shell",
zsh: "shell",
vue: "vue",
svelte: "svelte",
md: "markdown",
conf: "shell",
ini: "properties",
};
const language = langMap[ext];
return language ? loadLanguage(language) : null;
}
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
function CodeEditor(
{ fileName, value, placeholder, onChange, onFocus, onBlur },
ref,
) {
const editorRef = useRef<{ view?: EditorView } | null>(null);
const extensions = useMemo(() => {
const languageExtension = getLanguageExtension(fileName);
return [
...(languageExtension ? [languageExtension] : []),
history(),
search(),
autocompletion(),
keymap.of([
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
{
key: "Mod-/",
run: toggleComment,
preventDefault: true,
},
{
key: "Mod-h",
run: () => false,
preventDefault: true,
},
]),
EditorView.theme({
"&": {
height: "100%",
},
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
},
".cm-editor": {
height: "100%",
},
}),
];
}, [fileName]);
useImperativeHandle(
ref,
() => ({
openSearchPanel: () => {
const view = editorRef.current?.view;
if (view) {
openSearchPanel(view);
}
},
}),
[],
);
return (
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
extensions={extensions}
theme={oneDark}
placeholder={placeholder}
className="h-full"
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false,
scrollPastEnd: false,
}}
/>
);
},
);
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react";
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
import {
@@ -38,29 +38,33 @@ import {
SiDocker,
} from "react-icons/si";
import { Button } from "@/components/ui/button.tsx";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
import {
defaultKeymap,
history,
historyKeymap,
toggleComment,
} from "@codemirror/commands";
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
import { PhotoProvider, PhotoView } from "react-photo-view";
import "react-photo-view/dist/react-photo-view.css";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Document, Page, pdfjs } from "react-pdf";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
const CodeEditor = lazy(() =>
import("./CodeEditor.tsx").then((module) => ({
default: module.CodeEditor,
})),
);
const ImagePreview = lazy(() =>
import("./ImagePreview.tsx").then((module) => ({
default: module.ImagePreview,
})),
);
const MarkdownRenderer = lazy(() =>
import("./MarkdownRenderer.tsx").then((module) => ({
default: module.MarkdownRenderer,
})),
);
const PdfPreview = lazy(() =>
import("./PdfPreview.tsx").then((module) => ({
default: module.PdfPreview,
})),
);
const AudioPreview = lazy(() =>
import("./AudioPreview.tsx").then((module) => ({
default: module.AudioPreview,
})),
);
interface FileItem {
name: string;
@@ -235,52 +239,6 @@ function getFileType(filename: string): {
}
}
function getLanguageExtension(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName);
}
const langMap: Record<string, string> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
py: "python",
java: "java",
cpp: "cpp",
c: "c",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "sass",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
sql: "sql",
sh: "shell",
bash: "shell",
zsh: "shell",
vue: "vue",
svelte: "svelte",
md: "markdown",
conf: "shell",
ini: "properties",
};
const language = langMap[ext];
return language ? loadLanguage(language) : null;
}
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
@@ -288,6 +246,17 @@ function formatFileSize(bytes?: number, t?: (key: string) => string): string {
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
function PreviewFallback({ label }: { label: string }) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
</div>
);
}
export function FileViewer({
file,
content = "",
@@ -308,39 +277,11 @@ export function FileViewer({
const [forceShowAsText, setForceShowAsText] = useState(false);
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
const [editorFocused, setEditorFocused] = useState(false);
const [imageLoadError, setImageLoadError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [pdfScale, setPdfScale] = useState(1.2);
const [pdfError, setPdfError] = useState(false);
const [markdownEditMode, setMarkdownEditMode] = useState(false);
const editorRef = useRef<{
view?: { dispatch: (transaction: unknown) => void };
} | null>(null);
const editorRef = useRef<CodeEditorHandle | null>(null);
const fileTypeInfo = getFileType(file.name);
const getImageDataUrl = (content: string, fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const mimeTypes: Record<string, string> = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
tiff: "image/tiff",
tif: "image/tiff",
};
const mimeType = mimeTypes[ext] || "image/png";
return `data:${mimeType};base64,${content}`;
};
const WARNING_SIZE = 50 * 1024 * 1024;
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
@@ -467,14 +408,7 @@ export function FileViewer({
<Button
variant="ghost"
size="sm"
onClick={() => {
if (editorRef.current) {
const view = editorRef.current.view;
if (view) {
openSearchPanel(view);
}
}
}}
onClick={() => editorRef.current?.openSearchPanel()}
className="flex items-center gap-2"
title={t("fileManager.searchInFile")}
>
@@ -552,27 +486,35 @@ export function FileViewer({
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.search")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+F
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+F
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.replace")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+H
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+H
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findNext")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
F3
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
F3
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findPrevious")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Shift+F3
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Shift+F3
</KbdKey>
</Kbd>
</div>
</div>
</div>
@@ -583,51 +525,67 @@ export function FileViewer({
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.save")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+S
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+S
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.selectAll")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+A
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+A
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.undo")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Z
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Z
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.redo")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Y
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Y
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.toggleComment")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+/
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+/
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.autoComplete")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Space
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Space
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineUp")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Alt+
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Alt+
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineDown")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Alt+
</kbd>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Alt+
</KbdKey>
</Kbd>
</div>
</div>
</div>
@@ -700,136 +658,32 @@ export function FileViewer({
)}
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full relative">
{imageLoadError ? (
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.imageLoadError")}
</h3>
<p className="text-sm mb-4">{file.name}</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<PhotoProvider maskOpacity={0.7}>
<PhotoView src={getImageDataUrl(content, file.name)}>
<img
src={getImageDataUrl(content, file.name)}
alt={file.name}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: "calc(100vh - 200px)" }}
onLoad={(e) => {
setImageLoading(false);
setImageLoadError(false);
const img = e.currentTarget;
if (
onMediaDimensionsChange &&
img.naturalWidth &&
img.naturalHeight
) {
onMediaDimensionsChange({
width: img.naturalWidth,
height: img.naturalHeight,
});
}
}}
onError={() => {
setImageLoading(false);
setImageLoadError(true);
}}
/>
</PhotoView>
</PhotoProvider>
)}
{imageLoading && !imageLoadError && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
Loading image...
</p>
</div>
</div>
)}
</div>
<Suspense fallback={<PreviewFallback label="Loading image..." />}>
<ImagePreview
content={content}
fileName={file.name}
onDownload={onDownload}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex flex-col">
{isEditable ? (
<CodeMirror
ref={editorRef}
value={editedContent}
onChange={(value) => handleContentChange(value)}
onFocus={() => setEditorFocused(true)}
onBlur={() => setEditorFocused(false)}
extensions={[
...(getLanguageExtension(file.name)
? [getLanguageExtension(file.name)!]
: []),
history(),
search(),
autocompletion(),
keymap.of([
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
{
key: "Mod-/",
run: toggleComment,
preventDefault: true,
},
{
key: "Mod-h",
run: () => {
return false;
},
preventDefault: true,
},
]),
EditorView.theme({
"&": {
height: "100%",
},
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
},
".cm-editor": {
height: "100%",
},
}),
]}
theme={oneDark}
placeholder={t("fileManager.startTyping")}
className="h-full"
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false,
scrollPastEnd: false,
}}
/>
<Suspense
fallback={<PreviewFallback label="Loading editor..." />}
>
<CodeEditor
ref={editorRef}
fileName={file.name}
value={editedContent}
onChange={handleContentChange}
onFocus={() => setEditorFocused(true)}
onBlur={() => setEditorFocused(false)}
placeholder={t("fileManager.startTyping")}
/>
</Suspense>
) : (
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto thin-scrollbar bg-background text-foreground">
{editedContent || content || t("fileManager.fileIsEmpty")}
@@ -967,226 +821,27 @@ export function FileViewer({
<div className="flex-1 overflow-auto thin-scrollbar bg-muted/10">
<div className="p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(
className || "",
);
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
language={match[1]}
PreTag="div"
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
},
h1: ({ children }) => (
<h1 className="text-2xl font-bold mb-4 mt-6 text-foreground border-b border-border pb-2">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold mb-3 mt-5 text-foreground border-b border-border pb-1">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-semibold mb-2 mt-4 text-foreground">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-base font-semibold mb-2 mt-3 text-foreground">
{children}
</h4>
),
p: ({ children }) => (
<p className="mb-3 text-foreground leading-relaxed">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-3 ml-4 list-disc text-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-3 ml-4 list-decimal text-foreground">
{children}
</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="mb-3 overflow-x-auto thin-scrollbar">
<table className="min-w-full border border-border rounded-lg text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted">{children}</thead>
),
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-3 py-2 text-left font-semibold text-foreground">
{children}
</th>
),
td: ({ children }) => (
<td className="px-3 py-2 text-foreground">
{children}
</td>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
),
}}
<Suspense
fallback={
<PreviewFallback label="Loading preview..." />
}
>
{editedContent || "Nothing to preview yet..."}
</ReactMarkdown>
<MarkdownRenderer
compact
content={editedContent || "Nothing to preview yet..."}
/>
</Suspense>
</div>
</div>
</>
) : (
<div className="flex-1 overflow-auto thin-scrollbar p-6">
<div className="max-w-4xl mx-auto">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
language={match[1]}
PreTag="div"
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
},
h1: ({ children }) => (
<h1 className="text-3xl font-bold mb-6 mt-8 text-foreground border-b border-border pb-2">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold mb-4 mt-6 text-foreground border-b border-border pb-1">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold mb-3 mt-4 text-foreground">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold mb-2 mt-3 text-foreground">
{children}
</h4>
),
p: ({ children }) => (
<p className="mb-4 text-foreground leading-relaxed">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-6 list-disc text-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-6 list-decimal text-foreground">
{children}
</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="mb-4 overflow-x-auto thin-scrollbar">
<table className="min-w-full border border-border rounded-lg">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted">{children}</thead>
),
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left font-semibold text-foreground">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2 text-foreground">
{children}
</td>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
),
}}
<Suspense
fallback={<PreviewFallback label="Loading preview..." />}
>
{editedContent}
</ReactMarkdown>
<MarkdownRenderer content={editedContent} />
</Suspense>
</div>
</div>
)}
@@ -1195,202 +850,28 @@ export function FileViewer({
)}
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
<div className="h-full flex flex-col bg-background">
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
disabled={pageNumber <= 1}
>
{t("fileManager.previous")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
{t("fileManager.pageXOfY", {
current: pageNumber,
total: numPages || 0,
})}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
}
disabled={!numPages || pageNumber >= numPages}
>
{t("fileManager.next")}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.max(0.5, pdfScale - 0.2))}
>
{t("fileManager.zoomOut")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border min-w-[80px] text-center">
{Math.round(pdfScale * 100)}%
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.min(3.0, pdfScale + 0.2))}
>
{t("fileManager.zoomIn")}
</Button>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-auto thin-scrollbar p-6 bg-surface">
<div className="flex justify-center">
{pdfError ? (
<div className="text-center text-muted-foreground p-8">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
Cannot load PDF
</h3>
<p className="text-sm mb-4">
There was an error loading this PDF file.
</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<Document
file={`data:application/pdf;base64,${content}`}
onLoadSuccess={({ numPages }) => {
setNumPages(numPages);
setPdfError(false);
if (onMediaDimensionsChange) {
onMediaDimensionsChange({
width: 800,
height: 600,
});
}
}}
onLoadError={(error) => {
console.error("PDF load error:", error);
setPdfError(true);
}}
loading={
<div className="text-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
Loading PDF...
</p>
</div>
}
>
<Page
pageNumber={pageNumber}
scale={pdfScale}
className="shadow-lg"
loading={
<div className="text-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-xs text-muted-foreground">
Loading page...
</p>
</div>
}
/>
</Document>
)}
</div>
</div>
</div>
<Suspense
fallback={<PreviewFallback label="Loading PDF viewer..." />}
>
<PdfPreview
content={content}
onDownload={onDownload}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-2xl">
{(() => {
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const mimeType = (() => {
switch (ext) {
case "mp3":
return "audio/mpeg";
case "wav":
return "audio/wav";
case "flac":
return "audio/flac";
case "ogg":
return "audio/ogg";
case "aac":
return "audio/aac";
case "m4a":
return "audio/mp4";
default:
return "audio/mpeg";
}
})();
const audioUrl = `data:${mimeType};base64,${content}`;
return (
<div className="space-y-4">
<div className="flex justify-center">
<div
className={cn(
"w-32 h-32 rounded-lg bg-gradient-to-br from-pink-100 to-purple-100 flex items-center justify-center shadow-lg",
fileTypeInfo.color,
)}
>
<Music className="w-16 h-16 text-pink-600" />
</div>
</div>
<div className="text-center">
<h3 className="font-semibold text-foreground text-lg mb-1">
{file.name.replace(/\.[^/.]+$/, "")}
</h3>
<p className="text-sm text-muted-foreground">
{ext.toUpperCase()} {formatFileSize(file.size, t)}
</p>
</div>
<div className="rounded-lg overflow-hidden">
<AudioPlayer
src={audioUrl}
onLoadedMetadata={() => {
if (onMediaDimensionsChange) {
onMediaDimensionsChange({
width: 600,
height: 400,
});
}
}}
onError={(e) => {
console.error("Audio playback error:", e);
}}
showJumpControls={false}
showSkipControls={false}
showDownloadProgress={true}
customAdditionalControls={[]}
customVolumeControls={[]}
/>
</div>
</div>
);
})()}
</div>
</div>
<Suspense
fallback={<PreviewFallback label="Loading audio player..." />}
>
<AudioPreview
file={file}
content={content}
color={fileTypeInfo.color}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{fileTypeInfo.type === "unknown" &&
@@ -0,0 +1,112 @@
import React, { useState } from "react";
import { PhotoProvider, PhotoView } from "react-photo-view";
import "react-photo-view/dist/react-photo-view.css";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { useTranslation } from "react-i18next";
interface ImagePreviewProps {
content: string;
fileName: string;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function getImageDataUrl(content: string, fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const mimeTypes: Record<string, string> = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
tiff: "image/tiff",
tif: "image/tiff",
};
const mimeType = mimeTypes[ext] || "image/png";
return `data:${mimeType};base64,${content}`;
}
export function ImagePreview({
content,
fileName,
onDownload,
onMediaDimensionsChange,
}: ImagePreviewProps) {
const { t } = useTranslation();
const [imageLoadError, setImageLoadError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const imageUrl = getImageDataUrl(content, fileName);
return (
<div className="p-6 flex items-center justify-center h-full relative">
{imageLoadError ? (
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.imageLoadError")}
</h3>
<p className="text-sm mb-4">{fileName}</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<PhotoProvider maskOpacity={0.7}>
<PhotoView src={imageUrl}>
<img
src={imageUrl}
alt={fileName}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: "calc(100vh - 200px)" }}
onLoad={(e) => {
setImageLoading(false);
setImageLoadError(false);
const img = e.currentTarget;
if (
onMediaDimensionsChange &&
img.naturalWidth &&
img.naturalHeight
) {
onMediaDimensionsChange({
width: img.naturalWidth,
height: img.naturalHeight,
});
}
}}
onError={() => {
setImageLoading(false);
setImageLoadError(true);
}}
/>
</PhotoView>
</PhotoProvider>
)}
{imageLoading && !imageLoadError && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">Loading image...</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
interface MarkdownRendererProps {
content: string;
compact?: boolean;
}
export function MarkdownRenderer({
content,
compact = false,
}: MarkdownRendererProps) {
const h1Class = compact
? "text-2xl font-bold mb-4 mt-6 text-foreground border-b border-border pb-2"
: "text-3xl font-bold mb-6 mt-8 text-foreground border-b border-border pb-2";
const h2Class = compact
? "text-xl font-semibold mb-3 mt-5 text-foreground border-b border-border pb-1"
: "text-2xl font-semibold mb-4 mt-6 text-foreground border-b border-border pb-1";
const h3Class = compact
? "text-lg font-semibold mb-2 mt-4 text-foreground"
: "text-xl font-semibold mb-3 mt-4 text-foreground";
const h4Class = compact
? "text-base font-semibold mb-2 mt-3 text-foreground"
: "text-lg font-semibold mb-2 mt-3 text-foreground";
const pClass = compact
? "mb-3 text-foreground leading-relaxed"
: "mb-4 text-foreground leading-relaxed";
const listClass = compact
? "mb-3 ml-4 text-foreground"
: "mb-4 ml-6 text-foreground";
const quoteClass = compact
? "border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1"
: "border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2";
const tableWrapClass = compact
? "mb-3 overflow-x-auto thin-scrollbar"
: "mb-4 overflow-x-auto thin-scrollbar";
const tableClass = compact
? "min-w-full border border-border rounded-lg text-sm"
: "min-w-full border border-border rounded-lg";
const thClass = compact
? "px-3 py-2 text-left font-semibold text-foreground"
: "px-4 py-2 text-left font-semibold text-foreground";
const tdClass = compact
? "px-3 py-2 text-foreground"
: "px-4 py-2 text-foreground";
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
language={match[1]}
PreTag="div"
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
},
h1: ({ children }) => <h1 className={h1Class}>{children}</h1>,
h2: ({ children }) => <h2 className={h2Class}>{children}</h2>,
h3: ({ children }) => <h3 className={h3Class}>{children}</h3>,
h4: ({ children }) => <h4 className={h4Class}>{children}</h4>,
p: ({ children }) => <p className={pClass}>{children}</p>,
ul: ({ children }) => (
<ul className={`${listClass} list-disc`}>{children}</ul>
),
ol: ({ children }) => (
<ol className={`${listClass} list-decimal`}>{children}</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className={quoteClass}>{children}</blockquote>
),
table: ({ children }) => (
<div className={tableWrapClass}>
<table className={tableClass}>{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => <th className={thClass}>{children}</th>,
td: ({ children }) => <td className={tdClass}>{children}</td>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
);
}
@@ -0,0 +1,147 @@
import React, { useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { useTranslation } from "react-i18next";
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
interface PdfPreviewProps {
content: string;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
export function PdfPreview({
content,
onDownload,
onMediaDimensionsChange,
}: PdfPreviewProps) {
const { t } = useTranslation();
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [pdfScale, setPdfScale] = useState(1.2);
const [pdfError, setPdfError] = useState(false);
return (
<div className="h-full flex flex-col bg-background">
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
disabled={pageNumber <= 1}
>
{t("fileManager.previous")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
{t("fileManager.pageXOfY", {
current: pageNumber,
total: numPages || 0,
})}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
}
disabled={!numPages || pageNumber >= numPages}
>
{t("fileManager.next")}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.max(0.5, pdfScale - 0.2))}
>
{t("fileManager.zoomOut")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border min-w-[80px] text-center">
{Math.round(pdfScale * 100)}%
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.min(3.0, pdfScale + 0.2))}
>
{t("fileManager.zoomIn")}
</Button>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-auto thin-scrollbar p-6 bg-surface">
<div className="flex justify-center">
{pdfError ? (
<div className="text-center text-muted-foreground p-8">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Cannot load PDF</h3>
<p className="text-sm mb-4">
There was an error loading this PDF file.
</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<Document
file={`data:application/pdf;base64,${content}`}
onLoadSuccess={({ numPages }) => {
setNumPages(numPages);
setPdfError(false);
onMediaDimensionsChange?.({
width: 800,
height: 600,
});
}}
onLoadError={(error) => {
console.error("PDF load error:", error);
setPdfError(true);
}}
loading={
<div className="text-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
Loading PDF...
</p>
</div>
}
>
<Page
pageNumber={pageNumber}
scale={pdfScale}
className="shadow-lg"
loading={
<div className="text-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-xs text-muted-foreground">
Loading page...
</p>
</div>
}
/>
</Document>
)}
</div>
</div>
</div>
);
}
@@ -10,7 +10,6 @@ import {
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Input } from "@/components/ui/input.tsx";
import { useTranslation } from "react-i18next";
import { Shield } from "lucide-react";
@@ -3,6 +3,7 @@ import { GuacamoleDisplay } from "@/ui/desktop/apps/features/guacamole/Guacamole
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
import { getGuacamoleTokenFromHost } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types";
interface GuacamoleAppProps {
hostId?: string;
@@ -46,7 +47,7 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: any;
hostConfig: Pick<SSHHost, "connectionType">;
}
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
@@ -8,7 +8,11 @@ import {
} from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
import { getCookie, isElectron, isEmbeddedMode } from "@/ui/main-axios.ts";
import {
getGuacamoleToken,
isElectron,
isEmbeddedMode,
} from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
@@ -64,7 +68,6 @@ export const GuacamoleDisplay = forwardRef<
const windowFocusedRef = useRef(
typeof document === "undefined" ? true : document.hasFocus(),
);
const [isConnecting, setIsConnecting] = useState(false);
const [isReady, setIsReady] = useState(false);
useImperativeHandle(ref, () => ({
@@ -108,45 +111,35 @@ export const GuacamoleDisplay = forwardRef<
): Promise<string | null> => {
try {
let token: string;
const protocol = connectionConfig.protocol ?? connectionConfig.type;
if (connectionConfig.token) {
token = connectionConfig.token;
} else {
const jwtToken = getCookie("jwt");
if (!jwtToken) {
onError?.("Authentication required");
return null;
}
const baseUrl = isDev
? "http://localhost:30001"
: isElectron()
? (window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001"
: `${window.location.origin}`;
const response = await fetch(`${baseUrl}/guacamole/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwtToken}`,
},
body: JSON.stringify(connectionConfig),
credentials: "include",
const data = await getGuacamoleToken({
protocol: protocol ?? "rdp",
hostname: String(connectionConfig.hostname ?? ""),
port: connectionConfig.port,
username: connectionConfig.username,
password: connectionConfig.password,
domain: connectionConfig.domain,
security:
typeof connectionConfig.security === "string"
? connectionConfig.security
: undefined,
ignoreCert:
typeof connectionConfig.ignoreCert === "boolean"
? connectionConfig.ignoreCert
: undefined,
guacamoleConfig: connectionConfig.guacamoleConfig as Parameters<
typeof getGuacamoleToken
>[0]["guacamoleConfig"],
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || "Failed to get connection token");
}
const data = await response.json();
token = data.token;
}
const width = connectionConfig.width ?? containerWidth ?? 1280;
const height = connectionConfig.height ?? containerHeight ?? 720;
const protocol = connectionConfig.protocol ?? connectionConfig.type;
const dpi = protocol === "rdp" ? (connectionConfig.dpi ?? 96) : null;
const wsBase = isDev
@@ -281,7 +274,6 @@ export const GuacamoleDisplay = forwardRef<
const connect = useCallback(async () => {
if (isConnectingRef.current) return;
isConnectingRef.current = true;
setIsConnecting(true);
setIsReady(false);
let containerWidth = containerRef.current?.clientWidth || 0;
@@ -295,7 +287,6 @@ export const GuacamoleDisplay = forwardRef<
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
if (!wsUrl) {
isConnectingRef.current = false;
setIsConnecting(false);
return;
}
@@ -364,19 +355,16 @@ export const GuacamoleDisplay = forwardRef<
case 0:
break;
case 1:
setIsConnecting(true);
break;
case 2:
break;
case 3:
setIsConnecting(false);
setIsReady(true);
onConnect?.();
break;
case 4:
break;
case 5:
setIsConnecting(false);
setIsReady(false);
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
@@ -387,7 +375,6 @@ export const GuacamoleDisplay = forwardRef<
client.onerror = (error: Guacamole.Status) => {
const errorMessage = error.message || "Connection error";
setIsConnecting(false);
setIsReady(false);
onError?.(errorMessage);
};
@@ -37,18 +37,25 @@ import {
FirewallWidget,
} from "./widgets";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { RefreshCcw, RefreshCw, RefreshCwOff } from "lucide-react";
import { RefreshCw } from "lucide-react";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
import type { LogEntry } from "@/types/connection-log.ts";
interface QuickAction {
name: string;
snippetId: number;
}
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
type ConnectionLogError = Error & {
connectionLogs?: ConnectionLogPayload[];
};
interface HostConfig {
id: number;
name: string;
@@ -62,14 +69,6 @@ interface HostConfig {
[key: string]: unknown;
}
interface TabData {
id: number;
type: string;
title?: string;
hostConfig?: HostConfig;
[key: string]: unknown;
}
interface ServerProps {
hostConfig?: HostConfig;
title?: string;
@@ -92,9 +91,7 @@ function ServerStatsInner({
clearLogs,
isExpanded: isConnectionLogExpanded,
} = useConnectionLog();
const { addTab, tabs, currentTab, removeTab } = useTabs() as {
addTab: (tab: { type: string; [key: string]: unknown }) => number;
tabs: TabData[];
const { currentTab, removeTab } = useTabs() as {
currentTab: number | null;
removeTab: (tabId: number) => void;
};
@@ -419,7 +416,7 @@ function ServerStatsInner({
if (cancelled) return;
if (result?.connectionLogs) {
result.connectionLogs.forEach((log: any) => {
result.connectionLogs.forEach((log) => {
addLog({
type: log.type,
stage: log.stage,
@@ -451,7 +448,7 @@ function ServerStatsInner({
try {
data = await getServerMetricsById(currentHostConfig.id);
break;
} catch (error: any) {
} catch (error: unknown) {
retryCount++;
if (retryCount === 1) {
const initialDelay = totpVerified ? 3000 : 5000;
@@ -492,14 +489,15 @@ function ServerStatsInner({
}
}
}, statsConfig.metricsInterval * 1000);
} catch (error: any) {
} catch (error: unknown) {
if (!cancelled) {
const logError = error as ConnectionLogError;
console.error("Failed to start metrics polling:", error);
setIsLoadingMetrics(false);
setHasConnectionError(true);
if (error?.connectionLogs) {
error.connectionLogs.forEach((log: any) => {
if (logError.connectionLogs) {
logError.connectionLogs.forEach((log) => {
addLog({
type: log.type,
stage: log.stage,
@@ -511,7 +509,10 @@ function ServerStatsInner({
addLog({
type: "error",
stage: "connection",
message: error?.message || t("serverStats.connectionFailed"),
message:
error instanceof Error
? error.message
: t("serverStats.connectionFailed"),
});
}
}
@@ -566,15 +567,6 @@ function ServerStatsInner({
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
const bottomMarginPx = 8;
const isFileManagerAlreadyOpen = React.useMemo(() => {
if (!currentHostConfig) return false;
return tabs.some(
(tab: TabData) =>
tab.type === "file_manager" &&
tab.hostConfig?.id === currentHostConfig.id,
);
}, [tabs, currentHostConfig]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
@@ -775,7 +767,7 @@ function ServerStatsInner({
},
);
}
} catch (error: any) {
} catch (error: unknown) {
toast.error(
t("serverStats.quickActionError", {
name: action.name,
@@ -783,7 +775,9 @@ function ServerStatsInner({
{
id: `quick-action-${action.snippetId}`,
description:
error?.message || "Unknown error",
error instanceof Error
? error.message
: "Unknown error",
duration: 5000,
},
);
+111 -203
View File
@@ -27,6 +27,7 @@ import {
} from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/desktop/navigation/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/dialogs/SSHAuthDialog.tsx";
import { PassphraseDialog } from "@/ui/desktop/navigation/dialogs/PassphraseDialog.tsx";
import { WarpgateDialog } from "@/ui/desktop/navigation/dialogs/WarpgateDialog.tsx";
import { OPKSSHDialog } from "@/ui/desktop/navigation/dialogs/OPKSSHDialog.tsx";
import { HostKeyVerificationDialog } from "@/ui/desktop/navigation/dialogs/HostKeyVerificationDialog.tsx";
@@ -40,7 +41,6 @@ import type { TerminalConfig } from "@/types";
import { useTheme } from "@/components/theme-provider.tsx";
import { useCommandTracker } from "@/ui/hooks/useCommandTracker.ts";
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory.ts";
import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
@@ -78,6 +78,11 @@ interface TerminalHandle {
refresh: () => void;
}
type HostKeyVerificationData = Omit<
React.ComponentProps<typeof HostKeyVerificationDialog>,
"isOpen" | "scenario" | "onAccept" | "onReject" | "backgroundColor"
>;
interface SSHTerminalProps {
hostConfig: HostConfig;
isVisible: boolean;
@@ -88,112 +93,10 @@ interface SSHTerminalProps {
onTitleChange?: (title: string) => void;
initialPath?: string;
executeCommand?: string;
onOpenFileManager?: () => void;
onOpenFileManager?: (path?: string) => void;
previewTheme?: string | null;
}
function TerminalContextMenu({
x,
y,
hasSelection,
showCopyPaste,
showOpenFileManager,
onCopy,
onPaste,
onOpenFileManager,
onClose,
}: {
x: number;
y: number;
hasSelection: boolean;
showCopyPaste: boolean;
showOpenFileManager: boolean;
onCopy: () => void;
onPaste: () => void;
onOpenFileManager: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const menuX = x + 180 > window.innerWidth ? window.innerWidth - 190 : x;
const menuY = y + 150 > window.innerHeight ? window.innerHeight - 160 : y;
useEffect(() => {
let cleanup: (() => void) | null = null;
const timeoutId = setTimeout(() => {
const handleClose = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Element)) onClose();
};
const handleRightClick = (e: MouseEvent) => {
e.preventDefault();
onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClose, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKey);
window.addEventListener("blur", onClose);
cleanup = () => {
document.removeEventListener("mousedown", handleClose, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKey);
window.removeEventListener("blur", onClose);
};
}, 50);
return () => {
clearTimeout(timeoutId);
cleanup?.();
};
}, [onClose]);
const items: { label: string; action: () => void; disabled?: boolean }[] = [];
if (showCopyPaste) {
items.push(
{ label: t("terminal.copy"), action: onCopy, disabled: !hasSelection },
{ label: t("terminal.paste"), action: onPaste },
);
}
if (showOpenFileManager) {
items.push({
label: t("terminal.openFileManagerHere"),
action: onOpenFileManager,
});
}
return (
<>
<div className="fixed inset-0 z-[99990]" />
<div
ref={menuRef}
className="fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] z-[99995] overflow-hidden"
style={{ left: menuX, top: menuY }}
>
{items.map((item, i) => (
<button
key={i}
className={`w-full px-3 py-2 text-left text-sm flex items-center hover:bg-hover transition-colors first:rounded-t-lg last:rounded-b-lg ${item.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
disabled={item.disabled}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
>
{item.label}
</button>
))}
</div>
</>
);
}
const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
function SSHTerminal(
{
@@ -201,7 +104,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
isVisible,
splitScreen = false,
onClose,
onTitleChange,
initialPath,
executeCommand,
onOpenFileManager,
@@ -209,16 +111,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
},
ref,
) {
if (
typeof window !== "undefined" &&
!(window as { testJWT?: () => string | null }).testJWT
) {
(window as { testJWT?: () => string | null }).testJWT = () => {
const jwt = getCookie("jwt");
return jwt;
};
}
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const commandHistoryContext = useCommandHistory();
@@ -288,6 +180,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const [authDialogReason, setAuthDialogReason] = useState<
"no_keyboard" | "auth_failed" | "timeout"
>("no_keyboard");
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
useState(false);
const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false);
@@ -305,19 +198,14 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} | null>(null);
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
hasSelection: boolean;
} | null>(null);
const opksshFailedRef = useRef(false);
const currentHostIdRef = useRef<number | null>(null);
const currentHostConfigRef = useRef<any>(null);
const currentHostConfigRef = useRef<HostConfig | null>(null);
const [hostKeyVerification, setHostKeyVerification] = useState<{
isOpen: boolean;
scenario: "new" | "changed";
data: any;
data: HostKeyVerificationData;
} | null>(null);
const sessionIdRef = useRef<string | null>(null);
@@ -344,6 +232,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const isReconnectingRef = useRef(false);
const isConnectingRef = useRef(false);
const wasConnectedRef = useRef(false);
const closeAfterDisconnectRef = useRef(false);
useEffect(() => {
isUnmountingRef.current = false;
@@ -353,6 +242,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
reconnectAttempts.current = 0;
wasConnectedRef.current = false;
isAttachingSessionRef.current = false;
closeAfterDisconnectRef.current = false;
return () => {};
}, [hostConfig.id]);
@@ -360,7 +250,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const totpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activityLoggedRef = useRef(false);
const keyHandlerAttachedRef = useRef(false);
const [commandHistoryTrackingEnabled, setCommandHistoryTrackingEnabled] =
useState<boolean>(
() => localStorage.getItem("commandHistoryTracking") === "true",
@@ -425,9 +314,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const autocompleteSuggestionsRef = useRef<string[]>([]);
const autocompleteSelectedIndexRef = useRef(0);
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [showHistoryDialog] = useState(false);
const [, setCommandHistory] = useState<string[]>([]);
const [, setIsLoadingHistory] = useState(false);
const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading);
const setCommandHistoryContextRef = useRef(
@@ -535,12 +424,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
useEffect(() => {
const checkAuth = () => {
const jwtToken = getCookie("jwt");
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
setIsAuthenticated((prev) => {
if (prev !== isAuth) {
return isAuth;
if (!prev) {
return true;
}
return prev;
});
@@ -703,6 +589,32 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (onClose) onClose();
}
function handlePassphraseSubmit(passphrase: string) {
if (webSocketRef.current && terminal) {
webSocketRef.current.send(
JSON.stringify({
type: "reconnect_with_credentials",
data: {
cols: terminal.cols,
rows: terminal.rows,
keyPassword: passphrase,
hostConfig: {
...hostConfig,
keyPassword: passphrase,
},
},
}),
);
setShowPassphraseDialog(false);
setIsConnecting(true);
}
}
function handlePassphraseCancel() {
setShowPassphraseDialog(false);
if (onClose) onClose();
}
function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = { cols, rows };
@@ -802,12 +714,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
},
refresh: () => hardRefresh(),
openFileManager: () => {
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({ type: "get_cwd" }));
} else {
onOpenFileManager?.("/");
}
},
}),
[terminal],
);
function getUseRightClickCopyPaste() {
return getCookie("rightClickCopyPaste") === "true";
return getCookie("rightClickCopyPaste") !== "false";
}
function attemptReconnection() {
@@ -873,21 +792,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return;
}
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
console.warn("Reconnection cancelled - no authentication token");
isReconnectingRef.current = false;
updateConnectionError(t("terminal.authenticationRequired"));
setIsConnecting(false);
shouldNotReconnectRef.current = true;
addLog({
type: "error",
stage: "auth",
message: t("terminal.authenticationRequired"),
});
return;
}
if (terminal && hostConfig) {
if (!isAttachingSessionRef.current) {
terminal.clear();
@@ -922,17 +826,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
window.location.port === "5173" ||
window.location.port === "");
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
console.error("No JWT token available for WebSocket connection");
setIsConnected(false);
setIsConnecting(false);
updateConnectionError("Authentication required");
isConnectingRef.current = false;
return;
}
let baseWsUrl: string;
if (isDev) {
@@ -1053,7 +946,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const savedSessionId = persistenceEnabled
? localStorage.getItem(`termix_session_${tabId}`)
: null;
if (savedSessionId && !isReconnectingRef.current) {
if (savedSessionId) {
sessionIdRef.current = savedSessionId;
isAttachingSessionRef.current = true;
@@ -1304,13 +1197,27 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
);
}
}, 100);
} else if (msg.type === "session_ended") {
wasDisconnectedBySSH.current = true;
setIsConnected(false);
setIsConnecting(false);
shouldNotReconnectRef.current = true;
if (onClose) {
onClose();
}
} else if (msg.type === "disconnected") {
wasDisconnectedBySSH.current = true;
shouldNotReconnectRef.current = true;
setIsConnected(false);
setIsConnecting(false);
if (wasConnectedRef.current) {
wasConnectedRef.current = false;
setShowDisconnectedOverlay(true);
setShowDisconnectedOverlay(false);
if (onClose && !closeAfterDisconnectRef.current) {
closeAfterDisconnectRef.current = true;
isUnmountingRef.current = true;
window.setTimeout(onClose, 0);
}
} else if (!connectionErrorRef.current) {
updateConnectionError(
msg.message || t("terminal.connectionRejected"),
@@ -1334,6 +1241,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}, 180000);
} else if (msg.type === "totp_retry") {
// Existing prompt remains visible while the backend asks for another code.
} else if (msg.type === "password_required") {
setTotpRequired(true);
setTotpPrompt(msg.prompt || t("common.password"));
@@ -1502,6 +1410,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
} else if (msg.type === "cwd") {
onOpenFileManager?.(msg.path as string);
} else if (msg.type === "passphrase_required") {
setShowPassphraseDialog(true);
setIsConnecting(false);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
} else if (msg.type === "host_key_verification_required") {
setHostKeyVerification({
isOpen: true,
@@ -1691,8 +1608,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsConnecting(false);
shouldNotReconnectRef.current = true;
localStorage.removeItem("jwt");
return;
}
@@ -1752,6 +1667,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
async function writeTextToClipboard(text: string): Promise<boolean> {
try {
if (window.electronClipboard) {
await window.electronClipboard.writeText(text);
return true;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
@@ -1778,6 +1697,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
async function readTextFromClipboard(): Promise<string> {
try {
if (window.electronClipboard) {
return window.electronClipboard.readText();
}
if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText();
}
@@ -1870,7 +1792,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const config = {
...DEFAULT_TERMINAL_CONFIG,
...(hostConfig.terminalConfig as any),
...hostConfig.terminalConfig,
};
let themeColors;
@@ -1954,7 +1876,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const config = {
...DEFAULT_TERMINAL_CONFIG,
...(hostConfig.terminalConfig as any),
...hostConfig.terminalConfig,
};
const fontConfig = TERMINAL_FONTS.find(
@@ -2049,29 +1971,26 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const element = xtermRef.current;
const handleContextMenu = (e: MouseEvent) => {
if (getUseRightClickCopyPaste()) {
if (e.ctrlKey && onOpenFileManager) {
e.preventDefault();
e.stopPropagation();
onOpenFileManager();
return;
}
if (isElectron() && getUseRightClickCopyPaste()) {
e.preventDefault();
e.stopPropagation();
if (terminal.hasSelection()) {
const text = terminal.getSelection();
navigator.clipboard
.writeText(text)
.then(() => terminal.clearSelection());
writeTextToClipboard(text).then(() => terminal.clearSelection());
} else {
navigator.clipboard.readText().then((text) => {
readTextFromClipboard().then((text) => {
if (text) terminal.paste(text);
});
}
return;
}
if (!onOpenFileManager) return;
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
hasSelection: terminal.hasSelection(),
});
};
element?.addEventListener("contextmenu", handleContextMenu);
@@ -2111,7 +2030,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const config = {
...DEFAULT_TERMINAL_CONFIG,
...(hostConfig.terminalConfig as any),
...hostConfig.terminalConfig,
};
if (config.backspaceMode !== "control-h") return;
@@ -2236,8 +2155,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (
((e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) ||
(e.metaKey && !e.ctrlKey && !e.altKey) ||
((e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey) ||
(e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
@@ -2629,6 +2547,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor}
/>
<PassphraseDialog
isOpen={showPassphraseDialog}
onSubmit={handlePassphraseSubmit}
onCancel={handlePassphraseCancel}
hostInfo={{
ip: hostConfig.ip,
port: hostConfig.port,
username: hostConfig.username,
name: hostConfig.name,
}}
backgroundColor={backgroundColor}
/>
<WarpgateDialog
isOpen={warpgateAuthRequired}
url={warpgateAuthUrl}
@@ -2764,29 +2695,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
position={autocompletePosition}
onSelect={handleAutocompleteSelect}
/>
{contextMenu && (
<TerminalContextMenu
x={contextMenu.x}
y={contextMenu.y}
hasSelection={contextMenu.hasSelection}
showCopyPaste={getUseRightClickCopyPaste()}
showOpenFileManager={!!onOpenFileManager}
onCopy={async () => {
const selection = terminal?.getSelection();
if (selection) {
await writeTextToClipboard(selection);
terminal?.clearSelection();
}
}}
onPaste={async () => {
const text = await readTextFromClipboard();
if (text) terminal?.paste(text);
}}
onOpenFileManager={() => onOpenFileManager?.()}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
},
@@ -1,4 +1,3 @@
import type { TerminalTheme } from "@/constants/terminal-themes.ts";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
+14 -14
View File
@@ -1,9 +1,8 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { TunnelViewer } from "@/ui/desktop/apps/features/tunnel/TunnelViewer.tsx";
import {
getSSHHosts,
getTunnelStatuses,
subscribeTunnelStatuses,
connectTunnel,
disconnectTunnel,
cancelTunnel,
@@ -17,7 +16,6 @@ import type {
} from "../../../types/index.js";
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
const { t } = useTranslation();
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<
@@ -110,11 +108,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
}
};
const fetchTunnelStatuses = useCallback(async () => {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
}, []);
useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 5000);
@@ -137,10 +130,10 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
}, [fetchHosts]);
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 1000);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
return subscribeTunnelStatuses(setTunnelStatuses, () => {
// The view remains usable if the stream reconnects or is unavailable.
});
}, []);
useEffect(() => {
if (visibleHosts.length > 0 && visibleHosts[0]) {
@@ -168,6 +161,15 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
const tunnelConfig = {
name: tunnelName,
scope: tunnel.scope || "s2s",
mode: tunnel.mode || tunnel.tunnelType || "remote",
bindHost: tunnel.bindHost,
targetHost: tunnel.targetHost,
tunnelType:
tunnel.tunnelType ||
(tunnel.mode === "local" || tunnel.mode === "remote"
? tunnel.mode
: "remote"),
sourceHostId: host.id,
tunnelIndex: tunnelIndex,
hostName: host.name || `${host.username}@${host.ip}`,
@@ -221,8 +223,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
} else if (action === "cancel") {
await cancelTunnel(tunnelName);
}
await fetchTunnelStatuses();
} catch (error) {
console.error("Tunnel action failed:", {
action,
@@ -0,0 +1,156 @@
import { Button } from "@/components/ui/button.tsx";
import type { TunnelStatus } from "@/types/index.js";
import {
AlertCircle,
Loader2,
Play,
Square,
Wifi,
WifiOff,
} from "lucide-react";
import { useTranslation } from "react-i18next";
type TunnelInlineControlsProps = {
status?: TunnelStatus;
loading?: boolean;
onStart?: () => void;
onStop?: () => void;
startDisabled?: boolean;
startDisabledReason?: string;
};
function getStatusKind(status?: TunnelStatus) {
const value = status?.status?.toUpperCase() || "DISCONNECTED";
if (value === "CONNECTED") return "connected";
if (value === "ERROR" || value === "FAILED") return "error";
if (
value === "CONNECTING" ||
value === "DISCONNECTING" ||
value === "RETRYING" ||
value === "WAITING"
) {
return "connecting";
}
return "disconnected";
}
function getStatusTitle(
status: TunnelStatus | undefined,
statusText: string,
t: ReturnType<typeof useTranslation>["t"],
) {
if (!status) return statusText;
const details = [];
if (status.reason) details.push(status.reason);
if (status.retryCount && status.maxRetries) {
details.push(
t("tunnels.attempt", {
current: status.retryCount,
max: status.maxRetries,
}),
);
}
if (status.nextRetryIn) {
details.push(
t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
}),
);
}
if (status.errorType && !status.reason) details.push(status.errorType);
return details.length > 0 ? details.join("\n") : statusText;
}
export function TunnelInlineControls({
status,
loading = false,
onStart,
onStop,
startDisabled,
startDisabledReason,
}: TunnelInlineControlsProps) {
const { t } = useTranslation();
const kind = getStatusKind(status);
const isDisconnected = kind === "disconnected";
const statusText =
kind === "connected"
? t("tunnels.connected")
: kind === "connecting"
? t("tunnels.connecting")
: kind === "error"
? t("tunnels.error")
: t("tunnels.disconnected");
const title = getStatusTitle(status, statusText, t);
const statusClass =
kind === "connected"
? "text-green-600 dark:text-green-400 bg-green-500/10 border-green-500/20"
: kind === "connecting"
? "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20"
: kind === "error"
? "text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20"
: "text-muted-foreground bg-muted/30 border-border";
const statusIcon =
kind === "connected" ? (
<Wifi className="h-3 w-3" />
) : kind === "connecting" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : kind === "error" ? (
<AlertCircle className="h-3 w-3" />
) : (
<WifiOff className="h-3 w-3" />
);
return (
<div className="flex flex-wrap items-center justify-end gap-2">
<span
className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium ${statusClass}`}
title={title}
>
{statusIcon}
{statusText}
</span>
{loading ? (
<Button
type="button"
size="sm"
variant="outline"
disabled
className="h-8 px-3 text-xs text-muted-foreground border-border"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isDisconnected ? t("tunnels.start") : t("tunnels.stop")}
</Button>
) : isDisconnected ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onStart}
disabled={startDisabled}
title={startDisabled ? startDisabledReason : undefined}
className="h-8 px-3 text-xs text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.start")}
</Button>
) : (
<Button
type="button"
size="sm"
variant="outline"
onClick={onStop}
className="h-8 px-3 text-xs text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.stop")}
</Button>
)}
</div>
);
}
@@ -1,9 +1,11 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Tunnel } from "@/ui/desktop/apps/features/tunnel/Tunnel.tsx";
import { useTranslation } from "react-i18next";
import { getSSHHosts } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
interface HostConfig {
id: number;
@@ -33,7 +35,29 @@ export function TunnelManager({
}: TunnelManagerProps): React.ReactElement {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const { tabs, addTab, setCurrentTab, updateTab } = useTabs();
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const isElectron =
typeof window !== "undefined" && window.electronAPI?.isElectron === true;
const openC2SPresets = React.useCallback(() => {
const profileTab = tabs.find((tab) => tab.type === "user_profile");
if (profileTab) {
updateTab(profileTab.id, {
initialTab: "c2s-tunnels",
_updateTimestamp: Date.now(),
});
setCurrentTab(profileTab.id);
return;
}
const id = addTab({
type: "user_profile",
title: t("profile.title"),
initialTab: "c2s-tunnels",
});
setCurrentTab(id);
}, [addTab, setCurrentTab, t, tabs, updateTab]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
@@ -107,6 +131,11 @@ export function TunnelManager({
</h1>
</div>
</div>
{isElectron && (
<Button size="sm" variant="outline" onClick={openC2SPresets}>
{t("tunnels.manageClientTunnels")}
</Button>
)}
</div>
<Separator className="p-0.25 w-full" />
@@ -127,10 +156,10 @@ export function TunnelManager({
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-foreground-subtle text-lg">
{t("tunnel.noTunnelsConfigured")}
{t("tunnels.noTunnelsConfigured")}
</p>
<p className="text-foreground-subtle text-sm mt-2">
{t("tunnel.configureTunnelsInHostSettings")}
{t("tunnels.configureTunnelsInHostSettings")}
</p>
</div>
</div>
@@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next";
import type { TunnelMode } from "@/types/index.js";
type TunnelModeSelectorProps = {
mode: TunnelMode;
scope: "client" | "server";
onChange: (mode: TunnelMode) => void;
};
export function TunnelModeSelector({
mode,
scope,
onChange,
}: TunnelModeSelectorProps) {
const { t } = useTranslation();
const options: Array<{
value: TunnelMode;
label: string;
description: string;
}> = [
{
value: "local",
label: t("tunnels.typeLocal"),
description:
scope === "client"
? t("tunnels.typeClientLocalDesc")
: t("tunnels.typeServerLocalDesc"),
},
{
value: "remote",
label: t("tunnels.typeRemote"),
description:
scope === "client"
? t("tunnels.typeClientRemoteDesc")
: t("tunnels.typeServerRemoteDesc"),
},
{
value: "dynamic",
label: t("tunnels.typeDynamic"),
description:
scope === "client"
? t("tunnels.typeClientDynamicDesc")
: t("tunnels.typeDynamicDesc"),
},
];
return (
<div className="grid gap-3 lg:grid-cols-3">
{options.map((option) => (
<label
key={option.value}
className="flex items-start gap-3 rounded-md border bg-card p-3 cursor-pointer"
>
<input
type="radio"
value={option.value}
checked={mode === option.value}
onChange={() => onChange(option.value)}
className="mt-0.5 w-4 h-4 text-primary border-input focus:ring-ring"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{option.label}</span>
<span className="text-xs text-muted-foreground">
{option.description}
</span>
</div>
</label>
))}
</div>
);
}
@@ -172,7 +172,7 @@ export function TunnelObject({
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.disconnect")}
{t("tunnels.stop")}
</Button>
</>
) : isRetrying || isWaiting ? (
@@ -198,7 +198,7 @@ export function TunnelObject({
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.connect")}
{t("tunnels.start")}
</Button>
)}
</div>
@@ -406,7 +406,7 @@ export function TunnelObject({
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.disconnect")}
{t("tunnels.stop")}
</Button>
</>
) : isRetrying || isWaiting ? (
@@ -432,7 +432,7 @@ export function TunnelObject({
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.connect")}
{t("tunnels.start")}
</Button>
)}
</div>
@@ -0,0 +1,62 @@
import type { TunnelMode } from "@/types/index.js";
type Translate = (
key: string,
options?: Record<string, string | number>,
) => string;
export function getTunnelTypeForMode(mode: TunnelMode): "local" | "remote" {
return mode === "remote" ? "remote" : "local";
}
export function getTunnelPortLabels(
scope: "client" | "server",
mode: TunnelMode,
t: Translate,
) {
if (scope === "client") {
return {
sourcePortLabel:
mode === "remote" ? t("tunnels.remotePort") : t("tunnels.localPort"),
endpointPortLabel:
mode === "remote" ? t("tunnels.localPort") : t("tunnels.remotePort"),
};
}
return {
sourcePortLabel: t("tunnels.currentHostPort"),
endpointPortLabel: t("tunnels.endpointPort"),
};
}
export function getTunnelModeDescription(
scope: "client" | "server",
mode: TunnelMode,
ports: {
sourcePort: string | number;
endpointPort: string | number;
},
t: Translate,
) {
if (scope === "client") {
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionClientDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionClientLocal", ports);
}
return t("tunnels.forwardDescriptionClientRemote", ports);
}
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionServerDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionServerLocal", ports);
}
return t("tunnels.forwardDescriptionServerRemote", ports);
}
@@ -95,6 +95,7 @@ export function CredentialEditor({
setFolders(uniqueFolders);
} catch {
// Keep the editor usable even if credentials cannot be loaded.
} finally {
setLoading(false);
}
@@ -92,15 +92,6 @@ export function CredentialSelector({
setSearchQuery("");
};
const handleClear = () => {
onValueChange(null);
if (onCredentialSelect) {
onCredentialSelect(null);
}
setDropdownOpen(false);
setSearchQuery("");
};
return (
<FormItem>
<FormLabel>{t("hosts.selectCredential")}</FormLabel>
@@ -48,6 +48,7 @@ import {
Terminal,
Copy,
Plus,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils.ts";
import {
@@ -97,6 +98,7 @@ export function CredentialsManager({
>([]);
const [selectedHostId, setSelectedHostId] = useState<string>("");
const [deployLoading, setDeployLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [hostComboboxOpen, setHostComboboxOpen] = useState(false);
const [showCopyCommandDialog, setShowCopyCommandDialog] = useState(false);
const [copyCommandCredential, setCopyCommandCredential] =
@@ -124,16 +126,29 @@ export function CredentialsManager({
}
};
const fetchCredentials = async () => {
const fetchCredentials = async (options: { showLoading?: boolean } = {}) => {
const { showLoading = true } = options;
try {
setLoading(true);
if (showLoading) setLoading(true);
const data = await getCredentials();
setCredentials(data);
setError(null);
} catch {
setError(t("credentials.failedToFetchCredentials"));
} finally {
setLoading(false);
if (showLoading) setLoading(false);
}
};
const handleRefreshCredentials = async () => {
setRefreshing(true);
try {
await Promise.all([
fetchCredentials({ showLoading: false }),
fetchHosts(),
]);
} finally {
setRefreshing(false);
}
};
@@ -471,7 +486,15 @@ export function CredentialsManager({
</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={fetchCredentials} variant="outline" size="sm">
<Button
onClick={handleRefreshCredentials}
variant="outline"
size="sm"
disabled={refreshing}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`}
/>
{t("credentials.refresh")}
</Button>
</div>
@@ -523,7 +546,15 @@ export function CredentialsManager({
</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={fetchCredentials} variant="outline" size="sm">
<Button
onClick={handleRefreshCredentials}
variant="outline"
size="sm"
disabled={refreshing}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`}
/>
{t("credentials.refresh")}
</Button>
</div>

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