chore: cleanup files (possible RC)

This commit is contained in:
LukeGus
2025-12-29 02:46:52 -06:00
parent 7c850c1072
commit dcbc9454ab
123 changed files with 3521 additions and 4844 deletions

View File

@@ -345,7 +345,33 @@ jobs:
continue-on-error: true continue-on-error: true
create-pr: create-pr:
needs: [translate-zh, translate-ru, translate-pt, translate-fr, translate-es, translate-de, translate-hi, translate-bn, translate-ja, translate-vi, translate-tr, translate-ko, translate-it, translate-he, translate-ar, translate-pl, translate-nl, translate-sv, translate-id, translate-th, translate-uk, translate-cs, translate-ro, translate-el] needs:
[
translate-zh,
translate-ru,
translate-pt,
translate-fr,
translate-es,
translate-de,
translate-hi,
translate-bn,
translate-ja,
translate-vi,
translate-tr,
translate-ko,
translate-it,
translate-he,
translate-ar,
translate-pl,
translate-nl,
translate-sv,
translate-id,
translate-th,
translate-uk,
translate-cs,
translate-ro,
translate-el,
]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -11,10 +11,8 @@ const fs = require("fs");
const os = require("os"); const os = require("os");
if (process.platform === "linux") { if (process.platform === "linux") {
// Enable Ozone platform auto-detection for Wayland/X11 support
app.commandLine.appendSwitch("--ozone-platform-hint=auto"); app.commandLine.appendSwitch("--ozone-platform-hint=auto");
// Enable hardware video decoding if available
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder"); app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
} }

View File

@@ -2,21 +2,6 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"), getAppVersion: () => ipcRenderer.invoke("get-app-version"),
getPlatform: () => ipcRenderer.invoke("get-platform"),
checkElectronUpdate: () => ipcRenderer.invoke("check-electron-update"),
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
saveServerConfig: (config) =>
ipcRenderer.invoke("save-server-config", config),
testServerConnection: (serverUrl) =>
ipcRenderer.invoke("test-server-connection", serverUrl),
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
onUpdateDownloaded: (callback) =>
ipcRenderer.on("update-downloaded", callback),
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true, isElectron: true,

View File

@@ -15,7 +15,7 @@ const authManager = AuthManager.getInstance();
const serverStartTime = Date.now(); const serverStartTime = Date.now();
const activityRateLimiter = new Map<string, number>(); const activityRateLimiter = new Map<string, number>();
const RATE_LIMIT_MS = 1000; // 1 second window const RATE_LIMIT_MS = 1000;
app.use( app.use(
cors({ cors({

View File

@@ -578,7 +578,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "notes", "TEXT"); addColumnIfNotExists("ssh_data", "notes", "TEXT");
// SOCKS5 Proxy columns
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER"); addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
addColumnIfNotExists("ssh_data", "socks5_host", "TEXT"); addColumnIfNotExists("ssh_data", "socks5_host", "TEXT");
addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER"); addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER");
@@ -590,7 +589,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
// System-encrypted fields for offline credential sharing
addColumnIfNotExists("ssh_credentials", "system_password", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_password", "TEXT");
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
@@ -655,7 +653,6 @@ const migrateSchema = () => {
} }
} }
// RBAC Phase 1: Host Access table
try { try {
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get(); sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
} catch { } catch {
@@ -678,9 +675,6 @@ const migrateSchema = () => {
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
); );
`); `);
databaseLogger.info("Created host_access table", {
operation: "schema_migration",
});
} catch (createError) { } catch (createError) {
databaseLogger.warn("Failed to create host_access table", { databaseLogger.warn("Failed to create host_access table", {
operation: "schema_migration", operation: "schema_migration",
@@ -689,15 +683,11 @@ const migrateSchema = () => {
} }
} }
// Migration: Add role_id column to existing host_access table
try { try {
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get(); sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
} catch { } catch {
try { try {
sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE"); sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE");
databaseLogger.info("Added role_id column to host_access table", {
operation: "schema_migration",
});
} catch (alterError) { } catch (alterError) {
databaseLogger.warn("Failed to add role_id column", { databaseLogger.warn("Failed to add role_id column", {
operation: "schema_migration", operation: "schema_migration",
@@ -706,15 +696,11 @@ const migrateSchema = () => {
} }
} }
// Migration: Add sudo_password column to ssh_data table
try { try {
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get(); sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
} catch { } catch {
try { try {
sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sudo_password TEXT"); sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sudo_password TEXT");
databaseLogger.info("Added sudo_password column to ssh_data table", {
operation: "schema_migration",
});
} catch (alterError) { } catch (alterError) {
databaseLogger.warn("Failed to add sudo_password column", { databaseLogger.warn("Failed to add sudo_password column", {
operation: "schema_migration", operation: "schema_migration",
@@ -723,7 +709,6 @@ const migrateSchema = () => {
} }
} }
// RBAC Phase 2: Roles tables
try { try {
sqlite.prepare("SELECT id FROM roles LIMIT 1").get(); sqlite.prepare("SELECT id FROM roles LIMIT 1").get();
} catch { } catch {
@@ -740,9 +725,6 @@ const migrateSchema = () => {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
`); `);
databaseLogger.info("Created roles table", {
operation: "schema_migration",
});
} catch (createError) { } catch (createError) {
databaseLogger.warn("Failed to create roles table", { databaseLogger.warn("Failed to create roles table", {
operation: "schema_migration", operation: "schema_migration",
@@ -768,9 +750,6 @@ const migrateSchema = () => {
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
); );
`); `);
databaseLogger.info("Created user_roles table", {
operation: "schema_migration",
});
} catch (createError) { } catch (createError) {
databaseLogger.warn("Failed to create user_roles table", { databaseLogger.warn("Failed to create user_roles table", {
operation: "schema_migration", operation: "schema_migration",
@@ -779,7 +758,6 @@ const migrateSchema = () => {
} }
} }
// RBAC Phase 3: Audit logging tables
try { try {
sqlite.prepare("SELECT id FROM audit_logs LIMIT 1").get(); sqlite.prepare("SELECT id FROM audit_logs LIMIT 1").get();
} catch { } catch {
@@ -802,9 +780,6 @@ const migrateSchema = () => {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
); );
`); `);
databaseLogger.info("Created audit_logs table", {
operation: "schema_migration",
});
} catch (createError) { } catch (createError) {
databaseLogger.warn("Failed to create audit_logs table", { databaseLogger.warn("Failed to create audit_logs table", {
operation: "schema_migration", operation: "schema_migration",
@@ -836,9 +811,6 @@ const migrateSchema = () => {
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
); );
`); `);
databaseLogger.info("Created session_recordings table", {
operation: "schema_migration",
});
} catch (createError) { } catch (createError) {
databaseLogger.warn("Failed to create session_recordings table", { databaseLogger.warn("Failed to create session_recordings table", {
operation: "schema_migration", operation: "schema_migration",
@@ -847,7 +819,6 @@ const migrateSchema = () => {
} }
} }
// RBAC: Shared Credentials table
try { try {
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get(); sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
} catch { } catch {
@@ -872,9 +843,6 @@ const migrateSchema = () => {
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
); );
`); `);
databaseLogger.info("Created shared_credentials table", {
operation: "schema_migration",
});
} catch (createError) { } catch (createError) {
databaseLogger.warn("Failed to create shared_credentials table", { databaseLogger.warn("Failed to create shared_credentials table", {
operation: "schema_migration", operation: "schema_migration",
@@ -883,51 +851,31 @@ const migrateSchema = () => {
} }
} }
// Clean up old system roles and seed correct ones
try { try {
// First, check what roles exist
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>; const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
databaseLogger.info("Current roles in database", {
operation: "schema_migration",
roles: existingRoles,
});
// Migration: Remove ALL old unwanted roles (system or not) and keep only admin and user
try { try {
const validSystemRoles = ['admin', 'user']; const validSystemRoles = ['admin', 'user'];
const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member']; const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member'];
let deletedCount = 0; let deletedCount = 0;
// First delete known unwanted role names
const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?"); const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?");
for (const roleName of unwantedRoleNames) { for (const roleName of unwantedRoleNames) {
const result = deleteByName.run(roleName); const result = deleteByName.run(roleName);
if (result.changes > 0) { if (result.changes > 0) {
deletedCount += result.changes; deletedCount += result.changes;
databaseLogger.info(`Deleted role by name: ${roleName}`, {
operation: "schema_migration",
});
} }
} }
// Then delete any system roles that are not admin or user
const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1"); const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1");
for (const role of existingRoles) { for (const role of existingRoles) {
if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) { if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) {
const result = deleteOldSystemRole.run(role.name); const result = deleteOldSystemRole.run(role.name);
if (result.changes > 0) { if (result.changes > 0) {
deletedCount += result.changes; deletedCount += result.changes;
databaseLogger.info(`Deleted system role: ${role.name}`, {
operation: "schema_migration",
});
} }
} }
} }
databaseLogger.info("Cleanup completed", {
operation: "schema_migration",
deletedCount,
});
} catch (cleanupError) { } catch (cleanupError) {
databaseLogger.warn("Failed to clean up old system roles", { databaseLogger.warn("Failed to clean up old system roles", {
operation: "schema_migration", operation: "schema_migration",
@@ -935,7 +883,6 @@ const migrateSchema = () => {
}); });
} }
// Ensure only admin and user system roles exist
const systemRoles = [ const systemRoles = [
{ {
name: "admin", name: "admin",
@@ -954,7 +901,6 @@ const migrateSchema = () => {
for (const role of systemRoles) { for (const role of systemRoles) {
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name); const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
if (!existingRole) { if (!existingRole) {
// Create if doesn't exist
try { try {
sqlite.prepare(` sqlite.prepare(`
INSERT INTO roles (name, display_name, description, is_system, permissions) INSERT INTO roles (name, display_name, description, is_system, permissions)
@@ -969,11 +915,6 @@ const migrateSchema = () => {
} }
} }
databaseLogger.info("System roles migration completed", {
operation: "schema_migration",
});
// Migrate existing is_admin users to roles
try { try {
const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[]; const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[];
const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[]; const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[];
@@ -994,11 +935,6 @@ const migrateSchema = () => {
// Ignore duplicate errors // Ignore duplicate errors
} }
} }
databaseLogger.info("Migrated admin users to admin role", {
operation: "schema_migration",
count: adminUsers.length,
});
} }
if (userRole) { if (userRole) {
@@ -1014,11 +950,6 @@ const migrateSchema = () => {
// Ignore duplicate errors // Ignore duplicate errors
} }
} }
databaseLogger.info("Migrated normal users to user role", {
operation: "schema_migration",
count: normalUsers.length,
});
} }
} catch (migrationError) { } catch (migrationError) {
databaseLogger.warn("Failed to migrate existing users to roles", { databaseLogger.warn("Failed to migrate existing users to roles", {

View File

@@ -101,7 +101,7 @@ export const sshData = sqliteTable("ssh_data", {
socks5Port: integer("socks5_port"), socks5Port: integer("socks5_port"),
socks5Username: text("socks5_username"), socks5Username: text("socks5_username"),
socks5Password: text("socks5_password"), socks5Password: text("socks5_password"),
socks5ProxyChain: text("socks5_proxy_chain"), // JSON array for proxy chains socks5ProxyChain: text("socks5_proxy_chain"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
@@ -186,7 +186,6 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
keyType: text("key_type"), keyType: text("key_type"),
detectedKeyType: text("detected_key_type"), detectedKeyType: text("detected_key_type"),
// System-encrypted fields for offline credential sharing
systemPassword: text("system_password"), systemPassword: text("system_password"),
systemKey: text("system_key", { length: 16384 }), systemKey: text("system_key", { length: 16384 }),
systemKeyPassword: text("system_key_password"), systemKeyPassword: text("system_key_password"),
@@ -296,32 +295,27 @@ export const commandHistory = sqliteTable("command_history", {
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
}); });
// RBAC Phase 1: Host Sharing
export const hostAccess = sqliteTable("host_access", { export const hostAccess = sqliteTable("host_access", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id") hostId: integer("host_id")
.notNull() .notNull()
.references(() => sshData.id, { onDelete: "cascade" }), .references(() => sshData.id, { onDelete: "cascade" }),
// Share target: either userId OR roleId (at least one must be set)
userId: text("user_id") userId: text("user_id")
.references(() => users.id, { onDelete: "cascade" }), // Optional .references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id") roleId: integer("role_id")
.references(() => roles.id, { onDelete: "cascade" }), // Optional .references(() => roles.id, { onDelete: "cascade" }),
grantedBy: text("granted_by") grantedBy: text("granted_by")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
// Permission level (view-only)
permissionLevel: text("permission_level") permissionLevel: text("permission_level")
.notNull() .notNull()
.default("view"), // Only "view" is supported .default("view"),
// Time-based access expiresAt: text("expires_at"),
expiresAt: text("expires_at"), // NULL = never expires
// Metadata
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -329,26 +323,21 @@ export const hostAccess = sqliteTable("host_access", {
accessCount: integer("access_count").notNull().default(0), accessCount: integer("access_count").notNull().default(0),
}); });
// RBAC: Shared Credentials (per-user encrypted credential copies)
export const sharedCredentials = sqliteTable("shared_credentials", { export const sharedCredentials = sqliteTable("shared_credentials", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
// Link to the host access grant (CASCADE delete when share revoked)
hostAccessId: integer("host_access_id") hostAccessId: integer("host_access_id")
.notNull() .notNull()
.references(() => hostAccess.id, { onDelete: "cascade" }), .references(() => hostAccess.id, { onDelete: "cascade" }),
// Link to the original credential (for tracking updates/CASCADE delete)
originalCredentialId: integer("original_credential_id") originalCredentialId: integer("original_credential_id")
.notNull() .notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }), .references(() => sshCredentials.id, { onDelete: "cascade" }),
// Target user (recipient of the share) - CASCADE delete when user deleted
targetUserId: text("target_user_id") targetUserId: text("target_user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
// Encrypted credential data (encrypted with targetUserId's DEK)
encryptedUsername: text("encrypted_username").notNull(), encryptedUsername: text("encrypted_username").notNull(),
encryptedAuthType: text("encrypted_auth_type").notNull(), encryptedAuthType: text("encrypted_auth_type").notNull(),
encryptedPassword: text("encrypted_password"), encryptedPassword: text("encrypted_password"),
@@ -356,7 +345,6 @@ export const sharedCredentials = sqliteTable("shared_credentials", {
encryptedKeyPassword: text("encrypted_key_password"), encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"), encryptedKeyType: text("encrypted_key_type"),
// Metadata
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -364,26 +352,22 @@ export const sharedCredentials = sqliteTable("shared_credentials", {
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
// Track if needs re-encryption (when original credential updated but target user offline)
needsReEncryption: integer("needs_re_encryption", { mode: "boolean" }) needsReEncryption: integer("needs_re_encryption", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
}); });
// RBAC Phase 2: Roles
export const roles = sqliteTable("roles", { export const roles = sqliteTable("roles", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(), name: text("name").notNull().unique(),
displayName: text("display_name").notNull(), // For i18n displayName: text("display_name").notNull(),
description: text("description"), description: text("description"),
// System roles cannot be deleted
isSystem: integer("is_system", { mode: "boolean" }) isSystem: integer("is_system", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
// Permissions stored as JSON array (optional - used for grouping only in current phase) permissions: text("permissions"),
permissions: text("permissions"), // ["hosts.*", "credentials.read", ...] - optional
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
@@ -410,32 +394,26 @@ export const userRoles = sqliteTable("user_roles", {
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
}); });
// RBAC Phase 3: Audit Logging
export const auditLogs = sqliteTable("audit_logs", { export const auditLogs = sqliteTable("audit_logs", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
// Who
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
username: text("username").notNull(), // Snapshot in case user deleted username: text("username").notNull(),
// What action: text("action").notNull(),
action: text("action").notNull(), // "create", "read", "update", "delete", "share" resourceType: text("resource_type").notNull(),
resourceType: text("resource_type").notNull(), // "host", "credential", "user", "session" resourceId: text("resource_id"),
resourceId: text("resource_id"), // Can be text or number, store as text resourceName: text("resource_name"),
resourceName: text("resource_name"), // Human-readable identifier
// Context details: text("details"),
details: text("details"), // JSON: { oldValue, newValue, reason, ... }
ipAddress: text("ip_address"), ipAddress: text("ip_address"),
userAgent: text("user_agent"), userAgent: text("user_agent"),
// Result
success: integer("success", { mode: "boolean" }).notNull(), success: integer("success", { mode: "boolean" }).notNull(),
errorMessage: text("error_message"), errorMessage: text("error_message"),
// When
timestamp: text("timestamp") timestamp: text("timestamp")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -454,21 +432,17 @@ export const sessionRecordings = sqliteTable("session_recordings", {
onDelete: "set null", onDelete: "set null",
}), }),
// Session info
startedAt: text("started_at") startedAt: text("started_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"), endedAt: text("ended_at"),
duration: integer("duration"), // seconds duration: integer("duration"),
// Command log (lightweight) commands: text("commands"),
commands: text("commands"), // JSON: [{ts, cmd, exitCode, blocked}] dangerousActions: text("dangerous_actions"),
dangerousActions: text("dangerous_actions"), // JSON: blocked commands
// Full recording (optional, heavy) recordingPath: text("recording_path"),
recordingPath: text("recording_path"), // Path to .cast file
// Metadata
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" }) terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
.default(false), .default(false),
terminationReason: text("termination_reason"), terminationReason: text("termination_reason"),

View File

@@ -478,7 +478,6 @@ router.put(
userId, userId,
); );
// Update shared credentials if this credential is shared
const { SharedCredentialManager } = const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCredManager = SharedCredentialManager.getInstance();
@@ -541,8 +540,6 @@ router.delete(
return res.status(404).json({ error: "Credential not found" }); return res.status(404).json({ error: "Credential not found" });
} }
// Update hosts using this credential to set credentialId to null
// This prevents orphaned references before deletion
const hostsUsingCredential = await db const hostsUsingCredential = await db
.select() .select()
.from(sshData) .from(sshData)
@@ -570,7 +567,6 @@ router.delete(
), ),
); );
// Revoke all shares for hosts that used this credential
for (const host of hostsUsingCredential) { for (const host of hostsUsingCredential) {
const revokedShares = await db const revokedShares = await db
.delete(hostAccess) .delete(hostAccess)
@@ -592,16 +588,11 @@ router.delete(
} }
} }
// Delete shared credentials for this original credential
// Note: This will also be handled by CASCADE, but we do it explicitly for logging
const { SharedCredentialManager } = const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id)); await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
// sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
// No need for manual deletion
await db await db
.delete(sshCredentials) .delete(sshCredentials)
.where( .where(

View File

@@ -27,10 +27,8 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0; return typeof value === "string" && value.trim().length > 0;
} }
/** //Share a host with a user or role
* Share a host with a user or role //POST /rbac/host/:id/share
* POST /rbac/host/:id/share
*/
router.post( router.post(
"/host/:id/share", "/host/:id/share",
authenticateJWT, authenticateJWT,
@@ -44,21 +42,19 @@ router.post(
try { try {
const { const {
targetType = "user", // "user" or "role" targetType = "user",
targetUserId, targetUserId,
targetRoleId, targetRoleId,
durationHours, durationHours,
permissionLevel = "view", // Only "view" is supported permissionLevel = "view",
} = req.body; } = req.body;
// Validate target type
if (!["user", "role"].includes(targetType)) { if (!["user", "role"].includes(targetType)) {
return res return res
.status(400) .status(400)
.json({ error: "Invalid target type. Must be 'user' or 'role'" }); .json({ error: "Invalid target type. Must be 'user' or 'role'" });
} }
// Validate required fields based on target type
if (targetType === "user" && !isNonEmptyString(targetUserId)) { if (targetType === "user" && !isNonEmptyString(targetUserId)) {
return res return res
.status(400) .status(400)
@@ -70,7 +66,6 @@ router.post(
.json({ error: "Target role ID is required when sharing with role" }); .json({ error: "Target role ID is required when sharing with role" });
} }
// Verify user owns the host
const host = await db const host = await db
.select() .select()
.from(sshData) .from(sshData)
@@ -86,7 +81,6 @@ router.post(
return res.status(403).json({ error: "Not host owner" }); return res.status(403).json({ error: "Not host owner" });
} }
// Check if host uses credentials (required for sharing)
if (!host[0].credentialId) { if (!host[0].credentialId) {
return res.status(400).json({ return res.status(400).json({
error: error:
@@ -95,7 +89,6 @@ router.post(
}); });
} }
// Verify target exists (user or role)
if (targetType === "user") { if (targetType === "user") {
const targetUser = await db const targetUser = await db
.select({ id: users.id, username: users.username }) .select({ id: users.id, username: users.username })
@@ -118,7 +111,6 @@ router.post(
} }
} }
// Calculate expiry time
let expiresAt: string | null = null; let expiresAt: string | null = null;
if ( if (
durationHours && durationHours &&
@@ -130,7 +122,6 @@ router.post(
expiresAt = expiryDate.toISOString(); expiresAt = expiryDate.toISOString();
} }
// Validate permission level (only "view" is supported)
const validLevels = ["view"]; const validLevels = ["view"];
if (!validLevels.includes(permissionLevel)) { if (!validLevels.includes(permissionLevel)) {
return res.status(400).json({ return res.status(400).json({
@@ -139,7 +130,6 @@ router.post(
}); });
} }
// Check if access already exists
const whereConditions = [eq(hostAccess.hostId, hostId)]; const whereConditions = [eq(hostAccess.hostId, hostId)];
if (targetType === "user") { if (targetType === "user") {
whereConditions.push(eq(hostAccess.userId, targetUserId)); whereConditions.push(eq(hostAccess.userId, targetUserId));
@@ -154,7 +144,6 @@ router.post(
.limit(1); .limit(1);
if (existing.length > 0) { if (existing.length > 0) {
// Update existing access
await db await db
.update(hostAccess) .update(hostAccess)
.set({ .set({
@@ -163,7 +152,6 @@ router.post(
}) })
.where(eq(hostAccess.id, existing[0].id)); .where(eq(hostAccess.id, existing[0].id));
// Re-create shared credential (delete old, create new)
await db await db
.delete(sharedCredentials) .delete(sharedCredentials)
.where(eq(sharedCredentials.hostAccessId, existing[0].id)); .where(eq(sharedCredentials.hostAccessId, existing[0].id));
@@ -187,16 +175,6 @@ router.post(
); );
} }
databaseLogger.info("Updated existing host access", {
operation: "share_host",
hostId,
targetType,
targetUserId: targetType === "user" ? targetUserId : undefined,
targetRoleId: targetType === "role" ? targetRoleId : undefined,
permissionLevel,
expiresAt,
});
return res.json({ return res.json({
success: true, success: true,
message: "Host access updated", message: "Host access updated",
@@ -204,7 +182,6 @@ router.post(
}); });
} }
// Create new access
const result = await db.insert(hostAccess).values({ const result = await db.insert(hostAccess).values({
hostId, hostId,
userId: targetType === "user" ? targetUserId : null, userId: targetType === "user" ? targetUserId : null,
@@ -214,7 +191,6 @@ router.post(
expiresAt, expiresAt,
}); });
// Create shared credential for the target
const { SharedCredentialManager } = const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCredManager = SharedCredentialManager.getInstance();
@@ -235,17 +211,6 @@ router.post(
); );
} }
databaseLogger.info("Created host access", {
operation: "share_host",
hostId,
hostName: host[0].name,
targetType,
targetUserId: targetType === "user" ? targetUserId : undefined,
targetRoleId: targetType === "role" ? targetRoleId : undefined,
permissionLevel,
expiresAt,
});
res.json({ res.json({
success: true, success: true,
message: `Host shared successfully with ${targetType}`, message: `Host shared successfully with ${targetType}`,
@@ -262,10 +227,8 @@ router.post(
}, },
); );
/** // Revoke host access
* Revoke host access // DELETE /rbac/host/:id/access/:accessId
* DELETE /rbac/host/:id/access/:accessId
*/
router.delete( router.delete(
"/host/:id/access/:accessId", "/host/:id/access/:accessId",
authenticateJWT, authenticateJWT,
@@ -279,7 +242,6 @@ router.delete(
} }
try { try {
// Verify user owns the host
const host = await db const host = await db
.select() .select()
.from(sshData) .from(sshData)
@@ -290,16 +252,8 @@ router.delete(
return res.status(403).json({ error: "Not host owner" }); return res.status(403).json({ error: "Not host owner" });
} }
// Delete the access
await db.delete(hostAccess).where(eq(hostAccess.id, accessId)); await db.delete(hostAccess).where(eq(hostAccess.id, accessId));
databaseLogger.info("Revoked host access", {
operation: "revoke_host_access",
hostId,
accessId,
userId,
});
res.json({ success: true, message: "Access revoked" }); res.json({ success: true, message: "Access revoked" });
} catch (error) { } catch (error) {
databaseLogger.error("Failed to revoke host access", error, { databaseLogger.error("Failed to revoke host access", error, {
@@ -313,10 +267,8 @@ router.delete(
}, },
); );
/** // Get host access list
* Get host access list // GET /rbac/host/:id/access
* GET /rbac/host/:id/access
*/
router.get( router.get(
"/host/:id/access", "/host/:id/access",
authenticateJWT, authenticateJWT,
@@ -329,7 +281,6 @@ router.get(
} }
try { try {
// Verify user owns the host
const host = await db const host = await db
.select() .select()
.from(sshData) .from(sshData)
@@ -340,7 +291,6 @@ router.get(
return res.status(403).json({ error: "Not host owner" }); return res.status(403).json({ error: "Not host owner" });
} }
// Get all access records (both user and role based)
const rawAccessList = await db const rawAccessList = await db
.select({ .select({
id: hostAccess.id, id: hostAccess.id,
@@ -361,7 +311,6 @@ router.get(
.where(eq(hostAccess.hostId, hostId)) .where(eq(hostAccess.hostId, hostId))
.orderBy(desc(hostAccess.createdAt)); .orderBy(desc(hostAccess.createdAt));
// Format access list with type information
const accessList = rawAccessList.map((access) => ({ const accessList = rawAccessList.map((access) => ({
id: access.id, id: access.id,
targetType: access.userId ? "user" : "role", targetType: access.userId ? "user" : "role",
@@ -389,10 +338,8 @@ router.get(
}, },
); );
/** // Get user's shared hosts (hosts shared WITH this user)
* Get user's shared hosts (hosts shared WITH this user) // GET /rbac/shared-hosts
* GET /rbac/shared-hosts
*/
router.get( router.get(
"/shared-hosts", "/shared-hosts",
authenticateJWT, authenticateJWT,
@@ -438,10 +385,8 @@ router.get(
}, },
); );
/** // Get all roles
* Get all roles // GET /rbac/roles
* GET /rbac/roles
*/
router.get( router.get(
"/roles", "/roles",
authenticateJWT, authenticateJWT,
@@ -468,14 +413,8 @@ router.get(
}, },
); );
// ============================================================================ // Get all roles
// Role Management (CRUD) // GET /rbac/roles
// ============================================================================
/**
* Get all roles
* GET /rbac/roles
*/
router.get( router.get(
"/roles", "/roles",
authenticateJWT, authenticateJWT,
@@ -504,10 +443,8 @@ router.get(
}, },
); );
/** // Create new role
* Create new role // POST /rbac/roles
* POST /rbac/roles
*/
router.post( router.post(
"/roles", "/roles",
authenticateJWT, authenticateJWT,
@@ -515,14 +452,12 @@ router.post(
async (req: AuthenticatedRequest, res: Response) => { async (req: AuthenticatedRequest, res: Response) => {
const { name, displayName, description } = req.body; const { name, displayName, description } = req.body;
// Validate required fields
if (!isNonEmptyString(name) || !isNonEmptyString(displayName)) { if (!isNonEmptyString(name) || !isNonEmptyString(displayName)) {
return res.status(400).json({ return res.status(400).json({
error: "Role name and display name are required", error: "Role name and display name are required",
}); });
} }
// Validate name format (alphanumeric, underscore, hyphen only)
if (!/^[a-z0-9_-]+$/.test(name)) { if (!/^[a-z0-9_-]+$/.test(name)) {
return res.status(400).json({ return res.status(400).json({
error: error:
@@ -531,7 +466,6 @@ router.post(
} }
try { try {
// Check if role name already exists
const existing = await db const existing = await db
.select({ id: roles.id }) .select({ id: roles.id })
.from(roles) .from(roles)
@@ -544,23 +478,16 @@ router.post(
}); });
} }
// Create new role
const result = await db.insert(roles).values({ const result = await db.insert(roles).values({
name, name,
displayName, displayName,
description: description || null, description: description || null,
isSystem: false, isSystem: false,
permissions: null, // Roles are for grouping only permissions: null,
}); });
const newRoleId = result.lastInsertRowid; const newRoleId = result.lastInsertRowid;
databaseLogger.info("Created new role", {
operation: "create_role",
roleId: newRoleId,
roleName: name,
});
res.status(201).json({ res.status(201).json({
success: true, success: true,
roleId: newRoleId, roleId: newRoleId,
@@ -576,10 +503,8 @@ router.post(
}, },
); );
/** // Update role
* Update role // PUT /rbac/roles/:id
* PUT /rbac/roles/:id
*/
router.put( router.put(
"/roles/:id", "/roles/:id",
authenticateJWT, authenticateJWT,
@@ -592,7 +517,6 @@ router.put(
return res.status(400).json({ error: "Invalid role ID" }); return res.status(400).json({ error: "Invalid role ID" });
} }
// Validate at least one field to update
if (!displayName && description === undefined) { if (!displayName && description === undefined) {
return res.status(400).json({ return res.status(400).json({
error: "At least one field (displayName or description) is required", error: "At least one field (displayName or description) is required",
@@ -600,7 +524,6 @@ router.put(
} }
try { try {
// Get existing role
const existingRole = await db const existingRole = await db
.select({ .select({
id: roles.id, id: roles.id,
@@ -615,7 +538,6 @@ router.put(
return res.status(404).json({ error: "Role not found" }); return res.status(404).json({ error: "Role not found" });
} }
// Build update object
const updates: { const updates: {
displayName?: string; displayName?: string;
description?: string | null; description?: string | null;
@@ -632,15 +554,8 @@ router.put(
updates.description = description || null; updates.description = description || null;
} }
// Update role
await db.update(roles).set(updates).where(eq(roles.id, roleId)); await db.update(roles).set(updates).where(eq(roles.id, roleId));
databaseLogger.info("Updated role", {
operation: "update_role",
roleId,
roleName: existingRole[0].name,
});
res.json({ res.json({
success: true, success: true,
message: "Role updated successfully", message: "Role updated successfully",
@@ -655,10 +570,8 @@ router.put(
}, },
); );
/** // Delete role
* Delete role // DELETE /rbac/roles/:id
* DELETE /rbac/roles/:id
*/
router.delete( router.delete(
"/roles/:id", "/roles/:id",
authenticateJWT, authenticateJWT,
@@ -671,7 +584,6 @@ router.delete(
} }
try { try {
// Get role details
const role = await db const role = await db
.select({ .select({
id: roles.id, id: roles.id,
@@ -686,41 +598,28 @@ router.delete(
return res.status(404).json({ error: "Role not found" }); return res.status(404).json({ error: "Role not found" });
} }
// Cannot delete system roles
if (role[0].isSystem) { if (role[0].isSystem) {
return res.status(403).json({ return res.status(403).json({
error: "Cannot delete system roles", error: "Cannot delete system roles",
}); });
} }
// Delete user-role assignments first
const deletedUserRoles = await db const deletedUserRoles = await db
.delete(userRoles) .delete(userRoles)
.where(eq(userRoles.roleId, roleId)) .where(eq(userRoles.roleId, roleId))
.returning({ userId: userRoles.userId }); .returning({ userId: userRoles.userId });
// Invalidate permission cache for affected users
for (const { userId } of deletedUserRoles) { for (const { userId } of deletedUserRoles) {
permissionManager.invalidateUserPermissionCache(userId); permissionManager.invalidateUserPermissionCache(userId);
} }
// Delete host_access entries for this role
const deletedHostAccess = await db const deletedHostAccess = await db
.delete(hostAccess) .delete(hostAccess)
.where(eq(hostAccess.roleId, roleId)) .where(eq(hostAccess.roleId, roleId))
.returning({ id: hostAccess.id }); .returning({ id: hostAccess.id });
// Note: sharedCredentials will be auto-deleted by CASCADE
// Delete role
await db.delete(roles).where(eq(roles.id, roleId)); await db.delete(roles).where(eq(roles.id, roleId));
databaseLogger.info("Deleted role", {
operation: "delete_role",
roleId,
roleName: role[0].name,
});
res.json({ res.json({
success: true, success: true,
message: "Role deleted successfully", message: "Role deleted successfully",
@@ -735,14 +634,8 @@ router.delete(
}, },
); );
// ============================================================================ // Assign role to user
// User-Role Assignment // POST /rbac/users/:userId/roles
// ============================================================================
/**
* Assign role to user
* POST /rbac/users/:userId/roles
*/
router.post( router.post(
"/users/:userId/roles", "/users/:userId/roles",
authenticateJWT, authenticateJWT,
@@ -758,7 +651,6 @@ router.post(
return res.status(400).json({ error: "Role ID is required" }); return res.status(400).json({ error: "Role ID is required" });
} }
// Verify target user exists
const targetUser = await db const targetUser = await db
.select() .select()
.from(users) .from(users)
@@ -769,7 +661,6 @@ router.post(
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
} }
// Verify role exists
const role = await db const role = await db
.select() .select()
.from(roles) .from(roles)
@@ -780,7 +671,6 @@ router.post(
return res.status(404).json({ error: "Role not found" }); return res.status(404).json({ error: "Role not found" });
} }
// Prevent manual assignment of system roles
if (role[0].isSystem) { if (role[0].isSystem) {
return res.status(403).json({ return res.status(403).json({
error: error:
@@ -788,7 +678,6 @@ router.post(
}); });
} }
// Check if already assigned
const existing = await db const existing = await db
.select() .select()
.from(userRoles) .from(userRoles)
@@ -801,14 +690,12 @@ router.post(
return res.status(409).json({ error: "Role already assigned" }); return res.status(409).json({ error: "Role already assigned" });
} }
// Assign role
await db.insert(userRoles).values({ await db.insert(userRoles).values({
userId: targetUserId, userId: targetUserId,
roleId, roleId,
grantedBy: currentUserId, grantedBy: currentUserId,
}); });
// Create shared credentials for all hosts shared with this role
const hostsSharedWithRole = await db const hostsSharedWithRole = await db
.select() .select()
.from(hostAccess) .from(hostAccess)
@@ -839,31 +726,12 @@ router.post(
hostId: ssh_data.id, hostId: ssh_data.id,
}, },
); );
// Continue with other hosts even if one fails
} }
} }
} }
if (hostsSharedWithRole.length > 0) {
databaseLogger.info("Created shared credentials for new role member", {
operation: "assign_role_create_credentials",
targetUserId,
roleId,
hostCount: hostsSharedWithRole.length,
});
}
// Invalidate permission cache
permissionManager.invalidateUserPermissionCache(targetUserId); permissionManager.invalidateUserPermissionCache(targetUserId);
databaseLogger.info("Assigned role to user", {
operation: "assign_role",
targetUserId,
roleId,
roleName: role[0].name,
grantedBy: currentUserId,
});
res.json({ res.json({
success: true, success: true,
message: "Role assigned successfully", message: "Role assigned successfully",
@@ -878,10 +746,8 @@ router.post(
}, },
); );
/** // Remove role from user
* Remove role from user // DELETE /rbac/users/:userId/roles/:roleId
* DELETE /rbac/users/:userId/roles/:roleId
*/
router.delete( router.delete(
"/users/:userId/roles/:roleId", "/users/:userId/roles/:roleId",
authenticateJWT, authenticateJWT,
@@ -895,7 +761,6 @@ router.delete(
} }
try { try {
// Verify role exists and get its details
const role = await db const role = await db
.select({ .select({
id: roles.id, id: roles.id,
@@ -910,7 +775,6 @@ router.delete(
return res.status(404).json({ error: "Role not found" }); return res.status(404).json({ error: "Role not found" });
} }
// Prevent removal of system roles
if (role[0].isSystem) { if (role[0].isSystem) {
return res.status(403).json({ return res.status(403).json({
error: error:
@@ -918,22 +782,14 @@ router.delete(
}); });
} }
// Delete the user-role assignment
await db await db
.delete(userRoles) .delete(userRoles)
.where( .where(
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)), and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
); );
// Invalidate permission cache
permissionManager.invalidateUserPermissionCache(targetUserId); permissionManager.invalidateUserPermissionCache(targetUserId);
databaseLogger.info("Removed role from user", {
operation: "remove_role",
targetUserId,
roleId,
});
res.json({ res.json({
success: true, success: true,
message: "Role removed successfully", message: "Role removed successfully",
@@ -949,10 +805,8 @@ router.delete(
}, },
); );
/** // Get user's roles
* Get user's roles // GET /rbac/users/:userId/roles
* GET /rbac/users/:userId/roles
*/
router.get( router.get(
"/users/:userId/roles", "/users/:userId/roles",
authenticateJWT, authenticateJWT,
@@ -960,7 +814,6 @@ router.get(
const targetUserId = req.params.userId; const targetUserId = req.params.userId;
const currentUserId = req.userId!; const currentUserId = req.userId!;
// Users can only see their own roles unless they're admin
if ( if (
targetUserId !== currentUserId && targetUserId !== currentUserId &&
!(await permissionManager.isAdmin(currentUserId)) !(await permissionManager.isAdmin(currentUserId))

View File

@@ -604,7 +604,6 @@ router.put(
} }
try { try {
// Check if user can update this host (owner or manage permission)
const accessInfo = await permissionManager.canAccessHost( const accessInfo = await permissionManager.canAccessHost(
userId, userId,
Number(hostId), Number(hostId),
@@ -620,7 +619,6 @@ router.put(
return res.status(403).json({ error: "Access denied" }); return res.status(403).json({ error: "Access denied" });
} }
// Shared users cannot edit hosts (view-only)
if (!accessInfo.isOwner) { if (!accessInfo.isOwner) {
sshLogger.warn("Shared user attempted to update host (view-only)", { sshLogger.warn("Shared user attempted to update host (view-only)", {
operation: "host_update", operation: "host_update",
@@ -632,7 +630,6 @@ router.put(
}); });
} }
// Get the actual owner ID for the update
const hostRecord = await db const hostRecord = await db
.select({ .select({
userId: sshData.userId, userId: sshData.userId,
@@ -654,7 +651,6 @@ router.put(
const ownerId = hostRecord[0].userId; const ownerId = hostRecord[0].userId;
// Only owner can change credentialId
if ( if (
!accessInfo.isOwner && !accessInfo.isOwner &&
sshDataObj.credentialId !== undefined && sshDataObj.credentialId !== undefined &&
@@ -665,7 +661,6 @@ router.put(
}); });
} }
// Only owner can change authType
if ( if (
!accessInfo.isOwner && !accessInfo.isOwner &&
sshDataObj.authType !== undefined && sshDataObj.authType !== undefined &&
@@ -676,31 +671,15 @@ router.put(
}); });
} }
// Check if credentialId is changing from non-null to null
// This happens when switching from "credential" auth to "password"/"key"/"none"
if (sshDataObj.credentialId !== undefined) { if (sshDataObj.credentialId !== undefined) {
if ( if (
hostRecord[0].credentialId !== null && hostRecord[0].credentialId !== null &&
sshDataObj.credentialId === null sshDataObj.credentialId === null
) { ) {
// Auth type changed away from credential - revoke all shares
const revokedShares = await db const revokedShares = await db
.delete(hostAccess) .delete(hostAccess)
.where(eq(hostAccess.hostId, Number(hostId))) .where(eq(hostAccess.hostId, Number(hostId)))
.returning({ id: hostAccess.id, userId: hostAccess.userId }); .returning({ id: hostAccess.id, userId: hostAccess.userId });
if (revokedShares.length > 0) {
sshLogger.info(
"Auto-revoked host shares due to auth type change from credential",
{
operation: "auto_revoke_shares",
hostId: Number(hostId),
revokedCount: revokedShares.length,
reason: "auth_type_changed_from_credential",
},
);
// Note: sharedCredentials will be auto-deleted by CASCADE
}
} }
} }
@@ -830,17 +809,14 @@ router.get(
try { try {
const now = new Date().toISOString(); const now = new Date().toISOString();
// Get user's role IDs
const userRoleIds = await db const userRoleIds = await db
.select({ roleId: userRoles.roleId }) .select({ roleId: userRoles.roleId })
.from(userRoles) .from(userRoles)
.where(eq(userRoles.userId, userId)); .where(eq(userRoles.userId, userId));
const roleIds = userRoleIds.map((r) => r.roleId); const roleIds = userRoleIds.map((r) => r.roleId);
// Query own hosts + shared hosts with access check
const rawData = await db const rawData = await db
.select({ .select({
// All ssh_data fields
id: sshData.id, id: sshData.id,
userId: sshData.userId, userId: sshData.userId,
name: sshData.name, name: sshData.name,
@@ -881,7 +857,6 @@ router.get(
socks5Password: sshData.socks5Password, socks5Password: sshData.socks5Password,
socks5ProxyChain: sshData.socks5ProxyChain, socks5ProxyChain: sshData.socks5ProxyChain,
// Shared access info
ownerId: sshData.userId, ownerId: sshData.userId,
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`, isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`,
permissionLevel: hostAccess.permissionLevel, permissionLevel: hostAccess.permissionLevel,
@@ -903,15 +878,13 @@ router.get(
) )
.where( .where(
or( or(
eq(sshData.userId, userId), // Own hosts eq(sshData.userId, userId),
and( and(
// Shared to user directly (not expired)
eq(hostAccess.userId, userId), eq(hostAccess.userId, userId),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
), ),
roleIds.length > 0 roleIds.length > 0
? and( ? and(
// Shared to user's role (not expired)
inArray(hostAccess.roleId, roleIds), inArray(hostAccess.roleId, roleIds),
or( or(
isNull(hostAccess.expiresAt), isNull(hostAccess.expiresAt),
@@ -922,11 +895,9 @@ router.get(
), ),
); );
// Separate own hosts from shared hosts for proper decryption
const ownHosts = rawData.filter((row) => row.userId === userId); const ownHosts = rawData.filter((row) => row.userId === userId);
const sharedHosts = rawData.filter((row) => row.userId !== userId); const sharedHosts = rawData.filter((row) => row.userId !== userId);
// Decrypt own hosts with user's DEK
let decryptedOwnHosts: any[] = []; let decryptedOwnHosts: any[] = [];
try { try {
decryptedOwnHosts = await SimpleDBOps.select( decryptedOwnHosts = await SimpleDBOps.select(
@@ -934,38 +905,16 @@ router.get(
"ssh_data", "ssh_data",
userId, userId,
); );
sshLogger.debug("Own hosts decrypted successfully", {
operation: "host_fetch_own_decrypted",
userId,
count: decryptedOwnHosts.length,
});
} catch (decryptError) { } catch (decryptError) {
sshLogger.error("Failed to decrypt own hosts", decryptError, { sshLogger.error("Failed to decrypt own hosts", decryptError, {
operation: "host_fetch_own_decrypt_failed", operation: "host_fetch_own_decrypt_failed",
userId, userId,
}); });
// Return empty array if decryption fails
decryptedOwnHosts = []; decryptedOwnHosts = [];
} }
// For shared hosts, DON'T try to decrypt them with user's DEK
// Just pass them through as plain objects without encrypted credential fields
// The credentials will be resolved via SharedCredentialManager later when resolveHostCredentials is called
sshLogger.info("Processing shared hosts", {
operation: "host_fetch_shared_process",
userId,
count: sharedHosts.length,
});
const sanitizedSharedHosts = sharedHosts; const sanitizedSharedHosts = sharedHosts;
sshLogger.info("Combining hosts", {
operation: "host_fetch_combine",
userId,
ownCount: decryptedOwnHosts.length,
sharedCount: sanitizedSharedHosts.length,
});
const data = [...decryptedOwnHosts, ...sanitizedSharedHosts]; const data = [...decryptedOwnHosts, ...sanitizedSharedHosts];
const result = await Promise.all( const result = await Promise.all(
@@ -1001,7 +950,6 @@ router.get(
? JSON.parse(row.socks5ProxyChain as string) ? JSON.parse(row.socks5ProxyChain as string)
: [], : [],
// Add shared access metadata
isShared: !!row.isShared, isShared: !!row.isShared,
permissionLevel: row.permissionLevel || undefined, permissionLevel: row.permissionLevel || undefined,
sharedExpiresAt: row.expiresAt || undefined, sharedExpiresAt: row.expiresAt || undefined,
@@ -1013,12 +961,6 @@ router.get(
}), }),
); );
sshLogger.info("Credential resolution complete, sending response", {
operation: "host_fetch_complete",
userId,
hostCount: result.length,
});
res.json(result); res.json(result);
} catch (err) { } catch (err) {
sshLogger.error("Failed to fetch SSH hosts from database", err, { sshLogger.error("Failed to fetch SSH hosts from database", err, {
@@ -1220,7 +1162,6 @@ router.delete(
const numericHostId = Number(hostId); const numericHostId = Number(hostId);
// Delete all related data in correct order (child tables first)
await db await db
.delete(fileManagerRecent) .delete(fileManagerRecent)
.where(eq(fileManagerRecent.hostId, numericHostId)); .where(eq(fileManagerRecent.hostId, numericHostId));
@@ -1245,15 +1186,12 @@ router.delete(
.delete(recentActivity) .delete(recentActivity)
.where(eq(recentActivity.hostId, numericHostId)); .where(eq(recentActivity.hostId, numericHostId));
// Delete RBAC host access entries
await db.delete(hostAccess).where(eq(hostAccess.hostId, numericHostId)); await db.delete(hostAccess).where(eq(hostAccess.hostId, numericHostId));
// Delete session recordings
await db await db
.delete(sessionRecordings) .delete(sessionRecordings)
.where(eq(sessionRecordings.hostId, numericHostId)); .where(eq(sessionRecordings.hostId, numericHostId));
// Finally delete the host itself
await db await db
.delete(sshData) .delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId))); .where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
@@ -1762,21 +1700,11 @@ async function resolveHostCredentials(
requestingUserId?: string, requestingUserId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
sshLogger.info("Resolving credentials for host", {
operation: "resolve_credentials_start",
hostId: host.id as number,
hasCredentialId: !!host.credentialId,
requestingUserId,
ownerId: (host.ownerId || host.userId) as string,
});
if (host.credentialId && (host.userId || host.ownerId)) { if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number; const credentialId = host.credentialId as number;
const ownerId = (host.ownerId || host.userId) as string; const ownerId = (host.ownerId || host.userId) as string;
// Check if this is a shared host access
if (requestingUserId && requestingUserId !== ownerId) { if (requestingUserId && requestingUserId !== ownerId) {
// User is accessing a shared host - use shared credential
try { try {
const { SharedCredentialManager } = const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-credential-manager.js");
@@ -1796,7 +1724,6 @@ async function resolveHostCredentials(
keyType: sharedCred.keyType, keyType: sharedCred.keyType,
}; };
// Only override username if overrideCredentialUsername is not enabled
if (!host.overrideCredentialUsername) { if (!host.overrideCredentialUsername) {
resolvedHost.username = sharedCred.username; resolvedHost.username = sharedCred.username;
} }
@@ -1816,11 +1743,9 @@ async function resolveHostCredentials(
: "Unknown error", : "Unknown error",
}, },
); );
// Fall through to try owner's credential
} }
} }
// Original owner access - use original credential
const credentials = await SimpleDBOps.select( const credentials = await SimpleDBOps.select(
db db
.select() .select()
@@ -1846,7 +1771,6 @@ async function resolveHostCredentials(
keyType: credential.key_type || credential.keyType, keyType: credential.key_type || credential.keyType,
}; };
// Only override username if overrideCredentialUsername is not enabled
if (!host.overrideCredentialUsername) { if (!host.overrideCredentialUsername) {
resolvedHost.username = credential.username; resolvedHost.username = credential.username;
} }
@@ -2053,7 +1977,6 @@ router.delete(
const hostIds = hostsToDelete.map((host) => host.id); const hostIds = hostsToDelete.map((host) => host.id);
// Delete all related data for all hosts in the folder (child tables first)
if (hostIds.length > 0) { if (hostIds.length > 0) {
await db await db
.delete(fileManagerRecent) .delete(fileManagerRecent)
@@ -2079,21 +2002,17 @@ router.delete(
.delete(recentActivity) .delete(recentActivity)
.where(inArray(recentActivity.hostId, hostIds)); .where(inArray(recentActivity.hostId, hostIds));
// Delete RBAC host access entries
await db.delete(hostAccess).where(inArray(hostAccess.hostId, hostIds)); await db.delete(hostAccess).where(inArray(hostAccess.hostId, hostIds));
// Delete session recordings
await db await db
.delete(sessionRecordings) .delete(sessionRecordings)
.where(inArray(sessionRecordings.hostId, hostIds)); .where(inArray(sessionRecordings.hostId, hostIds));
} }
// Now delete the hosts themselves
await db await db
.delete(sshData) .delete(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName))); .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
// Finally delete the folder metadata
await db await db
.delete(sshFolders) .delete(sshFolders)
.where( .where(

View File

@@ -139,33 +139,12 @@ function isNonEmptyString(val: unknown): val is string {
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware(); const requireAdmin = authManager.createAdminMiddleware();
/**
* Comprehensive user deletion utility that ensures all related data is deleted
* in proper order to avoid foreign key constraint errors.
*
* This function explicitly deletes all user-related data before deleting the user record.
* It wraps everything in a transaction for atomicity.
*
* @param userId - The ID of the user to delete
* @returns Promise<void>
* @throws Error if deletion fails
*/
async function deleteUserAndRelatedData(userId: string): Promise<void> { async function deleteUserAndRelatedData(userId: string): Promise<void> {
try { try {
authLogger.info("Starting comprehensive user data deletion", {
operation: "delete_user_and_related_data_start",
userId,
});
// Delete all related data in proper order to avoid FK constraint errors
// Order matters due to foreign key relationships
// 1. Delete credential usage logs
await db await db
.delete(sshCredentialUsage) .delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId)); .where(eq(sshCredentialUsage.userId, userId));
// 2. Delete file manager data
await db await db
.delete(fileManagerRecent) .delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId)); .where(eq(fileManagerRecent.userId, userId));
@@ -176,32 +155,23 @@ async function deleteUserAndRelatedData(userId: string): Promise<void> {
.delete(fileManagerShortcuts) .delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId)); .where(eq(fileManagerShortcuts.userId, userId));
// 3. Delete activity and alerts
await db.delete(recentActivity).where(eq(recentActivity.userId, userId)); await db.delete(recentActivity).where(eq(recentActivity.userId, userId));
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId)); await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
// 4. Delete snippets and snippet folders
await db.delete(snippets).where(eq(snippets.userId, userId)); await db.delete(snippets).where(eq(snippets.userId, userId));
await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId)); await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId));
// 5. Delete SSH folders
await db.delete(sshFolders).where(eq(sshFolders.userId, userId)); await db.delete(sshFolders).where(eq(sshFolders.userId, userId));
// 6. Delete command history
await db.delete(commandHistory).where(eq(commandHistory.userId, userId)); await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
// 7. Delete SSH data and credentials
await db.delete(sshData).where(eq(sshData.userId, userId)); await db.delete(sshData).where(eq(sshData.userId, userId));
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId)); await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
// 8. Delete user-specific settings (encryption keys, etc.)
db.$client db.$client
.prepare("DELETE FROM settings WHERE key LIKE ?") .prepare("DELETE FROM settings WHERE key LIKE ?")
.run(`user_%_${userId}`); .run(`user_%_${userId}`);
// 9. Finally, delete the user record
// Note: Sessions, user_roles, host_access, audit_logs, and session_recordings
// will be automatically deleted via CASCADE DELETE foreign key constraints
await db.delete(users).where(eq(users.id, userId)); await db.delete(users).where(eq(users.id, userId));
authLogger.success("User and all related data deleted successfully", { authLogger.success("User and all related data deleted successfully", {
@@ -293,7 +263,6 @@ router.post("/create", async (req, res) => {
totp_backup_codes: null, totp_backup_codes: null,
}); });
// Assign default role to new user
try { try {
const defaultRoleName = isFirstUser ? "admin" : "user"; const defaultRoleName = isFirstUser ? "admin" : "user";
const defaultRole = await db const defaultRole = await db
@@ -306,12 +275,7 @@ router.post("/create", async (req, res) => {
await db.insert(userRoles).values({ await db.insert(userRoles).values({
userId: id, userId: id,
roleId: defaultRole[0].id, roleId: defaultRole[0].id,
grantedBy: id, // Self-assigned during registration grantedBy: id,
});
authLogger.info("Assigned default role to new user", {
operation: "assign_default_role",
userId: id,
roleName: defaultRoleName,
}); });
} else { } else {
authLogger.warn("Default role not found during user registration", { authLogger.warn("Default role not found during user registration", {
@@ -325,7 +289,6 @@ router.post("/create", async (req, res) => {
operation: "assign_default_role", operation: "assign_default_role",
userId: id, userId: id,
}); });
// Don't fail user creation if role assignment fails
} }
try { try {
@@ -934,7 +897,6 @@ router.get("/oidc/callback", async (req, res) => {
scopes: String(config.scopes), scopes: String(config.scopes),
}); });
// Assign default role to new OIDC user
try { try {
const defaultRoleName = isFirstUser ? "admin" : "user"; const defaultRoleName = isFirstUser ? "admin" : "user";
const defaultRole = await db const defaultRole = await db
@@ -947,12 +909,7 @@ router.get("/oidc/callback", async (req, res) => {
await db.insert(userRoles).values({ await db.insert(userRoles).values({
userId: id, userId: id,
roleId: defaultRole[0].id, roleId: defaultRole[0].id,
grantedBy: id, // Self-assigned during registration grantedBy: id,
});
authLogger.info("Assigned default role to new OIDC user", {
operation: "assign_default_role_oidc",
userId: id,
roleName: defaultRoleName,
}); });
} else { } else {
authLogger.warn( authLogger.warn(
@@ -973,7 +930,6 @@ router.get("/oidc/callback", async (req, res) => {
userId: id, userId: id,
}, },
); );
// Don't fail user creation if role assignment fails
} }
try { try {
@@ -1215,7 +1171,6 @@ router.post("/login", async (req, res) => {
return res.status(401).json({ error: "Incorrect password" }); return res.status(401).json({ error: "Incorrect password" });
} }
// Re-encrypt any pending shared credentials for this user
try { try {
const { SharedCredentialManager } = const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-credential-manager.js");
@@ -1227,7 +1182,6 @@ router.post("/login", async (req, res) => {
userId: userRecord.id, userId: userRecord.id,
error, error,
}); });
// Continue with login even if re-encryption fails
} }
if (userRecord.totp_enabled) { if (userRecord.totp_enabled) {
@@ -1303,15 +1257,7 @@ router.post("/logout", authenticateJWT, async (req, res) => {
try { try {
const payload = await authManager.verifyJWTToken(token); const payload = await authManager.verifyJWTToken(token);
sessionId = payload?.sessionId; sessionId = payload?.sessionId;
} catch (error) { } catch (error) {}
authLogger.debug(
"Token verification failed during logout (expected if token expired)",
{
operation: "logout_token_verify_failed",
userId,
},
);
}
} }
await authManager.logoutUser(userId, sessionId); await authManager.logoutUser(userId, sessionId);
@@ -2840,11 +2786,9 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
}); });
} }
// Revoke all sessions and logout the OIDC user before deletion
await authManager.revokeAllUserSessions(oidcUserId); await authManager.revokeAllUserSessions(oidcUserId);
authManager.logoutUser(oidcUserId); authManager.logoutUser(oidcUserId);
// Use the comprehensive deletion utility to ensure all data is properly deleted
await deleteUserAndRelatedData(oidcUserId); await deleteUserAndRelatedData(oidcUserId);
try { try {

View File

@@ -21,7 +21,6 @@ interface SSHSession {
const activeSessions = new Map<string, SSHSession>(); const activeSessions = new Map<string, SSHSession>();
// WebSocket server on port 30008
const wss = new WebSocketServer({ const wss = new WebSocketServer({
port: 30008, port: 30008,
verifyClient: async (info, callback) => { verifyClient: async (info, callback) => {
@@ -49,14 +48,8 @@ const wss = new WebSocketServer({
return callback(false, 401, "Invalid token"); return callback(false, 401, "Invalid token");
} }
// Store userId in the request for later use
(info.req as any).userId = decoded.userId; (info.req as any).userId = decoded.userId;
dockerConsoleLogger.info("WebSocket connection verified", {
operation: "ws_verify",
userId: decoded.userId,
});
callback(true); callback(true);
} catch (error) { } catch (error) {
dockerConsoleLogger.error("WebSocket verification error", error, { dockerConsoleLogger.error("WebSocket verification error", error, {
@@ -67,7 +60,6 @@ const wss = new WebSocketServer({
}, },
}); });
// Helper function to detect available shell in container
async function detectShell( async function detectShell(
session: SSHSession, session: SSHSession,
containerId: string, containerId: string,
@@ -102,19 +94,15 @@ async function detectShell(
); );
}); });
// If we get here, the shell was found
return shell; return shell;
} catch { } catch {
// Try next shell
continue; continue;
} }
} }
// Default to sh if nothing else works
return "sh"; return "sh";
} }
// Helper function to create jump host chain
async function createJumpHostChain( async function createJumpHostChain(
jumpHosts: any[], jumpHosts: any[],
userId: string, userId: string,
@@ -128,7 +116,6 @@ async function createJumpHostChain(
for (let i = 0; i < jumpHosts.length; i++) { for (let i = 0; i < jumpHosts.length; i++) {
const jumpHostId = jumpHosts[i].hostId; const jumpHostId = jumpHosts[i].hostId;
// Fetch jump host from database
const jumpHostData = await SimpleDBOps.select( const jumpHostData = await SimpleDBOps.select(
getDb() getDb()
.select() .select()
@@ -154,7 +141,6 @@ async function createJumpHostChain(
} }
} }
// Resolve credentials for jump host
let resolvedCredentials: any = { let resolvedCredentials: any = {
password: jumpHost.password, password: jumpHost.password,
sshKey: jumpHost.key, sshKey: jumpHost.key,
@@ -203,7 +189,6 @@ async function createJumpHostChain(
tcpKeepAliveInitialDelay: 30000, tcpKeepAliveInitialDelay: 30000,
}; };
// Set authentication
if ( if (
resolvedCredentials.authType === "password" && resolvedCredentials.authType === "password" &&
resolvedCredentials.password resolvedCredentials.password
@@ -223,7 +208,6 @@ async function createJumpHostChain(
} }
} }
// If we have a previous client, use it as the sock
if (currentClient) { if (currentClient) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
currentClient!.forwardOut( currentClient!.forwardOut(
@@ -252,17 +236,10 @@ async function createJumpHostChain(
return currentClient; return currentClient;
} }
// Handle WebSocket connections
wss.on("connection", async (ws: WebSocket, req) => { wss.on("connection", async (ws: WebSocket, req) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const sessionId = `docker-console-${Date.now()}-${Math.random()}`; const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
dockerConsoleLogger.info("Docker console WebSocket connected", {
operation: "ws_connect",
sessionId,
userId,
});
let sshSession: SSHSession | null = null; let sshSession: SSHSession | null = null;
ws.on("message", async (data) => { ws.on("message", async (data) => {
@@ -304,7 +281,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; return;
} }
// Check if Docker is enabled for this host
if (!hostConfig.enableDocker) { if (!hostConfig.enableDocker) {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -317,7 +293,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
try { try {
// Resolve credentials
let resolvedCredentials: any = { let resolvedCredentials: any = {
password: hostConfig.password, password: hostConfig.password,
sshKey: hostConfig.key, sshKey: hostConfig.key,
@@ -355,7 +330,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
} }
// Create SSH client
const client = new SSHClient(); const client = new SSHClient();
const config: any = { const config: any = {
@@ -370,7 +344,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
tcpKeepAliveInitialDelay: 30000, tcpKeepAliveInitialDelay: 30000,
}; };
// Set authentication
if ( if (
resolvedCredentials.authType === "password" && resolvedCredentials.authType === "password" &&
resolvedCredentials.password resolvedCredentials.password
@@ -390,7 +363,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
} }
// Handle jump hosts if configured
if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) { if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) {
const jumpClient = await createJumpHostChain( const jumpClient = await createJumpHostChain(
hostConfig.jumpHosts, hostConfig.jumpHosts,
@@ -413,7 +385,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
} }
// Connect to SSH
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve()); client.on("ready", () => resolve());
client.on("error", reject); client.on("error", reject);
@@ -429,10 +400,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
activeSessions.set(sessionId, sshSession); activeSessions.set(sessionId, sshSession);
// Validate or detect shell
let shellToUse = shell || "bash"; let shellToUse = shell || "bash";
// If a shell is explicitly provided, verify it exists in the container
if (shell) { if (shell) {
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -461,7 +430,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
); );
}); });
} catch { } catch {
// Requested shell not found, detect available shell
dockerConsoleLogger.warn( dockerConsoleLogger.warn(
`Requested shell ${shell} not found, detecting available shell`, `Requested shell ${shell} not found, detecting available shell`,
{ {
@@ -474,13 +442,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
shellToUse = await detectShell(sshSession, containerId); shellToUse = await detectShell(sshSession, containerId);
} }
} else { } else {
// No shell specified, detect available shell
shellToUse = await detectShell(sshSession, containerId); shellToUse = await detectShell(sshSession, containerId);
} }
sshSession.shell = shellToUse; sshSession.shell = shellToUse;
// Create docker exec PTY
const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`; const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`;
client.exec( client.exec(
@@ -515,7 +481,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshSession!.stream = stream; sshSession!.stream = stream;
// Forward stream output to WebSocket
stream.on("data", (data: Buffer) => { stream.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send( ws.send(
@@ -527,15 +492,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
}); });
stream.stderr.on("data", (data: Buffer) => { stream.stderr.on("data", (data: Buffer) => {});
// Log stderr but don't send to terminal to avoid duplicate error messages
dockerConsoleLogger.debug("Docker exec stderr", {
operation: "docker_exec_stderr",
sessionId,
containerId,
data: data.toString("utf8"),
});
});
stream.on("close", () => { stream.on("close", () => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
@@ -547,7 +504,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
); );
} }
// Cleanup
if (sshSession) { if (sshSession) {
sshSession.client.end(); sshSession.client.end();
activeSessions.delete(sessionId); activeSessions.delete(sessionId);
@@ -564,14 +520,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
}, },
}), }),
); );
dockerConsoleLogger.info("Docker console session started", {
operation: "console_start",
sessionId,
containerId,
shell: shellToUse,
requestedShell: shell,
});
}, },
); );
} catch (error) { } catch (error) {
@@ -605,13 +553,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (sshSession && sshSession.stream) { if (sshSession && sshSession.stream) {
const { cols, rows } = message.data; const { cols, rows } = message.data;
sshSession.stream.setWindow(rows, cols); sshSession.stream.setWindow(rows, cols);
dockerConsoleLogger.debug("Console resized", {
operation: "console_resize",
sessionId,
cols,
rows,
});
} }
break; break;
} }
@@ -624,11 +565,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshSession.client.end(); sshSession.client.end();
activeSessions.delete(sessionId); activeSessions.delete(sessionId);
dockerConsoleLogger.info("Docker console disconnected", {
operation: "console_disconnect",
sessionId,
});
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "disconnected", type: "disconnected",
@@ -640,7 +576,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
case "ping": { case "ping": {
// Respond with pong to acknowledge keepalive
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" })); ws.send(JSON.stringify({ type: "pong" }));
} }
@@ -669,12 +604,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
}); });
ws.on("close", () => { ws.on("close", () => {
dockerConsoleLogger.info("WebSocket connection closed", {
operation: "ws_close",
sessionId,
});
// Cleanup SSH session if still active
if (sshSession) { if (sshSession) {
if (sshSession.stream) { if (sshSession.stream) {
sshSession.stream.end(); sshSession.stream.end();
@@ -690,7 +619,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
sessionId, sessionId,
}); });
// Cleanup
if (sshSession) { if (sshSession) {
if (sshSession.stream) { if (sshSession.stream) {
sshSession.stream.end(); sshSession.stream.end();
@@ -701,37 +629,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
}); });
}); });
dockerConsoleLogger.info(
"Docker console WebSocket server started on port 30008",
{
operation: "startup",
},
);
// Graceful shutdown
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
dockerConsoleLogger.info("Shutting down Docker console server...", {
operation: "shutdown",
});
// Close all active sessions
activeSessions.forEach((session, sessionId) => { activeSessions.forEach((session, sessionId) => {
if (session.stream) { if (session.stream) {
session.stream.end(); session.stream.end();
} }
session.client.end(); session.client.end();
dockerConsoleLogger.info("Closed session during shutdown", {
operation: "shutdown",
sessionId,
});
}); });
activeSessions.clear(); activeSessions.clear();
wss.close(() => { wss.close(() => {
dockerConsoleLogger.info("Docker console server closed", {
operation: "shutdown",
});
process.exit(0); process.exit(0);
}); });
}); });

View File

@@ -11,10 +11,8 @@ import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js"; import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest, SSHHost } from "../../types/index.js"; import type { AuthenticatedRequest, SSHHost } from "../../types/index.js";
// Create dedicated logger for Docker operations
const dockerLogger = logger; const dockerLogger = logger;
// SSH Session Management
interface SSHSession { interface SSHSession {
client: SSHClient; client: SSHClient;
isConnected: boolean; isConnected: boolean;
@@ -26,7 +24,6 @@ interface SSHSession {
const sshSessions: Record<string, SSHSession> = {}; const sshSessions: Record<string, SSHSession> = {};
// Session cleanup with 60-minute idle timeout
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000; const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
function cleanupSession(sessionId: string) { function cleanupSession(sessionId: string) {
@@ -47,9 +44,7 @@ function cleanupSession(sessionId: string) {
try { try {
session.client.end(); session.client.end();
} catch (error) { } catch (error) {}
dockerLogger.debug("Error ending SSH client during cleanup", { error });
}
clearTimeout(session.timeout); clearTimeout(session.timeout);
delete sshSessions[sessionId]; delete sshSessions[sessionId];
dockerLogger.info("Docker SSH session cleaned up", { dockerLogger.info("Docker SSH session cleaned up", {
@@ -70,7 +65,6 @@ function scheduleSessionCleanup(sessionId: string) {
} }
} }
// Helper function to resolve jump host
async function resolveJumpHost( async function resolveJumpHost(
hostId: number, hostId: number,
userId: string, userId: string,
@@ -131,7 +125,6 @@ async function resolveJumpHost(
} }
} }
// Helper function to create jump host chain
async function createJumpHostChain( async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>, jumpHosts: Array<{ hostId: number }>,
userId: string, userId: string,
@@ -239,7 +232,6 @@ async function createJumpHostChain(
} }
} }
// Helper function to execute Docker CLI commands
async function executeDockerCommand( async function executeDockerCommand(
session: SSHSession, session: SSHSession,
command: string, command: string,
@@ -290,7 +282,6 @@ async function executeDockerCommand(
}); });
} }
// Express app setup
const app = express(); const app = express();
app.use( app.use(
@@ -334,12 +325,9 @@ app.use(cookieParser());
app.use(express.json({ limit: "100mb" })); app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true })); app.use(express.urlencoded({ limit: "100mb", extended: true }));
// Initialize AuthManager and apply middleware
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware()); app.use(authManager.createAuthMiddleware());
// Session management endpoints
// POST /docker/ssh/connect - Establish SSH session // POST /docker/ssh/connect - Establish SSH session
app.post("/docker/ssh/connect", async (req, res) => { app.post("/docker/ssh/connect", async (req, res) => {
const { sessionId, hostId } = req.body; const { sessionId, hostId } = req.body;
@@ -373,7 +361,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
} }
try { try {
// Get host configuration - check both owned and shared hosts
const hosts = await SimpleDBOps.select( const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(eq(sshData.id, hostId)), getDb().select().from(sshData).where(eq(sshData.id, hostId)),
"ssh_data", "ssh_data",
@@ -386,7 +373,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
const host = hosts[0] as unknown as SSHHost; const host = hosts[0] as unknown as SSHHost;
// Verify user has access to this host (either owner or shared access)
if (host.userId !== userId) { if (host.userId !== userId) {
const { PermissionManager } = const { PermissionManager } =
await import("../utils/permission-manager.js"); await import("../utils/permission-manager.js");
@@ -417,7 +403,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
} }
} }
// Check if Docker is enabled for this host
if (!host.enableDocker) { if (!host.enableDocker) {
dockerLogger.warn("Docker not enabled for host", { dockerLogger.warn("Docker not enabled for host", {
operation: "docker_connect", operation: "docker_connect",
@@ -431,12 +416,10 @@ app.post("/docker/ssh/connect", async (req, res) => {
}); });
} }
// Clean up existing session if any
if (sshSessions[sessionId]) { if (sshSessions[sessionId]) {
cleanupSession(sessionId); cleanupSession(sessionId);
} }
// Resolve credentials
let resolvedCredentials: any = { let resolvedCredentials: any = {
password: host.password, password: host.password,
sshKey: host.key, sshKey: host.key,
@@ -447,9 +430,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (host.credentialId) { if (host.credentialId) {
const ownerId = host.userId; const ownerId = host.userId;
// Check if this is a shared host access
if (userId !== ownerId) { if (userId !== ownerId) {
// User is accessing a shared host - use shared credential
try { try {
const { SharedCredentialManager } = const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js"); await import("../utils/shared-credential-manager.js");
@@ -475,7 +456,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
}); });
} }
} else { } else {
// Owner accessing their own host
const credentials = await SimpleDBOps.select( const credentials = await SimpleDBOps.select(
getDb() getDb()
.select() .select()
@@ -503,7 +483,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
} }
} }
// Create SSH client
const client = new SSHClient(); const client = new SSHClient();
const config: any = { const config: any = {
@@ -518,7 +497,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
tcpKeepAliveInitialDelay: 30000, tcpKeepAliveInitialDelay: 30000,
}; };
// Set authentication
if ( if (
resolvedCredentials.authType === "password" && resolvedCredentials.authType === "password" &&
resolvedCredentials.password resolvedCredentials.password
@@ -554,13 +532,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
dockerLogger.info("Docker SSH session established", {
operation: "docker_connect",
sessionId,
hostId,
userId,
});
res.json({ success: true, message: "SSH connection established" }); res.json({ success: true, message: "SSH connection established" });
}); });
@@ -588,7 +559,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
} }
}); });
// Handle jump hosts if configured
if (host.jumpHosts && host.jumpHosts.length > 0) { if (host.jumpHosts && host.jumpHosts.length > 0) {
const jumpClient = await createJumpHostChain( const jumpClient = await createJumpHostChain(
host.jumpHosts as Array<{ hostId: number }>, host.jumpHosts as Array<{ hostId: number }>,
@@ -654,11 +624,6 @@ app.post("/docker/ssh/disconnect", async (req, res) => {
cleanupSession(sessionId); cleanupSession(sessionId);
dockerLogger.info("Docker SSH session disconnected", {
operation: "docker_disconnect",
sessionId,
});
res.json({ success: true, message: "SSH session disconnected" }); res.json({ success: true, message: "SSH session disconnected" });
}); });
@@ -724,7 +689,6 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
session.activeOperations++; session.activeOperations++;
try { try {
// Check if Docker is installed
try { try {
const versionOutput = await executeDockerCommand( const versionOutput = await executeDockerCommand(
session, session,
@@ -733,7 +697,6 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
const versionMatch = versionOutput.match(/Docker version ([^\s,]+)/); const versionMatch = versionOutput.match(/Docker version ([^\s,]+)/);
const version = versionMatch ? versionMatch[1] : "unknown"; const version = versionMatch ? versionMatch[1] : "unknown";
// Check if Docker daemon is running
try { try {
await executeDockerCommand(session, "docker ps >/dev/null 2>&1"); await executeDockerCommand(session, "docker ps >/dev/null 2>&1");
@@ -798,7 +761,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
// GET /docker/containers/:sessionId - List all containers // GET /docker/containers/:sessionId - List all containers
app.get("/docker/containers/:sessionId", async (req, res) => { app.get("/docker/containers/:sessionId", async (req, res) => {
const { sessionId } = req.params; const { sessionId } = req.params;
const all = req.query.all !== "false"; // Default to true const all = req.query.all !== "false";
const userId = (req as any).userId; const userId = (req as any).userId;
if (!userId) { if (!userId) {
@@ -942,13 +905,6 @@ app.post(
session.activeOperations--; session.activeOperations--;
dockerLogger.info("Container started", {
operation: "start_container",
sessionId,
containerId,
userId,
});
res.json({ res.json({
success: true, success: true,
message: "Container started successfully", message: "Container started successfully",
@@ -1007,13 +963,6 @@ app.post(
session.activeOperations--; session.activeOperations--;
dockerLogger.info("Container stopped", {
operation: "stop_container",
sessionId,
containerId,
userId,
});
res.json({ res.json({
success: true, success: true,
message: "Container stopped successfully", message: "Container stopped successfully",
@@ -1072,13 +1021,6 @@ app.post(
session.activeOperations--; session.activeOperations--;
dockerLogger.info("Container restarted", {
operation: "restart_container",
sessionId,
containerId,
userId,
});
res.json({ res.json({
success: true, success: true,
message: "Container restarted successfully", message: "Container restarted successfully",
@@ -1137,13 +1079,6 @@ app.post(
session.activeOperations--; session.activeOperations--;
dockerLogger.info("Container paused", {
operation: "pause_container",
sessionId,
containerId,
userId,
});
res.json({ res.json({
success: true, success: true,
message: "Container paused successfully", message: "Container paused successfully",
@@ -1202,13 +1137,6 @@ app.post(
session.activeOperations--; session.activeOperations--;
dockerLogger.info("Container unpaused", {
operation: "unpause_container",
sessionId,
containerId,
userId,
});
res.json({ res.json({
success: true, success: true,
message: "Container unpaused successfully", message: "Container unpaused successfully",
@@ -1272,14 +1200,6 @@ app.delete(
session.activeOperations--; session.activeOperations--;
dockerLogger.info("Container removed", {
operation: "remove_container",
sessionId,
containerId,
force,
userId,
});
res.json({ res.json({
success: true, success: true,
message: "Container removed successfully", message: "Container removed successfully",
@@ -1425,17 +1345,14 @@ app.get(
const output = await executeDockerCommand(session, command); const output = await executeDockerCommand(session, command);
const rawStats = JSON.parse(output.trim()); const rawStats = JSON.parse(output.trim());
// Parse memory usage (e.g., "1.5GiB / 8GiB" -> { used: "1.5GiB", limit: "8GiB" })
const memoryParts = rawStats.memory.split(" / "); const memoryParts = rawStats.memory.split(" / ");
const memoryUsed = memoryParts[0]?.trim() || "0B"; const memoryUsed = memoryParts[0]?.trim() || "0B";
const memoryLimit = memoryParts[1]?.trim() || "0B"; const memoryLimit = memoryParts[1]?.trim() || "0B";
// Parse network I/O (e.g., "1.5MB / 2.3MB" -> { input: "1.5MB", output: "2.3MB" })
const netIOParts = rawStats.netIO.split(" / "); const netIOParts = rawStats.netIO.split(" / ");
const netInput = netIOParts[0]?.trim() || "0B"; const netInput = netIOParts[0]?.trim() || "0B";
const netOutput = netIOParts[1]?.trim() || "0B"; const netOutput = netIOParts[1]?.trim() || "0B";
// Parse block I/O (e.g., "10MB / 5MB" -> { read: "10MB", write: "5MB" })
const blockIOParts = rawStats.blockIO.split(" / "); const blockIOParts = rawStats.blockIO.split(" / ");
const blockRead = blockIOParts[0]?.trim() || "0B"; const blockRead = blockIOParts[0]?.trim() || "0B";
const blockWrite = blockIOParts[1]?.trim() || "0B"; const blockWrite = blockIOParts[1]?.trim() || "0B";
@@ -1482,13 +1399,11 @@ app.get(
}, },
); );
// Start server
const PORT = 30007; const PORT = 30007;
app.listen(PORT, async () => { app.listen(PORT, async () => {
try { try {
await authManager.initialize(); await authManager.initialize();
dockerLogger.info(`Docker backend server started on port ${PORT}`);
} catch (err) { } catch (err) {
dockerLogger.error("Failed to initialize Docker backend", err, { dockerLogger.error("Failed to initialize Docker backend", err, {
operation: "startup", operation: "startup",
@@ -1496,9 +1411,7 @@ app.listen(PORT, async () => {
} }
}); });
// Graceful shutdown
process.on("SIGINT", () => { process.on("SIGINT", () => {
dockerLogger.info("Shutting down Docker backend");
Object.keys(sshSessions).forEach((sessionId) => { Object.keys(sshSessions).forEach((sessionId) => {
cleanupSession(sessionId); cleanupSession(sessionId);
}); });
@@ -1506,7 +1419,6 @@ process.on("SIGINT", () => {
}); });
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
dockerLogger.info("Shutting down Docker backend");
Object.keys(sshSessions).forEach((sessionId) => { Object.keys(sshSessions).forEach((sessionId) => {
cleanupSession(sessionId); cleanupSession(sessionId);
}); });

View File

@@ -815,34 +815,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}, },
); );
fileLogger.info("SFTP connection request received", {
operation: "sftp_connect_request",
sessionId,
hostId,
ip,
port,
useSocks5,
socks5Host,
socks5Port,
hasSocks5ProxyChain: !!(
socks5ProxyChain && (socks5ProxyChain as any).length > 0
),
proxyChainLength: socks5ProxyChain ? (socks5ProxyChain as any).length : 0,
});
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
if ( if (
useSocks5 && useSocks5 &&
(socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0)) (socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0))
) { ) {
fileLogger.info("SOCKS5 enabled for SFTP, creating connection", {
operation: "sftp_socks5_enabled",
sessionId,
socks5Host,
socks5Port,
hasChain: !!(socks5ProxyChain && (socks5ProxyChain as any).length > 0),
});
try { try {
const socks5Socket = await createSocks5Connection(ip, port, { const socks5Socket = await createSocks5Connection(ip, port, {
useSocks5, useSocks5,
@@ -854,10 +830,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}); });
if (socks5Socket) { if (socks5Socket) {
fileLogger.info("SOCKS5 socket created for SFTP", {
operation: "sftp_socks5_socket_ready",
sessionId,
});
config.sock = socks5Socket; config.sock = socks5Socket;
client.connect(config); client.connect(config);
return; return;
@@ -883,17 +855,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
: "Unknown error"), : "Unknown error"),
}); });
} }
} else { } else if (jumpHosts && jumpHosts.length > 0 && userId) {
fileLogger.info("SOCKS5 NOT enabled for SFTP connection", {
operation: "sftp_no_socks5",
sessionId,
useSocks5,
socks5Host,
hasChain: !!(socks5ProxyChain && (socks5ProxyChain as any).length > 0),
});
}
if (jumpHosts && jumpHosts.length > 0 && userId) {
try { try {
const jumpClient = await createJumpHostChain(jumpHosts, userId); const jumpClient = await createJumpHostChain(jumpHosts, userId);
@@ -976,9 +938,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId]; delete pendingTOTPSessions[sessionId];
try { try {
session.client.end(); session.client.end();
} catch (error) { } catch (error) {}
sshLogger.debug("Operation failed, continuing", { error });
}
fileLogger.warn("TOTP session timeout before code submission", { fileLogger.warn("TOTP session timeout before code submission", {
operation: "file_totp_verify", operation: "file_totp_verify",
sessionId, sessionId,
@@ -3055,21 +3015,10 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
let errorOutput = ""; let errorOutput = "";
stream.on("data", (data: Buffer) => { stream.on("data", (data: Buffer) => {});
fileLogger.debug("Extract stdout", {
operation: "extract_archive",
sessionId,
output: data.toString(),
});
});
stream.stderr.on("data", (data: Buffer) => { stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString(); errorOutput += data.toString();
fileLogger.debug("Extract stderr", {
operation: "extract_archive",
sessionId,
error: data.toString(),
});
}); });
stream.on("close", (code: number) => { stream.on("close", (code: number) => {
@@ -3247,21 +3196,10 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
let errorOutput = ""; let errorOutput = "";
stream.on("data", (data: Buffer) => { stream.on("data", (data: Buffer) => {});
fileLogger.debug("Compress stdout", {
operation: "compress_files",
sessionId,
output: data.toString(),
});
});
stream.stderr.on("data", (data: Buffer) => { stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString(); errorOutput += data.toString();
fileLogger.debug("Compress stderr", {
operation: "compress_files",
sessionId,
error: data.toString(),
});
}); });
stream.on("close", (code: number) => { stream.on("close", (code: number) => {

View File

@@ -201,7 +201,6 @@ class SSHConnectionPool {
private cleanupInterval: NodeJS.Timeout; private cleanupInterval: NodeJS.Timeout;
constructor() { constructor() {
// Reduce cleanup interval from 5 minutes to 2 minutes for faster dead connection removal
this.cleanupInterval = setInterval( this.cleanupInterval = setInterval(
() => { () => {
this.cleanup(); this.cleanup();
@@ -211,8 +210,6 @@ class SSHConnectionPool {
} }
private getHostKey(host: SSHHostWithCredentials): string { private getHostKey(host: SSHHostWithCredentials): string {
// Include SOCKS5 settings in the key to ensure separate connection pools
// for direct connections vs SOCKS5 connections
const socks5Key = host.useSocks5 const socks5Key = host.useSocks5
? `:socks5:${host.socks5Host}:${host.socks5Port}:${JSON.stringify(host.socks5ProxyChain || [])}` ? `:socks5:${host.socks5Host}:${host.socks5Port}:${JSON.stringify(host.socks5ProxyChain || [])}`
: ""; : "";
@@ -221,9 +218,8 @@ class SSHConnectionPool {
private isConnectionHealthy(client: Client): boolean { private isConnectionHealthy(client: Client): boolean {
try { try {
// Check if the connection has been destroyed or closed const sock = (client as any)._sock;
// @ts-ignore - accessing internal property to check connection state if (sock && (sock.destroyed || !sock.writable)) {
if (client._sock && (client._sock.destroyed || !client._sock.writable)) {
return false; return false;
} }
return true; return true;
@@ -236,28 +232,13 @@ class SSHConnectionPool {
const hostKey = this.getHostKey(host); const hostKey = this.getHostKey(host);
let connections = this.connections.get(hostKey) || []; let connections = this.connections.get(hostKey) || [];
statsLogger.info("Getting connection from pool", {
operation: "get_connection_from_pool",
hostKey: hostKey,
availableConnections: connections.length,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
hasSocks5ProxyChain: !!(
host.socks5ProxyChain && host.socks5ProxyChain.length > 0
),
hostId: host.id,
});
// Find available connection and validate health
const available = connections.find((conn) => !conn.inUse); const available = connections.find((conn) => !conn.inUse);
if (available) { if (available) {
// Health check before reuse
if (!this.isConnectionHealthy(available.client)) { if (!this.isConnectionHealthy(available.client)) {
statsLogger.warn("Removing unhealthy connection from pool", { statsLogger.warn("Removing unhealthy connection from pool", {
operation: "remove_dead_connection", operation: "remove_dead_connection",
hostKey, hostKey,
}); });
// Remove dead connection
try { try {
available.client.end(); available.client.end();
} catch (error) { } catch (error) {
@@ -265,12 +246,7 @@ class SSHConnectionPool {
} }
connections = connections.filter((c) => c !== available); connections = connections.filter((c) => c !== available);
this.connections.set(hostKey, connections); this.connections.set(hostKey, connections);
// Fall through to create new connection
} else { } else {
statsLogger.info("Reusing existing connection from pool", {
operation: "reuse_connection",
hostKey,
});
available.inUse = true; available.inUse = true;
available.lastUsed = Date.now(); available.lastUsed = Date.now();
return available.client; return available.client;
@@ -278,10 +254,6 @@ class SSHConnectionPool {
} }
if (connections.length < this.maxConnectionsPerHost) { if (connections.length < this.maxConnectionsPerHost) {
statsLogger.info("Creating new connection for pool", {
operation: "create_new_connection",
hostKey,
});
const client = await this.createConnection(host); const client = await this.createConnection(host);
const pooled: PooledConnection = { const pooled: PooledConnection = {
client, client,
@@ -369,24 +341,11 @@ class SSHConnectionPool {
try { try {
const config = buildSshConfig(host); const config = buildSshConfig(host);
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
if ( if (
host.useSocks5 && host.useSocks5 &&
(host.socks5Host || (host.socks5Host ||
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) (host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
) { ) {
statsLogger.info("Using SOCKS5 proxy for connection", {
operation: "socks5_enabled",
hostIp: host.ip,
hostPort: host.port,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
hasChain: !!(
host.socks5ProxyChain && host.socks5ProxyChain.length > 0
),
chainLength: host.socks5ProxyChain?.length || 0,
});
try { try {
const socks5Socket = await createSocks5Connection( const socks5Socket = await createSocks5Connection(
host.ip, host.ip,
@@ -402,10 +361,6 @@ class SSHConnectionPool {
); );
if (socks5Socket) { if (socks5Socket) {
statsLogger.info("SOCKS5 socket created successfully", {
operation: "socks5_socket_ready",
hostIp: host.ip,
});
config.sock = socks5Socket; config.sock = socks5Socket;
client.connect(config); client.connect(config);
return; return;
@@ -492,12 +447,6 @@ class SSHConnectionPool {
const hostKey = this.getHostKey(host); const hostKey = this.getHostKey(host);
const connections = this.connections.get(hostKey) || []; const connections = this.connections.get(hostKey) || [];
statsLogger.info("Clearing all connections for host", {
operation: "clear_host_connections",
hostKey,
connectionCount: connections.length,
});
for (const conn of connections) { for (const conn of connections) {
try { try {
conn.client.end(); conn.client.end();
@@ -519,7 +468,6 @@ class SSHConnectionPool {
for (const [hostKey, connections] of this.connections.entries()) { for (const [hostKey, connections] of this.connections.entries()) {
const activeConnections = connections.filter((conn) => { const activeConnections = connections.filter((conn) => {
// Remove if idle for too long
if (!conn.inUse && now - conn.lastUsed > maxAge) { if (!conn.inUse && now - conn.lastUsed > maxAge) {
try { try {
conn.client.end(); conn.client.end();
@@ -527,7 +475,6 @@ class SSHConnectionPool {
totalCleaned++; totalCleaned++;
return false; return false;
} }
// Also remove if connection is unhealthy (even if recently used)
if (!this.isConnectionHealthy(conn.client)) { if (!this.isConnectionHealthy(conn.client)) {
statsLogger.warn("Removing unhealthy connection during cleanup", { statsLogger.warn("Removing unhealthy connection during cleanup", {
operation: "cleanup_unhealthy", operation: "cleanup_unhealthy",
@@ -549,23 +496,9 @@ class SSHConnectionPool {
this.connections.set(hostKey, activeConnections); this.connections.set(hostKey, activeConnections);
} }
} }
if (totalCleaned > 0 || totalUnhealthy > 0) {
statsLogger.info("Connection pool cleanup completed", {
operation: "cleanup_complete",
idleCleaned: totalCleaned,
unhealthyCleaned: totalUnhealthy,
remainingHosts: this.connections.size,
});
}
} }
clearAllConnections(): void { clearAllConnections(): void {
statsLogger.info("Clearing ALL connections from pool", {
operation: "clear_all_connections",
totalHosts: this.connections.size,
});
for (const [hostKey, connections] of this.connections.entries()) { for (const [hostKey, connections] of this.connections.entries()) {
for (const conn of connections) { for (const conn of connections) {
try { try {
@@ -601,13 +534,12 @@ class SSHConnectionPool {
class RequestQueue { class RequestQueue {
private queues = new Map<number, Array<() => Promise<unknown>>>(); private queues = new Map<number, Array<() => Promise<unknown>>>();
private processing = new Set<number>(); private processing = new Set<number>();
private requestTimeout = 60000; // 60 second timeout for requests private requestTimeout = 60000;
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> { async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const wrappedRequest = async () => { const wrappedRequest = async () => {
try { try {
// Add timeout wrapper to prevent indefinite hanging
const result = await Promise.race<T>([ const result = await Promise.race<T>([
request(), request(),
new Promise<never>((_, rej) => new Promise<never>((_, rej) =>
@@ -646,19 +578,11 @@ class RequestQueue {
if (request) { if (request) {
try { try {
await request(); await request();
} catch (error) { } catch (error) {}
// Log errors but continue processing queue
statsLogger.debug("Request queue error", {
operation: "queue_request_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
} }
} }
this.processing.delete(hostId); this.processing.delete(hostId);
// Check if new items were added during processing
const currentQueue = this.queues.get(hostId); const currentQueue = this.queues.get(hostId);
if (currentQueue && currentQueue.length > 0) { if (currentQueue && currentQueue.length > 0) {
this.processQueue(hostId); this.processQueue(hostId);
@@ -797,9 +721,9 @@ class AuthFailureTracker {
class PollingBackoff { class PollingBackoff {
private failures = new Map<number, { count: number; nextRetry: number }>(); private failures = new Map<number, { count: number; nextRetry: number }>();
private baseDelay = 30000; // 30s base delay private baseDelay = 30000;
private maxDelay = 600000; // 10 min max delay private maxDelay = 600000;
private maxRetries = 5; // Max retry attempts before giving up private maxRetries = 5;
recordFailure(hostId: number): void { recordFailure(hostId: number): void {
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 }; const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
@@ -811,25 +735,16 @@ class PollingBackoff {
count: existing.count + 1, count: existing.count + 1,
nextRetry: Date.now() + delay, nextRetry: Date.now() + delay,
}); });
statsLogger.debug("Recorded polling backoff", {
operation: "polling_backoff_recorded",
hostId,
failureCount: existing.count + 1,
nextRetryDelay: delay,
});
} }
shouldSkip(hostId: number): boolean { shouldSkip(hostId: number): boolean {
const backoff = this.failures.get(hostId); const backoff = this.failures.get(hostId);
if (!backoff) return false; if (!backoff) return false;
// If exceeded max retries, always skip
if (backoff.count >= this.maxRetries) { if (backoff.count >= this.maxRetries) {
return true; return true;
} }
// Otherwise check if we're still in backoff period
return Date.now() < backoff.nextRetry; return Date.now() < backoff.nextRetry;
} }
@@ -852,18 +767,13 @@ class PollingBackoff {
reset(hostId: number): void { reset(hostId: number): void {
this.failures.delete(hostId); this.failures.delete(hostId);
statsLogger.debug("Reset polling backoff", {
operation: "polling_backoff_reset",
hostId,
});
} }
cleanup(): void { cleanup(): void {
const maxAge = 60 * 60 * 1000; // 1 hour const maxAge = 60 * 60 * 1000;
const now = Date.now(); const now = Date.now();
for (const [hostId, backoff] of this.failures.entries()) { for (const [hostId, backoff] of this.failures.entries()) {
// Only cleanup if not at max retries and old enough
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) { if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
this.failures.delete(hostId); this.failures.delete(hostId);
} }
@@ -906,7 +816,6 @@ interface SSHHostWithCredentials {
updatedAt: string; updatedAt: string;
userId: string; userId: string;
// SOCKS5 Proxy configuration
useSocks5?: boolean; useSocks5?: boolean;
socks5Host?: string; socks5Host?: string;
socks5Port?: number; socks5Port?: number;
@@ -1051,7 +960,6 @@ class PollingManager {
} }
private async pollHostStatus(host: SSHHostWithCredentials): Promise<void> { private async pollHostStatus(host: SSHHostWithCredentials): Promise<void> {
// Refresh host data from database to get latest settings
const refreshedHost = await fetchHostById(host.id, host.userId); const refreshedHost = await fetchHostById(host.id, host.userId);
if (!refreshedHost) { if (!refreshedHost) {
statsLogger.warn("Host not found during status polling", { statsLogger.warn("Host not found during status polling", {
@@ -1082,18 +990,11 @@ class PollingManager {
} }
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> { private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
// Check if we should skip due to backoff
if (pollingBackoff.shouldSkip(host.id)) { if (pollingBackoff.shouldSkip(host.id)) {
const backoffInfo = pollingBackoff.getBackoffInfo(host.id); const backoffInfo = pollingBackoff.getBackoffInfo(host.id);
statsLogger.debug("Skipping metrics polling due to backoff", {
operation: "poll_metrics_skipped",
hostId: host.id,
backoffInfo,
});
return; return;
} }
// Refresh host data from database to get latest SOCKS5 and other settings
const refreshedHost = await fetchHostById(host.id, host.userId); const refreshedHost = await fetchHostById(host.id, host.userId);
if (!refreshedHost) { if (!refreshedHost) {
statsLogger.warn("Host not found during metrics polling", { statsLogger.warn("Host not found during metrics polling", {
@@ -1114,13 +1015,11 @@ class PollingManager {
data: metrics, data: metrics,
timestamp: Date.now(), timestamp: Date.now(),
}); });
// Reset backoff on successful collection
pollingBackoff.reset(refreshedHost.id); pollingBackoff.reset(refreshedHost.id);
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
// Record failure for backoff
pollingBackoff.recordFailure(refreshedHost.id); pollingBackoff.recordFailure(refreshedHost.id);
const latestConfig = this.pollingConfigs.get(refreshedHost.id); const latestConfig = this.pollingConfigs.get(refreshedHost.id);
@@ -1356,7 +1255,6 @@ async function resolveHostCredentials(
createdAt: host.createdAt, createdAt: host.createdAt,
updatedAt: host.updatedAt, updatedAt: host.updatedAt,
userId: host.userId, userId: host.userId,
// SOCKS5 proxy settings
useSocks5: !!host.useSocks5, useSocks5: !!host.useSocks5,
socks5Host: host.socks5Host || undefined, socks5Host: host.socks5Host || undefined,
socks5Port: host.socks5Port || undefined, socks5Port: host.socks5Port || undefined,
@@ -1415,21 +1313,6 @@ async function resolveHostCredentials(
addLegacyCredentials(baseHost, host); addLegacyCredentials(baseHost, host);
} }
statsLogger.info("Resolved host credentials with SOCKS5 settings", {
operation: "resolve_host",
hostId: host.id as number,
useSocks5: baseHost.useSocks5,
socks5Host: baseHost.socks5Host,
socks5Port: baseHost.socks5Port,
hasSocks5ProxyChain: !!(
baseHost.socks5ProxyChain &&
(baseHost.socks5ProxyChain as any[]).length > 0
),
proxyChainLength: baseHost.socks5ProxyChain
? (baseHost.socks5ProxyChain as any[]).length
: 0,
});
return baseHost as unknown as SSHHostWithCredentials; return baseHost as unknown as SSHHostWithCredentials;
} catch (error) { } catch (error) {
statsLogger.error( statsLogger.error(
@@ -1654,12 +1537,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
}; };
try { try {
login_stats = await collectLoginStats(client); login_stats = await collectLoginStats(client);
} catch (e) { } catch (e) {}
statsLogger.debug("Failed to collect login stats", {
operation: "login_stats_failed",
error: e instanceof Error ? e.message : String(e),
});
}
const result = { const result = {
cpu, cpu,
@@ -1800,7 +1678,6 @@ app.post("/refresh", async (req, res) => {
}); });
} }
// Clear all connections to ensure fresh connections with updated settings
connectionPool.clearAllConnections(); connectionPool.clearAllConnections();
await pollingManager.refreshHostPolling(userId); await pollingManager.refreshHostPolling(userId);
@@ -1825,7 +1702,6 @@ app.post("/host-updated", async (req, res) => {
try { try {
const host = await fetchHostById(hostId, userId); const host = await fetchHostById(hostId, userId);
if (host) { if (host) {
// Clear existing connections for this host to ensure new settings (like SOCKS5) are used
connectionPool.clearHostConnections(host); connectionPool.clearHostConnections(host);
await pollingManager.startPollingForHost(host); await pollingManager.startPollingForHost(host);

View File

@@ -137,12 +137,10 @@ async function createJumpHostChain(
const clients: Client[] = []; const clients: Client[] = [];
try { try {
// Fetch all jump host configurations in parallel
const jumpHostConfigs = await Promise.all( const jumpHostConfigs = await Promise.all(
jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)), jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)),
); );
// Validate all configs resolved
for (let i = 0; i < jumpHostConfigs.length; i++) { for (let i = 0; i < jumpHostConfigs.length; i++) {
if (!jumpHostConfigs[i]) { if (!jumpHostConfigs[i]) {
sshLogger.error(`Jump host ${i + 1} not found`, undefined, { sshLogger.error(`Jump host ${i + 1} not found`, undefined, {
@@ -154,7 +152,6 @@ async function createJumpHostChain(
} }
} }
// Connect through jump hosts sequentially
for (let i = 0; i < jumpHostConfigs.length; i++) { for (let i = 0; i < jumpHostConfigs.length; i++) {
const jumpHostConfig = jumpHostConfigs[i]; const jumpHostConfig = jumpHostConfigs[i];
@@ -1196,7 +1193,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; return;
} }
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
if ( if (
hostConfig.useSocks5 && hostConfig.useSocks5 &&
(hostConfig.socks5Host || (hostConfig.socks5Host ||

View File

@@ -594,11 +594,6 @@ async function connectSSHTunnel(
keyType: sharedCred.keyType, keyType: sharedCred.keyType,
authMethod: sharedCred.authType, authMethod: sharedCred.authType,
}; };
tunnelLogger.info("Resolved shared credentials for tunnel source", {
operation: "tunnel_connect_shared_cred",
tunnelName,
userId: effectiveUserId,
});
} else { } else {
const errorMessage = `Cannot connect tunnel '${tunnelName}': shared credentials not available`; const errorMessage = `Cannot connect tunnel '${tunnelName}': shared credentials not available`;
tunnelLogger.error(errorMessage); tunnelLogger.error(errorMessage);
@@ -1126,7 +1121,6 @@ async function connectSSHTunnel(
}); });
} }
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
if ( if (
tunnelConfig.useSocks5 && tunnelConfig.useSocks5 &&
(tunnelConfig.socks5Host || (tunnelConfig.socks5Host ||
@@ -1399,7 +1393,6 @@ async function killRemoteTunnelByMarker(
callback(err); callback(err);
}); });
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
if ( if (
tunnelConfig.useSocks5 && tunnelConfig.useSocks5 &&
(tunnelConfig.socks5Host || (tunnelConfig.socks5Host ||
@@ -1517,12 +1510,6 @@ app.post(
if (accessInfo.isShared && !accessInfo.isOwner) { if (accessInfo.isShared && !accessInfo.isOwner) {
tunnelConfig.requestingUserId = userId; tunnelConfig.requestingUserId = userId;
tunnelLogger.info("Shared host tunnel connect", {
operation: "tunnel_connect_shared",
userId,
hostId: tunnelConfig.sourceHostId,
tunnelName,
});
} }
} }
@@ -1552,14 +1539,7 @@ app.post(
} }
} }
// If endpoint details are missing, resolve them from database
if (!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername) { if (!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername) {
tunnelLogger.info("Resolving endpoint host details from database", {
operation: "tunnel_connect_resolve_endpoint",
tunnelName,
endpointHost: tunnelConfig.endpointHost,
});
try { try {
const systemCrypto = SystemCrypto.getInstance(); const systemCrypto = SystemCrypto.getInstance();
const internalAuthToken = await systemCrypto.getInternalAuthToken(); const internalAuthToken = await systemCrypto.getInternalAuthToken();
@@ -1587,7 +1567,6 @@ app.post(
); );
} }
// Populate endpoint fields
tunnelConfig.endpointIP = endpointHost.ip; tunnelConfig.endpointIP = endpointHost.ip;
tunnelConfig.endpointSSHPort = endpointHost.port; tunnelConfig.endpointSSHPort = endpointHost.port;
tunnelConfig.endpointUsername = endpointHost.username; tunnelConfig.endpointUsername = endpointHost.username;
@@ -1598,13 +1577,6 @@ app.post(
tunnelConfig.endpointKeyType = endpointHost.keyType; tunnelConfig.endpointKeyType = endpointHost.keyType;
tunnelConfig.endpointCredentialId = endpointHost.credentialId; tunnelConfig.endpointCredentialId = endpointHost.credentialId;
tunnelConfig.endpointUserId = endpointHost.userId; tunnelConfig.endpointUserId = endpointHost.userId;
tunnelLogger.info("Endpoint host details resolved", {
operation: "tunnel_connect_endpoint_resolved",
tunnelName,
endpointIP: tunnelConfig.endpointIP,
endpointUsername: tunnelConfig.endpointUsername,
});
} catch (resolveError) { } catch (resolveError) {
tunnelLogger.error( tunnelLogger.error(
"Failed to resolve endpoint host", "Failed to resolve endpoint host",

View File

@@ -26,7 +26,6 @@ export async function collectCpuMetrics(client: Client): Promise<{
let loadTriplet: [number, number, number] | null = null; let loadTriplet: [number, number, number] | null = null;
try { try {
// Wrap Promise.all with timeout to prevent indefinite blocking
const [stat1, loadAvgOut, coresOut] = await Promise.race([ const [stat1, loadAvgOut, coresOut] = await Promise.race([
Promise.all([ Promise.all([
execCommand(client, "cat /proc/stat"), execCommand(client, "cat /proc/stat"),

View File

@@ -169,7 +169,6 @@ class AuthManager {
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
} }
// Migrate credentials to system encryption for offline sharing
try { try {
const { CredentialSystemEncryptionMigration } = const { CredentialSystemEncryptionMigration } =
await import("./credential-system-encryption-migration.js"); await import("./credential-system-encryption-migration.js");
@@ -177,18 +176,9 @@ class AuthManager {
const credResult = await credMigration.migrateUserCredentials(userId); const credResult = await credMigration.migrateUserCredentials(userId);
if (credResult.migrated > 0) { if (credResult.migrated > 0) {
databaseLogger.info(
"Credentials migrated to system encryption on login",
{
operation: "login_credential_migration",
userId,
migrated: credResult.migrated,
},
);
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
} }
} catch (error) { } catch (error) {
// Log but don't fail login
databaseLogger.warn("Credential migration failed during login", { databaseLogger.warn("Credential migration failed during login", {
operation: "login_credential_migration_failed", operation: "login_credential_migration_failed",
userId, userId,

View File

@@ -6,31 +6,21 @@ import { SystemCrypto } from "./system-crypto.js";
import { FieldCrypto } from "./field-crypto.js"; import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js"; import { databaseLogger } from "./logger.js";
/**
* Migrates credentials to include system-encrypted fields for offline sharing
*/
export class CredentialSystemEncryptionMigration { export class CredentialSystemEncryptionMigration {
/**
* Migrates a user's credentials to include system-encrypted fields
* Requires user to be logged in (DEK available)
*/
async migrateUserCredentials(userId: string): Promise<{ async migrateUserCredentials(userId: string): Promise<{
migrated: number; migrated: number;
failed: number; failed: number;
skipped: number; skipped: number;
}> { }> {
try { try {
// Get user's DEK (requires logged in)
const userDEK = DataCrypto.getUserDataKey(userId); const userDEK = DataCrypto.getUserDataKey(userId);
if (!userDEK) { if (!userDEK) {
throw new Error("User must be logged in to migrate credentials"); throw new Error("User must be logged in to migrate credentials");
} }
// Get system key
const systemCrypto = SystemCrypto.getInstance(); const systemCrypto = SystemCrypto.getInstance();
const CSKEK = await systemCrypto.getCredentialSharingKey(); const CSKEK = await systemCrypto.getCredentialSharingKey();
// Find credentials without system encryption
const credentials = await db const credentials = await db
.select() .select()
.from(sshCredentials) .from(sshCredentials)
@@ -51,7 +41,6 @@ export class CredentialSystemEncryptionMigration {
for (const cred of credentials) { for (const cred of credentials) {
try { try {
// Decrypt with user DEK
const plainPassword = cred.password const plainPassword = cred.password
? FieldCrypto.decryptField( ? FieldCrypto.decryptField(
cred.password, cred.password,
@@ -79,7 +68,6 @@ export class CredentialSystemEncryptionMigration {
) )
: null; : null;
// Re-encrypt with CSKEK
const systemPassword = plainPassword const systemPassword = plainPassword
? FieldCrypto.encryptField( ? FieldCrypto.encryptField(
plainPassword, plainPassword,
@@ -107,7 +95,6 @@ export class CredentialSystemEncryptionMigration {
) )
: null; : null;
// Update database
await db await db
.update(sshCredentials) .update(sshCredentials)
.set({ .set({
@@ -119,12 +106,6 @@ export class CredentialSystemEncryptionMigration {
.where(eq(sshCredentials.id, cred.id)); .where(eq(sshCredentials.id, cred.id));
migrated++; migrated++;
databaseLogger.info("Credential migrated for offline sharing", {
operation: "credential_system_encryption_migrated",
credentialId: cred.id,
userId,
});
} catch (error) { } catch (error) {
databaseLogger.error("Failed to migrate credential", error, { databaseLogger.error("Failed to migrate credential", error, {
credentialId: cred.id, credentialId: cred.id,
@@ -133,20 +114,6 @@ export class CredentialSystemEncryptionMigration {
failed++; failed++;
} }
} }
if (migrated > 0) {
databaseLogger.success(
"Credential system encryption migration completed",
{
operation: "credential_migration_complete",
userId,
migrated,
failed,
skipped,
},
);
}
return { migrated, failed, skipped }; return { migrated, failed, skipped };
} catch (error) { } catch (error) {
databaseLogger.error( databaseLogger.error(

View File

@@ -488,12 +488,10 @@ class DataCrypto {
const systemEncrypted: Record<string, unknown> = {}; const systemEncrypted: Record<string, unknown> = {};
const recordId = record.id || "temp-" + Date.now(); const recordId = record.id || "temp-" + Date.now();
// Only encrypt for sshCredentials table
if (tableName !== "ssh_credentials") { if (tableName !== "ssh_credentials") {
return systemEncrypted as Partial<T>; return systemEncrypted as Partial<T>;
} }
// Encrypt password field
if (record.password && typeof record.password === "string") { if (record.password && typeof record.password === "string") {
systemEncrypted.systemPassword = FieldCrypto.encryptField( systemEncrypted.systemPassword = FieldCrypto.encryptField(
record.password as string, record.password as string,
@@ -503,7 +501,6 @@ class DataCrypto {
); );
} }
// Encrypt key field
if (record.key && typeof record.key === "string") { if (record.key && typeof record.key === "string") {
systemEncrypted.systemKey = FieldCrypto.encryptField( systemEncrypted.systemKey = FieldCrypto.encryptField(
record.key as string, record.key as string,
@@ -513,7 +510,6 @@ class DataCrypto {
); );
} }
// Encrypt key_password field
if (record.key_password && typeof record.key_password === "string") { if (record.key_password && typeof record.key_password === "string") {
systemEncrypted.systemKeyPassword = FieldCrypto.encryptField( systemEncrypted.systemKeyPassword = FieldCrypto.encryptField(
record.key_password as string, record.key_password as string,

View File

@@ -327,11 +327,7 @@ class DatabaseFileEncryption {
fs.accessSync(envPath, fs.constants.R_OK); fs.accessSync(envPath, fs.constants.R_OK);
envFileReadable = true; envFileReadable = true;
} }
} catch (error) { } catch (error) {}
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
databaseLogger.error( databaseLogger.error(
"Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write", "Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write",

View File

@@ -19,7 +19,7 @@ interface HostAccessInfo {
hasAccess: boolean; hasAccess: boolean;
isOwner: boolean; isOwner: boolean;
isShared: boolean; isShared: boolean;
permissionLevel?: "view"; // Only "view" is supported for shared access permissionLevel?: "view";
expiresAt?: string | null; expiresAt?: string | null;
} }
@@ -34,12 +34,11 @@ class PermissionManager {
string, string,
{ permissions: string[]; timestamp: number } { permissions: string[]; timestamp: number }
>; >;
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes private readonly CACHE_TTL = 5 * 60 * 1000;
private constructor() { private constructor() {
this.permissionCache = new Map(); this.permissionCache = new Map();
// Auto-cleanup expired host access every 1 minute
setInterval(() => { setInterval(() => {
this.cleanupExpiredAccess().catch((error) => { this.cleanupExpiredAccess().catch((error) => {
databaseLogger.error( databaseLogger.error(
@@ -52,7 +51,6 @@ class PermissionManager {
}); });
}, 60 * 1000); }, 60 * 1000);
// Clear permission cache every 5 minutes
setInterval(() => { setInterval(() => {
this.clearPermissionCache(); this.clearPermissionCache();
}, this.CACHE_TTL); }, this.CACHE_TTL);
@@ -80,13 +78,6 @@ class PermissionManager {
), ),
) )
.returning({ id: hostAccess.id }); .returning({ id: hostAccess.id });
if (result.length > 0) {
databaseLogger.info("Cleaned up expired host access", {
operation: "host_access_cleanup",
count: result.length,
});
}
} catch (error) { } catch (error) {
databaseLogger.error("Failed to cleanup expired host access", error, { databaseLogger.error("Failed to cleanup expired host access", error, {
operation: "host_access_cleanup_failed", operation: "host_access_cleanup_failed",
@@ -112,7 +103,6 @@ class PermissionManager {
* Get user permissions from roles * Get user permissions from roles
*/ */
async getUserPermissions(userId: string): Promise<string[]> { async getUserPermissions(userId: string): Promise<string[]> {
// Check cache first
const cached = this.permissionCache.get(userId); const cached = this.permissionCache.get(userId);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.permissions; return cached.permissions;
@@ -145,7 +135,6 @@ class PermissionManager {
const permissionsArray = Array.from(allPermissions); const permissionsArray = Array.from(allPermissions);
// Cache the result
this.permissionCache.set(userId, { this.permissionCache.set(userId, {
permissions: permissionsArray, permissions: permissionsArray,
timestamp: Date.now(), timestamp: Date.now(),
@@ -168,17 +157,14 @@ class PermissionManager {
async hasPermission(userId: string, permission: string): Promise<boolean> { async hasPermission(userId: string, permission: string): Promise<boolean> {
const userPermissions = await this.getUserPermissions(userId); const userPermissions = await this.getUserPermissions(userId);
// Check for wildcard "*" (god mode)
if (userPermissions.includes("*")) { if (userPermissions.includes("*")) {
return true; return true;
} }
// Check exact match
if (userPermissions.includes(permission)) { if (userPermissions.includes(permission)) {
return true; return true;
} }
// Check wildcard matches
const parts = permission.split("."); const parts = permission.split(".");
for (let i = parts.length; i > 0; i--) { for (let i = parts.length; i > 0; i--) {
const wildcardPermission = parts.slice(0, i).join(".") + ".*"; const wildcardPermission = parts.slice(0, i).join(".") + ".*";
@@ -199,7 +185,6 @@ class PermissionManager {
action: "read" | "write" | "execute" | "delete" | "share" = "read", action: "read" | "write" | "execute" | "delete" | "share" = "read",
): Promise<HostAccessInfo> { ): Promise<HostAccessInfo> {
try { try {
// Check if user is the owner
const host = await db const host = await db
.select() .select()
.from(sshData) .from(sshData)
@@ -214,14 +199,12 @@ class PermissionManager {
}; };
} }
// Get user's role IDs
const userRoleIds = await db const userRoleIds = await db
.select({ roleId: userRoles.roleId }) .select({ roleId: userRoles.roleId })
.from(userRoles) .from(userRoles)
.where(eq(userRoles.userId, userId)); .where(eq(userRoles.userId, userId));
const roleIds = userRoleIds.map((r) => r.roleId); const roleIds = userRoleIds.map((r) => r.roleId);
// Check if host is shared with user OR user's roles
const now = new Date().toISOString(); const now = new Date().toISOString();
const sharedAccess = await db const sharedAccess = await db
.select() .select()
@@ -246,7 +229,6 @@ class PermissionManager {
if (sharedAccess.length > 0) { if (sharedAccess.length > 0) {
const access = sharedAccess[0]; const access = sharedAccess[0];
// All shared access is view-only - deny write/delete
if (action === "write" || action === "delete") { if (action === "write" || action === "delete") {
return { return {
hasAccess: false, hasAccess: false,
@@ -257,7 +239,6 @@ class PermissionManager {
}; };
} }
// Update last accessed time
try { try {
await db await db
.update(hostAccess) .update(hostAccess)
@@ -306,7 +287,6 @@ class PermissionManager {
*/ */
async isAdmin(userId: string): Promise<boolean> { async isAdmin(userId: string): Promise<boolean> {
try { try {
// Check old is_admin field
const user = await db const user = await db
.select({ isAdmin: users.is_admin }) .select({ isAdmin: users.is_admin })
.from(users) .from(users)
@@ -317,7 +297,6 @@ class PermissionManager {
return true; return true;
} }
// Check if user has admin or super_admin role
const adminRoles = await db const adminRoles = await db
.select({ roleName: roles.name }) .select({ roleName: roles.name })
.from(userRoles) .from(userRoles)
@@ -415,7 +394,6 @@ class PermissionManager {
}); });
} }
// Attach access info to request for use in route handlers
(req as any).hostAccessInfo = accessInfo; (req as any).hostAccessInfo = accessInfo;
next(); next();

View File

@@ -49,14 +49,11 @@ class SharedCredentialManager {
ownerId: string, ownerId: string,
): Promise<void> { ): Promise<void> {
try { try {
// Try owner's DEK first (existing path)
const ownerDEK = DataCrypto.getUserDataKey(ownerId); const ownerDEK = DataCrypto.getUserDataKey(ownerId);
if (ownerDEK) { if (ownerDEK) {
// Owner online - use existing flow
const targetDEK = DataCrypto.getUserDataKey(targetUserId); const targetDEK = DataCrypto.getUserDataKey(targetUserId);
if (!targetDEK) { if (!targetDEK) {
// Target user is offline, mark for lazy re-encryption
await this.createPendingSharedCredential( await this.createPendingSharedCredential(
hostAccessId, hostAccessId,
originalCredentialId, originalCredentialId,
@@ -65,14 +62,12 @@ class SharedCredentialManager {
return; return;
} }
// Fetch and decrypt original credential using owner's DEK
const credentialData = await this.getDecryptedCredential( const credentialData = await this.getDecryptedCredential(
originalCredentialId, originalCredentialId,
ownerId, ownerId,
ownerDEK, ownerDEK,
); );
// Encrypt credential data with target user's DEK
const encryptedForTarget = this.encryptCredentialForUser( const encryptedForTarget = this.encryptCredentialForUser(
credentialData, credentialData,
targetUserId, targetUserId,
@@ -80,7 +75,6 @@ class SharedCredentialManager {
hostAccessId, hostAccessId,
); );
// Store shared credential
await db.insert(sharedCredentials).values({ await db.insert(sharedCredentials).values({
hostAccessId, hostAccessId,
originalCredentialId, originalCredentialId,
@@ -88,28 +82,9 @@ class SharedCredentialManager {
...encryptedForTarget, ...encryptedForTarget,
needsReEncryption: false, needsReEncryption: false,
}); });
databaseLogger.info("Created shared credential for user", {
operation: "create_shared_credential",
hostAccessId,
targetUserId,
});
} else { } else {
// NEW: Owner offline - use system key fallback
databaseLogger.info(
"Owner offline, attempting to share using system key",
{
operation: "create_shared_credential_system_key",
hostAccessId,
targetUserId,
ownerId,
},
);
// Get target user's DEK
const targetDEK = DataCrypto.getUserDataKey(targetUserId); const targetDEK = DataCrypto.getUserDataKey(targetUserId);
if (!targetDEK) { if (!targetDEK) {
// Both offline - create pending
await this.createPendingSharedCredential( await this.createPendingSharedCredential(
hostAccessId, hostAccessId,
originalCredentialId, originalCredentialId,
@@ -118,11 +93,9 @@ class SharedCredentialManager {
return; return;
} }
// Decrypt using system key
const credentialData = const credentialData =
await this.getDecryptedCredentialViaSystemKey(originalCredentialId); await this.getDecryptedCredentialViaSystemKey(originalCredentialId);
// Encrypt for target user
const encryptedForTarget = this.encryptCredentialForUser( const encryptedForTarget = this.encryptCredentialForUser(
credentialData, credentialData,
targetUserId, targetUserId,
@@ -130,7 +103,6 @@ class SharedCredentialManager {
hostAccessId, hostAccessId,
); );
// Store shared credential
await db.insert(sharedCredentials).values({ await db.insert(sharedCredentials).values({
hostAccessId, hostAccessId,
originalCredentialId, originalCredentialId,
@@ -138,12 +110,6 @@ class SharedCredentialManager {
...encryptedForTarget, ...encryptedForTarget,
needsReEncryption: false, needsReEncryption: false,
}); });
databaseLogger.info("Created shared credential using system key", {
operation: "create_shared_credential_system_key",
hostAccessId,
targetUserId,
});
} }
} catch (error) { } catch (error) {
databaseLogger.error("Failed to create shared credential", error, { databaseLogger.error("Failed to create shared credential", error, {
@@ -166,13 +132,11 @@ class SharedCredentialManager {
ownerId: string, ownerId: string,
): Promise<void> { ): Promise<void> {
try { try {
// Get all users in the role
const roleUsers = await db const roleUsers = await db
.select({ userId: userRoles.userId }) .select({ userId: userRoles.userId })
.from(userRoles) .from(userRoles)
.where(eq(userRoles.roleId, roleId)); .where(eq(userRoles.roleId, roleId));
// Create shared credential for each user
for (const { userId } of roleUsers) { for (const { userId } of roleUsers) {
try { try {
await this.createSharedCredentialForUser( await this.createSharedCredentialForUser(
@@ -192,16 +156,8 @@ class SharedCredentialManager {
userId, userId,
}, },
); );
// Continue with other users even if one fails
} }
} }
databaseLogger.info("Created shared credentials for role", {
operation: "create_shared_credentials_role",
hostAccessId,
roleId,
userCount: roleUsers.length,
});
} catch (error) { } catch (error) {
databaseLogger.error( databaseLogger.error(
"Failed to create shared credentials for role", "Failed to create shared credentials for role",
@@ -230,7 +186,6 @@ class SharedCredentialManager {
throw new Error(`User ${userId} data not unlocked`); throw new Error(`User ${userId} data not unlocked`);
} }
// Find shared credential via hostAccess
const sharedCred = await db const sharedCred = await db
.select() .select()
.from(sharedCredentials) .from(sharedCredentials)
@@ -252,7 +207,6 @@ class SharedCredentialManager {
const cred = sharedCred[0].shared_credentials; const cred = sharedCred[0].shared_credentials;
// Check if needs re-encryption
if (cred.needsReEncryption) { if (cred.needsReEncryption) {
databaseLogger.warn( databaseLogger.warn(
"Shared credential needs re-encryption but cannot be accessed yet", "Shared credential needs re-encryption but cannot be accessed yet",
@@ -262,12 +216,9 @@ class SharedCredentialManager {
userId, userId,
}, },
); );
// Credential is pending re-encryption - owner must be offline
// Return null instead of trying to re-encrypt (which would cause infinite loop)
return null; return null;
} }
// Decrypt credential data with user's DEK
return this.decryptSharedCredential(cred, userDEK); return this.decryptSharedCredential(cred, userDEK);
} catch (error) { } catch (error) {
databaseLogger.error("Failed to get shared credential", error, { databaseLogger.error("Failed to get shared credential", error, {
@@ -288,34 +239,21 @@ class SharedCredentialManager {
ownerId: string, ownerId: string,
): Promise<void> { ): Promise<void> {
try { try {
// Get all shared credentials for this original credential
const sharedCreds = await db const sharedCreds = await db
.select() .select()
.from(sharedCredentials) .from(sharedCredentials)
.where(eq(sharedCredentials.originalCredentialId, credentialId)); .where(eq(sharedCredentials.originalCredentialId, credentialId));
// Try owner's DEK first
const ownerDEK = DataCrypto.getUserDataKey(ownerId); const ownerDEK = DataCrypto.getUserDataKey(ownerId);
let credentialData: CredentialData; let credentialData: CredentialData;
if (ownerDEK) { if (ownerDEK) {
// Owner online - use owner's DEK
credentialData = await this.getDecryptedCredential( credentialData = await this.getDecryptedCredential(
credentialId, credentialId,
ownerId, ownerId,
ownerDEK, ownerDEK,
); );
} else { } else {
// Owner offline - use system key fallback
databaseLogger.info(
"Updating shared credentials using system key (owner offline)",
{
operation: "update_shared_credentials_system_key",
credentialId,
ownerId,
},
);
try { try {
credentialData = credentialData =
await this.getDecryptedCredentialViaSystemKey(credentialId); await this.getDecryptedCredentialViaSystemKey(credentialId);
@@ -329,7 +267,6 @@ class SharedCredentialManager {
error: error instanceof Error ? error.message : "Unknown error", error: error instanceof Error ? error.message : "Unknown error",
}, },
); );
// Mark all shared credentials for re-encryption
await db await db
.update(sharedCredentials) .update(sharedCredentials)
.set({ needsReEncryption: true }) .set({ needsReEncryption: true })
@@ -338,12 +275,10 @@ class SharedCredentialManager {
} }
} }
// Update each shared credential
for (const sharedCred of sharedCreds) { for (const sharedCred of sharedCreds) {
const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId); const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId);
if (!targetDEK) { if (!targetDEK) {
// Target user offline, mark for lazy re-encryption
await db await db
.update(sharedCredentials) .update(sharedCredentials)
.set({ needsReEncryption: true }) .set({ needsReEncryption: true })
@@ -351,7 +286,6 @@ class SharedCredentialManager {
continue; continue;
} }
// Re-encrypt with target user's DEK
const encryptedForTarget = this.encryptCredentialForUser( const encryptedForTarget = this.encryptCredentialForUser(
credentialData, credentialData,
sharedCred.targetUserId, sharedCred.targetUserId,
@@ -368,12 +302,6 @@ class SharedCredentialManager {
}) })
.where(eq(sharedCredentials.id, sharedCred.id)); .where(eq(sharedCredentials.id, sharedCred.id));
} }
databaseLogger.info("Updated shared credentials for original", {
operation: "update_shared_credentials",
credentialId,
count: sharedCreds.length,
});
} catch (error) { } catch (error) {
databaseLogger.error("Failed to update shared credentials", error, { databaseLogger.error("Failed to update shared credentials", error, {
operation: "update_shared_credentials", operation: "update_shared_credentials",
@@ -394,12 +322,6 @@ class SharedCredentialManager {
.delete(sharedCredentials) .delete(sharedCredentials)
.where(eq(sharedCredentials.originalCredentialId, credentialId)) .where(eq(sharedCredentials.originalCredentialId, credentialId))
.returning({ id: sharedCredentials.id }); .returning({ id: sharedCredentials.id });
databaseLogger.info("Deleted shared credentials for original", {
operation: "delete_shared_credentials",
credentialId,
count: result.length,
});
} catch (error) { } catch (error) {
databaseLogger.error("Failed to delete shared credentials", error, { databaseLogger.error("Failed to delete shared credentials", error, {
operation: "delete_shared_credentials", operation: "delete_shared_credentials",
@@ -416,7 +338,7 @@ class SharedCredentialManager {
try { try {
const userDEK = DataCrypto.getUserDataKey(userId); const userDEK = DataCrypto.getUserDataKey(userId);
if (!userDEK) { if (!userDEK) {
return; // User not unlocked yet return;
} }
const pendingCreds = await db const pendingCreds = await db
@@ -432,14 +354,6 @@ class SharedCredentialManager {
for (const cred of pendingCreds) { for (const cred of pendingCreds) {
await this.reEncryptSharedCredential(cred.id, userId); await this.reEncryptSharedCredential(cred.id, userId);
} }
if (pendingCreds.length > 0) {
databaseLogger.info("Re-encrypted pending credentials for user", {
operation: "reencrypt_pending_credentials",
userId,
count: pendingCreds.length,
});
}
} catch (error) { } catch (error) {
databaseLogger.error("Failed to re-encrypt pending credentials", error, { databaseLogger.error("Failed to re-encrypt pending credentials", error, {
operation: "reencrypt_pending_credentials", operation: "reencrypt_pending_credentials",
@@ -448,8 +362,6 @@ class SharedCredentialManager {
} }
} }
// ========== PRIVATE HELPER METHODS ==========
private async getDecryptedCredential( private async getDecryptedCredential(
credentialId: number, credentialId: number,
ownerId: string, ownerId: string,
@@ -472,8 +384,6 @@ class SharedCredentialManager {
const cred = creds[0]; const cred = creds[0];
// Decrypt sensitive fields
// Note: username and authType are NOT encrypted
return { return {
username: cred.username, username: cred.username,
authType: cred.authType, authType: cred.authType,
@@ -513,7 +423,6 @@ class SharedCredentialManager {
const cred = creds[0]; const cred = creds[0];
// Check if system fields exist
if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) { if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) {
throw new Error( throw new Error(
"Credential not yet migrated for offline sharing. " + "Credential not yet migrated for offline sharing. " +
@@ -521,12 +430,10 @@ class SharedCredentialManager {
); );
} }
// Get system key
const { SystemCrypto } = await import("./system-crypto.js"); const { SystemCrypto } = await import("./system-crypto.js");
const systemCrypto = SystemCrypto.getInstance(); const systemCrypto = SystemCrypto.getInstance();
const CSKEK = await systemCrypto.getCredentialSharingKey(); const CSKEK = await systemCrypto.getCredentialSharingKey();
// Decrypt using system-encrypted fields
return { return {
username: cred.username, username: cred.username,
authType: cred.authType, authType: cred.authType,
@@ -575,7 +482,7 @@ class SharedCredentialManager {
recordId, recordId,
"username", "username",
), ),
encryptedAuthType: credentialData.authType, // authType is not sensitive encryptedAuthType: credentialData.authType,
encryptedPassword: credentialData.password encryptedPassword: credentialData.password
? FieldCrypto.encryptField( ? FieldCrypto.encryptField(
credentialData.password, credentialData.password,
@@ -660,7 +567,6 @@ class SharedCredentialManager {
fieldName, fieldName,
); );
} catch (error) { } catch (error) {
// If decryption fails, value might not be encrypted (legacy data)
databaseLogger.warn("Field decryption failed, returning as-is", { databaseLogger.warn("Field decryption failed, returning as-is", {
operation: "decrypt_field", operation: "decrypt_field",
fieldName, fieldName,
@@ -675,12 +581,11 @@ class SharedCredentialManager {
originalCredentialId: number, originalCredentialId: number,
targetUserId: string, targetUserId: string,
): Promise<void> { ): Promise<void> {
// Create placeholder with needsReEncryption flag
await db.insert(sharedCredentials).values({ await db.insert(sharedCredentials).values({
hostAccessId, hostAccessId,
originalCredentialId, originalCredentialId,
targetUserId, targetUserId,
encryptedUsername: "", // Will be filled during re-encryption encryptedUsername: "",
encryptedAuthType: "", encryptedAuthType: "",
needsReEncryption: true, needsReEncryption: true,
}); });
@@ -697,7 +602,6 @@ class SharedCredentialManager {
userId: string, userId: string,
): Promise<void> { ): Promise<void> {
try { try {
// Get the shared credential
const sharedCred = await db const sharedCred = await db
.select() .select()
.from(sharedCredentials) .from(sharedCredentials)
@@ -714,7 +618,6 @@ class SharedCredentialManager {
const cred = sharedCred[0]; const cred = sharedCred[0];
// Get the host access to find the owner
const access = await db const access = await db
.select() .select()
.from(hostAccess) .from(hostAccess)
@@ -732,7 +635,6 @@ class SharedCredentialManager {
const ownerId = access[0].ssh_data.userId; const ownerId = access[0].ssh_data.userId;
// Get user's DEK (must be available)
const userDEK = DataCrypto.getUserDataKey(userId); const userDEK = DataCrypto.getUserDataKey(userId);
if (!userDEK) { if (!userDEK) {
databaseLogger.warn("Re-encrypt: user DEK not available", { databaseLogger.warn("Re-encrypt: user DEK not available", {
@@ -740,29 +642,19 @@ class SharedCredentialManager {
sharedCredId, sharedCredId,
userId, userId,
}); });
// User offline, keep pending
return; return;
} }
// Try owner's DEK first
const ownerDEK = DataCrypto.getUserDataKey(ownerId); const ownerDEK = DataCrypto.getUserDataKey(ownerId);
let credentialData: CredentialData; let credentialData: CredentialData;
if (ownerDEK) { if (ownerDEK) {
// Owner online - use owner's DEK
credentialData = await this.getDecryptedCredential( credentialData = await this.getDecryptedCredential(
cred.originalCredentialId, cred.originalCredentialId,
ownerId, ownerId,
ownerDEK, ownerDEK,
); );
} else { } else {
// Owner offline - use system key fallback
databaseLogger.info("Re-encrypt: using system key (owner offline)", {
operation: "reencrypt_system_key",
sharedCredId,
ownerId,
});
try { try {
credentialData = await this.getDecryptedCredentialViaSystemKey( credentialData = await this.getDecryptedCredentialViaSystemKey(
cred.originalCredentialId, cred.originalCredentialId,
@@ -776,12 +668,10 @@ class SharedCredentialManager {
error: error instanceof Error ? error.message : "Unknown error", error: error instanceof Error ? error.message : "Unknown error",
}, },
); );
// Keep pending if system fields don't exist yet
return; return;
} }
} }
// Re-encrypt for user
const encryptedForTarget = this.encryptCredentialForUser( const encryptedForTarget = this.encryptCredentialForUser(
credentialData, credentialData,
userId, userId,
@@ -789,7 +679,6 @@ class SharedCredentialManager {
cred.hostAccessId, cred.hostAccessId,
); );
// Update shared credential
await db await db
.update(sharedCredentials) .update(sharedCredentials)
.set({ .set({
@@ -798,12 +687,6 @@ class SharedCredentialManager {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}) })
.where(eq(sharedCredentials.id, sharedCredId)); .where(eq(sharedCredentials.id, sharedCredId));
databaseLogger.info("Re-encrypted shared credential successfully", {
operation: "reencrypt_shared_credential",
sharedCredId,
userId,
});
} catch (error) { } catch (error) {
databaseLogger.error("Failed to re-encrypt shared credential", error, { databaseLogger.error("Failed to re-encrypt shared credential", error, {
operation: "reencrypt_shared_credential", operation: "reencrypt_shared_credential",

View File

@@ -28,7 +28,6 @@ class SimpleDBOps {
userDataKey, userDataKey,
); );
// Also encrypt with system key for ssh_credentials (offline sharing)
if (tableName === "ssh_credentials") { if (tableName === "ssh_credentials") {
const { SystemCrypto } = await import("./system-crypto.js"); const { SystemCrypto } = await import("./system-crypto.js");
const systemCrypto = SystemCrypto.getInstance(); const systemCrypto = SystemCrypto.getInstance();
@@ -125,7 +124,6 @@ class SimpleDBOps {
userDataKey, userDataKey,
); );
// Also encrypt with system key for ssh_credentials (offline sharing)
if (tableName === "ssh_credentials") { if (tableName === "ssh_credentials") {
const { SystemCrypto } = await import("./system-crypto.js"); const { SystemCrypto } = await import("./system-crypto.js");
const systemCrypto = SystemCrypto.getInstance(); const systemCrypto = SystemCrypto.getInstance();

View File

@@ -25,22 +25,25 @@ export async function createSocks5Connection(
targetPort: number, targetPort: number,
socks5Config: SOCKS5Config, socks5Config: SOCKS5Config,
): Promise<net.Socket | null> { ): Promise<net.Socket | null> {
// If SOCKS5 is not enabled, return null
if (!socks5Config.useSocks5) { if (!socks5Config.useSocks5) {
return null; return null;
} }
// If proxy chain is provided, use chain connection if (
if (socks5Config.socks5ProxyChain && socks5Config.socks5ProxyChain.length > 0) { socks5Config.socks5ProxyChain &&
return createProxyChainConnection(targetHost, targetPort, socks5Config.socks5ProxyChain); socks5Config.socks5ProxyChain.length > 0
) {
return createProxyChainConnection(
targetHost,
targetPort,
socks5Config.socks5ProxyChain,
);
} }
// If single proxy is configured, use single proxy connection
if (socks5Config.socks5Host) { if (socks5Config.socks5Host) {
return createSingleProxyConnection(targetHost, targetPort, socks5Config); return createSingleProxyConnection(targetHost, targetPort, socks5Config);
} }
// No proxy configured
return null; return null;
} }
@@ -67,24 +70,9 @@ async function createSingleProxyConnection(
}, },
}; };
sshLogger.info("Creating SOCKS5 connection", {
operation: "socks5_connect",
proxyHost: socks5Config.socks5Host,
proxyPort: socks5Config.socks5Port || 1080,
targetHost,
targetPort,
hasAuth: !!(socks5Config.socks5Username && socks5Config.socks5Password),
});
try { try {
const info = await SocksClient.createConnection(socksOptions); const info = await SocksClient.createConnection(socksOptions);
sshLogger.info("SOCKS5 connection established", {
operation: "socks5_connected",
targetHost,
targetPort,
});
return info.socket; return info.socket;
} catch (error) { } catch (error) {
sshLogger.error("SOCKS5 connection failed", error, { sshLogger.error("SOCKS5 connection failed", error, {
@@ -113,14 +101,6 @@ async function createProxyChainConnection(
} }
const chainPath = proxyChain.map((p) => `${p.host}:${p.port}`).join(" → "); const chainPath = proxyChain.map((p) => `${p.host}:${p.port}`).join(" → ");
sshLogger.info(`Creating SOCKS proxy chain: ${chainPath}${targetHost}:${targetPort}`, {
operation: "socks5_chain_connect",
chainLength: proxyChain.length,
targetHost,
targetPort,
proxies: proxyChain.map((p) => `${p.host}:${p.port}`),
});
try { try {
const info = await SocksClient.createConnectionChain({ const info = await SocksClient.createConnectionChain({
proxies: proxyChain.map((p) => ({ proxies: proxyChain.map((p) => ({
@@ -129,7 +109,7 @@ async function createProxyChainConnection(
type: p.type, type: p.type,
userId: p.username, userId: p.username,
password: p.password, password: p.password,
timeout: 10000, // 10-second timeout for each hop timeout: 10000,
})), })),
command: "connect", command: "connect",
destination: { destination: {
@@ -137,15 +117,6 @@ async function createProxyChainConnection(
port: targetPort, port: targetPort,
}, },
}); });
sshLogger.info(`✓ Proxy chain established: ${chainPath}${targetHost}:${targetPort}`, {
operation: "socks5_chain_connected",
chainLength: proxyChain.length,
targetHost,
targetPort,
fullPath: `${chainPath}${targetHost}:${targetPort}`,
});
return info.socket; return info.socket;
} catch (error) { } catch (error) {
sshLogger.error("SOCKS proxy chain connection failed", error, { sshLogger.error("SOCKS proxy chain connection failed", error, {

View File

@@ -28,7 +28,6 @@ export interface TerminalTheme {
} }
export const TERMINAL_THEMES: Record<string, TerminalTheme> = { export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
// Legacy "termix" theme - auto-switches between termixDark and termixLight based on app theme
termix: { termix: {
name: "Termix Default", name: "Termix Default",
category: "dark", category: "dark",

View File

@@ -39,13 +39,11 @@ export function useConfirmation() {
opts: ConfirmationOptions | string, opts: ConfirmationOptions | string,
callback?: () => void, callback?: () => void,
): Promise<boolean> => { ): Promise<boolean> => {
// Legacy signature support
if (typeof opts === "string" && callback) { if (typeof opts === "string" && callback) {
callback(); callback();
return Promise.resolve(true); return Promise.resolve(true);
} }
// New Promise-based signature
return Promise.resolve(true); return Promise.resolve(true);
}; };

View File

@@ -48,19 +48,18 @@
--sidebar-border: #e4e4e7; --sidebar-border: #e4e4e7;
--sidebar-ring: #a1a1aa; --sidebar-ring: #a1a1aa;
/* NEW SEMANTIC VARIABLES - Light Mode Backgrounds */
--bg-base: #fcfcfc; --bg-base: #fcfcfc;
--bg-elevated: #ffffff; --bg-elevated: #ffffff;
--bg-surface: #f3f4f6; --bg-surface: #f3f4f6;
--bg-surface-hover: #e5e7eb; /* Panel hover - replaces dark-bg-panel-hover */ --bg-surface-hover: #e5e7eb;
--bg-input: #ffffff; --bg-input: #ffffff;
--bg-deepest: #e5e7eb; --bg-deepest: #e5e7eb;
--bg-header: #eeeeef; --bg-header: #eeeeef;
--bg-button: #f3f4f6; --bg-button: #f3f4f6;
--bg-active: #e5e7eb; --bg-active: #e5e7eb;
--bg-light: #fafafa; /* Light background - replaces dark-bg-light */ --bg-light: #fafafa;
--bg-subtle: #f5f5f5; /* Very light background - replaces dark-bg-very-light */ --bg-subtle: #f5f5f5;
--bg-interact: #d1d5db; /* Interactive/active state - replaces dark-active */ --bg-interact: #d1d5db;
--border-base: #e5e7eb; --border-base: #e5e7eb;
--border-panel: #d1d5db; --border-panel: #d1d5db;
--border-subtle: #f3f4f6; --border-subtle: #f3f4f6;
@@ -71,16 +70,13 @@
--border-hover: #d1d5db; --border-hover: #d1d5db;
--border-active: #9ca3af; --border-active: #9ca3af;
/* NEW SEMANTIC VARIABLES - Light Mode Text Colors */
--foreground-secondary: #334155; --foreground-secondary: #334155;
--foreground-subtle: #94a3b8; --foreground-subtle: #94a3b8;
/* Scrollbar Colors - Light Mode */
--scrollbar-thumb: #c1c1c3; --scrollbar-thumb: #c1c1c3;
--scrollbar-thumb-hover: #a1a1a3; --scrollbar-thumb-hover: #a1a1a3;
--scrollbar-track: #f3f4f6; --scrollbar-track: #f3f4f6;
/* Modal Overlay - Light Mode */
--bg-overlay: rgba(0, 0, 0, 0.5); --bg-overlay: rgba(0, 0, 0, 0.5);
} }
@@ -143,8 +139,6 @@
--color-dark-border-panel: #222224; --color-dark-border-panel: #222224;
--color-dark-bg-panel-hover: #232327; --color-dark-bg-panel-hover: #232327;
/* NEW SEMANTIC COLOR MAPPINGS - Creates Tailwind classes */
/* Backgrounds: bg-canvas, bg-elevated, bg-surface, etc. */
--color-canvas: var(--bg-base); --color-canvas: var(--bg-base);
--color-elevated: var(--bg-elevated); --color-elevated: var(--bg-elevated);
--color-surface: var(--bg-surface); --color-surface: var(--bg-surface);
@@ -160,7 +154,7 @@
--color-hover: var(--bg-hover); --color-hover: var(--bg-hover);
--color-hover-alt: var(--bg-hover-alt); --color-hover-alt: var(--bg-hover-alt);
--color-pressed: var(--bg-pressed); --color-pressed: var(--bg-pressed);
/* Borders: border-edge, border-edge-panel, etc. */
--color-edge: var(--border-base); --color-edge: var(--border-base);
--color-edge-panel: var(--border-panel); --color-edge-panel: var(--border-panel);
--color-edge-subtle: var(--border-subtle); --color-edge-subtle: var(--border-subtle);
@@ -168,11 +162,9 @@
--color-edge-hover: var(--border-hover); --color-edge-hover: var(--border-hover);
--color-edge-active: var(--border-active); --color-edge-active: var(--border-active);
/* NEW SEMANTIC TEXT COLOR MAPPINGS - Creates Tailwind text classes */
--color-foreground-secondary: var(--foreground-secondary); --color-foreground-secondary: var(--foreground-secondary);
--color-foreground-subtle: var(--foreground-subtle); --color-foreground-subtle: var(--foreground-subtle);
/* Modal Overlay Mapping - Creates Tailwind bg-overlay class */
--color-overlay: var(--bg-overlay); --color-overlay: var(--bg-overlay);
} }
@@ -231,16 +223,13 @@
--border-hover: #434345; --border-hover: #434345;
--border-active: #2d2d30; --border-active: #2d2d30;
/* NEW SEMANTIC VARIABLES - Dark Mode Text Color Overrides */ --foreground-secondary: #d1d5db;
--foreground-secondary: #d1d5db; /* Matches text-gray-300 */ --foreground-subtle: #6b7280;
--foreground-subtle: #6b7280; /* Matches text-gray-500 */
/* Scrollbar Colors - Dark Mode */
--scrollbar-thumb: #434345; --scrollbar-thumb: #434345;
--scrollbar-thumb-hover: #5a5a5d; --scrollbar-thumb-hover: #5a5a5d;
--scrollbar-track: #18181b; --scrollbar-track: #18181b;
/* Modal Overlay - Dark Mode */
--bg-overlay: rgba(0, 0, 0, 0.7); --bg-overlay: rgba(0, 0, 0, 0.7);
} }
@@ -259,7 +248,6 @@
} }
} }
/* Thin Scrollbar - Theme Aware */
.thin-scrollbar { .thin-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
@@ -283,7 +271,6 @@
background: var(--scrollbar-thumb-hover); background: var(--scrollbar-thumb-hover);
} }
/* Skinny scrollbar - even thinner variant */
.skinny-scrollbar { .skinny-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent; scrollbar-color: var(--scrollbar-thumb) transparent;

View File

@@ -1,17 +1,3 @@
/**
* Terminal Syntax Highlighter
*
* Adds syntax highlighting to terminal output by injecting ANSI color codes
* for common patterns like commands, paths, IPs, log levels, and keywords.
*
* Features:
* - Preserves existing ANSI codes from SSH output
* - Performance-optimized for streaming logs
* - Priority-based pattern matching to avoid overlaps
* - Configurable via localStorage
*/
// ANSI escape code constants
const ANSI_CODES = { const ANSI_CODES = {
reset: "\x1b[0m", reset: "\x1b[0m",
colors: { colors: {
@@ -22,7 +8,7 @@ const ANSI_CODES = {
magenta: "\x1b[35m", magenta: "\x1b[35m",
cyan: "\x1b[36m", cyan: "\x1b[36m",
white: "\x1b[37m", white: "\x1b[37m",
brightBlack: "\x1b[90m", // Gray brightBlack: "\x1b[90m",
brightRed: "\x1b[91m", brightRed: "\x1b[91m",
brightGreen: "\x1b[92m", brightGreen: "\x1b[92m",
brightYellow: "\x1b[93m", brightYellow: "\x1b[93m",
@@ -39,16 +25,14 @@ const ANSI_CODES = {
}, },
} as const; } as const;
// Pattern definition interface
interface HighlightPattern { interface HighlightPattern {
name: string; name: string;
regex: RegExp; regex: RegExp;
ansiCode: string; ansiCode: string;
priority: number; priority: number;
quickCheck?: string; // Optional fast string.includes() check quickCheck?: string;
} }
// Match result interface for tracking ranges
interface MatchResult { interface MatchResult {
start: number; start: number;
end: number; end: number;
@@ -56,16 +40,10 @@ interface MatchResult {
priority: number; priority: number;
} }
// Configuration const MAX_LINE_LENGTH = 5000;
const MAX_LINE_LENGTH = 5000; // Skip highlighting for very long lines const MAX_ANSI_CODES = 10;
const MAX_ANSI_CODES = 10; // Skip if text has many ANSI codes (likely already colored/interactive app)
// Pattern definitions by category (pre-compiled)
// Based on SecureCRT proven patterns with strict boundaries
const PATTERNS: HighlightPattern[] = [ const PATTERNS: HighlightPattern[] = [
// Priority 1: IP Addresses (HIGHEST - from SecureCRT line 94)
// Matches: 192.168.1.1, 10.0.0.5, 127.0.0.1:8080
// WON'T match: dates like "2025" or "03:11:36"
{ {
name: "ipv4", name: "ipv4",
regex: regex:
@@ -74,7 +52,6 @@ const PATTERNS: HighlightPattern[] = [
priority: 10, priority: 10,
}, },
// Priority 2: Log Levels - Error (bright red)
{ {
name: "log-error", name: "log-error",
regex: regex:
@@ -83,7 +60,6 @@ const PATTERNS: HighlightPattern[] = [
priority: 9, priority: 9,
}, },
// Priority 3: Log Levels - Warning (yellow)
{ {
name: "log-warn", name: "log-warn",
regex: /\b(WARN(?:ING)?|ALERT)\b|\[WARN(?:ING)?\]/gi, regex: /\b(WARN(?:ING)?|ALERT)\b|\[WARN(?:ING)?\]/gi,
@@ -91,7 +67,6 @@ const PATTERNS: HighlightPattern[] = [
priority: 9, priority: 9,
}, },
// Priority 4: Log Levels - Success (bright green)
{ {
name: "log-success", name: "log-success",
regex: regex:
@@ -100,7 +75,6 @@ const PATTERNS: HighlightPattern[] = [
priority: 8, priority: 8,
}, },
// Priority 5: URLs (must start with http/https)
{ {
name: "url", name: "url",
regex: /https?:\/\/[^\s\])}]+/g, regex: /https?:\/\/[^\s\])}]+/g,
@@ -108,9 +82,6 @@ const PATTERNS: HighlightPattern[] = [
priority: 8, priority: 8,
}, },
// Priority 6: Absolute paths - STRICT (must have 2+ segments)
// Matches: /var/log/file.log, /home/user/docs
// WON'T match: /03, /2025, single segments
{ {
name: "path-absolute", name: "path-absolute",
regex: /\/[a-zA-Z][a-zA-Z0-9_\-@.]*(?:\/[a-zA-Z0-9_\-@.]+)+/g, regex: /\/[a-zA-Z][a-zA-Z0-9_\-@.]*(?:\/[a-zA-Z0-9_\-@.]+)+/g,
@@ -118,7 +89,6 @@ const PATTERNS: HighlightPattern[] = [
priority: 7, priority: 7,
}, },
// Priority 7: Home paths
{ {
name: "path-home", name: "path-home",
regex: /~\/[a-zA-Z0-9_\-@./]+/g, regex: /~\/[a-zA-Z0-9_\-@./]+/g,
@@ -126,7 +96,6 @@ const PATTERNS: HighlightPattern[] = [
priority: 7, priority: 7,
}, },
// Priority 8: Other log levels
{ {
name: "log-info", name: "log-info",
regex: /\bINFO\b|\[INFO\]/gi, regex: /\bINFO\b|\[INFO\]/gi,
@@ -141,11 +110,7 @@ const PATTERNS: HighlightPattern[] = [
}, },
]; ];
/**
* Check if text contains existing ANSI escape sequences
*/
function hasExistingAnsiCodes(text: string): boolean { function hasExistingAnsiCodes(text: string): boolean {
// Count all ANSI escape sequences (not just CSI)
const ansiCount = ( const ansiCount = (
text.match( text.match(
/\x1b[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g, /\x1b[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g,
@@ -154,17 +119,10 @@ function hasExistingAnsiCodes(text: string): boolean {
return ansiCount > MAX_ANSI_CODES; return ansiCount > MAX_ANSI_CODES;
} }
/**
* Check if text appears to be incomplete (partial ANSI sequence at end)
*/
function hasIncompleteAnsiSequence(text: string): boolean { function hasIncompleteAnsiSequence(text: string): boolean {
// Check if text ends with incomplete ANSI escape sequence
return /\x1b(?:\[(?:[0-9;]*)?)?$/.test(text); return /\x1b(?:\[(?:[0-9;]*)?)?$/.test(text);
} }
/**
* Parse text into segments: plain text and ANSI codes
*/
interface TextSegment { interface TextSegment {
isAnsi: boolean; isAnsi: boolean;
content: string; content: string;
@@ -172,13 +130,11 @@ interface TextSegment {
function parseAnsiSegments(text: string): TextSegment[] { function parseAnsiSegments(text: string): TextSegment[] {
const segments: TextSegment[] = []; const segments: TextSegment[] = [];
// More comprehensive ANSI regex - matches SGR (colors), cursor movement, erase sequences, etc.
const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;]*[@-~])/g; const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;]*[@-~])/g;
let lastIndex = 0; let lastIndex = 0;
let match; let match;
while ((match = ansiRegex.exec(text)) !== null) { while ((match = ansiRegex.exec(text)) !== null) {
// Plain text before ANSI code
if (match.index > lastIndex) { if (match.index > lastIndex) {
segments.push({ segments.push({
isAnsi: false, isAnsi: false,
@@ -186,7 +142,6 @@ function parseAnsiSegments(text: string): TextSegment[] {
}); });
} }
// ANSI code itself
segments.push({ segments.push({
isAnsi: true, isAnsi: true,
content: match[0], content: match[0],
@@ -195,7 +150,6 @@ function parseAnsiSegments(text: string): TextSegment[] {
lastIndex = ansiRegex.lastIndex; lastIndex = ansiRegex.lastIndex;
} }
// Remaining plain text
if (lastIndex < text.length) { if (lastIndex < text.length) {
segments.push({ segments.push({
isAnsi: false, isAnsi: false,
@@ -206,25 +160,18 @@ function parseAnsiSegments(text: string): TextSegment[] {
return segments; return segments;
} }
/**
* Apply highlights to plain text (no ANSI codes)
*/
function highlightPlainText(text: string): string { function highlightPlainText(text: string): string {
// Skip very long lines for performance
if (text.length > MAX_LINE_LENGTH) { if (text.length > MAX_LINE_LENGTH) {
return text; return text;
} }
// Skip if text is empty or whitespace
if (!text.trim()) { if (!text.trim()) {
return text; return text;
} }
// Find all matches for all patterns
const matches: MatchResult[] = []; const matches: MatchResult[] = [];
for (const pattern of PATTERNS) { for (const pattern of PATTERNS) {
// Reset regex lastIndex
pattern.regex.lastIndex = 0; pattern.regex.lastIndex = 0;
let match; let match;
@@ -238,12 +185,10 @@ function highlightPlainText(text: string): string {
} }
} }
// If no matches, return original text
if (matches.length === 0) { if (matches.length === 0) {
return text; return text;
} }
// Sort matches by priority (descending) then by position
matches.sort((a, b) => { matches.sort((a, b) => {
if (a.priority !== b.priority) { if (a.priority !== b.priority) {
return b.priority - a.priority; return b.priority - a.priority;
@@ -251,7 +196,6 @@ function highlightPlainText(text: string): string {
return a.start - b.start; return a.start - b.start;
}); });
// Filter out overlapping matches (keep higher priority)
const appliedRanges: Array<{ start: number; end: number }> = []; const appliedRanges: Array<{ start: number; end: number }> = [];
const finalMatches = matches.filter((match) => { const finalMatches = matches.filter((match) => {
const overlaps = appliedRanges.some( const overlaps = appliedRanges.some(
@@ -268,7 +212,6 @@ function highlightPlainText(text: string): string {
return false; return false;
}); });
// Apply ANSI codes from end to start (to preserve indices)
let result = text; let result = text;
finalMatches.reverse().forEach((match) => { finalMatches.reverse().forEach((match) => {
const before = result.slice(0, match.start); const before = result.slice(0, match.start);
@@ -281,41 +224,28 @@ function highlightPlainText(text: string): string {
return result; return result;
} }
/**
* Main export: Highlight terminal output text
*
* @param text - Terminal output text (may contain ANSI codes)
* @returns Text with syntax highlighting applied
*/
export function highlightTerminalOutput(text: string): string { export function highlightTerminalOutput(text: string): string {
// Early exit for empty or whitespace-only text
if (!text || !text.trim()) { if (!text || !text.trim()) {
return text; return text;
} }
// Skip highlighting if text has incomplete ANSI sequence (streaming chunk)
if (hasIncompleteAnsiSequence(text)) { if (hasIncompleteAnsiSequence(text)) {
return text; return text;
} }
// Skip highlighting if text already has many ANSI codes
// (likely already styled by SSH output or application)
if (hasExistingAnsiCodes(text)) { if (hasExistingAnsiCodes(text)) {
return text; return text;
} }
// Parse text into segments (plain text vs ANSI codes)
const segments = parseAnsiSegments(text); const segments = parseAnsiSegments(text);
// If no ANSI codes found, highlight entire text
if (segments.length === 0) { if (segments.length === 0) {
return highlightPlainText(text); return highlightPlainText(text);
} }
// Highlight only plain text segments, preserve ANSI segments
const highlightedSegments = segments.map((segment) => { const highlightedSegments = segments.map((segment) => {
if (segment.isAnsi) { if (segment.isAnsi) {
return segment.content; // Preserve existing ANSI codes return segment.content;
} else { } else {
return highlightPlainText(segment.content); return highlightPlainText(segment.content);
} }
@@ -324,15 +254,10 @@ export function highlightTerminalOutput(text: string): string {
return highlightedSegments.join(""); return highlightedSegments.join("");
} }
/**
* Check if syntax highlighting is enabled in localStorage
* Defaults to false if not set (opt-in behavior - BETA feature)
*/
export function isSyntaxHighlightingEnabled(): boolean { export function isSyntaxHighlightingEnabled(): boolean {
try { try {
return localStorage.getItem("terminalSyntaxHighlighting") === "true"; return localStorage.getItem("terminalSyntaxHighlighting") === "true";
} catch { } catch {
// If localStorage is not available, default to disabled
return false; return false;
} }
} }

View File

@@ -2328,4 +2328,4 @@
"noContainersMatchFiltersHint": "التبديل إلى الوضع الداكن" "noContainersMatchFiltersHint": "التبديل إلى الوضع الداكن"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "আলোতে স্যুইচ করুন", "switchToLight": "আলোতে স্যুইচ করুন",
"switchToDark": "অন্ধকারে স্যুইচ করুন" "switchToDark": "অন্ধকারে স্যুইচ করুন"
} }
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "Spusťte kontejner pro přístup ke konzoli", "switchToLight": "Spusťte kontejner pro přístup ke konzoli",
"switchToDark": "Přepnout na světlou verzi" "switchToDark": "Přepnout na světlou verzi"
} }
} }

View File

@@ -2360,4 +2360,4 @@
"console": "Auf Dunkel umschalten" "console": "Auf Dunkel umschalten"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2376,4 +2376,4 @@
"startContainerToAccess": "Μετάβαση σε σκούρο" "startContainerToAccess": "Μετάβαση σε σκούρο"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "Cambiar a Claro", "switchToLight": "Cambiar a Claro",
"switchToDark": "Cambiar a Oscuro" "switchToDark": "Cambiar a Oscuro"
} }
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "Passer en mode clair", "switchToLight": "Passer en mode clair",
"switchToDark": "Passer en mode sombre" "switchToDark": "Passer en mode sombre"
} }
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "עבור לבהיר", "switchToLight": "עבור לבהיר",
"switchToDark": "עבור לכהה" "switchToDark": "עבור לכהה"
} }
} }

View File

@@ -2378,4 +2378,4 @@
"theme": { "theme": {
"switchToLight": "डार्क मोड पर स्विच करें" "switchToLight": "डार्क मोड पर स्विच करें"
} }
} }

View File

@@ -2369,4 +2369,4 @@
"clickToConnect": "Beralih ke Gelap" "clickToConnect": "Beralih ke Gelap"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "Passa a chiaro", "switchToLight": "Passa a chiaro",
"switchToDark": "Passa a scuro" "switchToDark": "Passa a scuro"
} }
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "ライトモードに切り替える", "switchToLight": "ライトモードに切り替える",
"switchToDark": "ダークモードに切り替える" "switchToDark": "ダークモードに切り替える"
} }
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "라이트 모드로 전환", "switchToLight": "라이트 모드로 전환",
"switchToDark": "다크 모드로 전환" "switchToDark": "다크 모드로 전환"
} }
} }

View File

@@ -2371,4 +2371,4 @@
"containerNotFound": "Schakelen naar donker" "containerNotFound": "Schakelen naar donker"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2358,4 +2358,4 @@
"errorMessage": "Przełącz na Ciemny" "errorMessage": "Przełącz na Ciemny"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "Alternar para o modo claro", "switchToLight": "Alternar para o modo claro",
"switchToDark": "Alternar para o modo escuro" "switchToDark": "Alternar para o modo escuro"
} }
} }

View File

@@ -2375,4 +2375,4 @@
"consoleTab": "Comutați pe Întunecat" "consoleTab": "Comutați pe Întunecat"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "Переключиться на светлый режим", "switchToLight": "Переключиться на светлый режим",
"switchToDark": "Переключиться на темный режим" "switchToDark": "Переключиться на темный режим"
} }
} }

View File

@@ -2371,4 +2371,4 @@
"containerNotFound": "Växla till mörk" "containerNotFound": "Växla till mörk"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2371,4 +2371,4 @@
"containerNotFound": "สลับเป็นโหมดมืด" "containerNotFound": "สลับเป็นโหมดมืด"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2347,4 +2347,4 @@
"pids": "Koyu moda geç" "pids": "Koyu moda geç"
}, },
"theme": {} "theme": {}
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "Переключитися на світлий режим", "switchToLight": "Переключитися на світлий режим",
"switchToDark": "Переключитися на темний режим" "switchToDark": "Переключитися на темний режим"
} }
} }

View File

@@ -2378,4 +2378,4 @@
"theme": { "theme": {
"switchToLight": "Chuyển sang Tối" "switchToLight": "Chuyển sang Tối"
} }
} }

View File

@@ -2379,4 +2379,4 @@
"switchToLight": "切换到浅色模式", "switchToLight": "切换到浅色模式",
"switchToDark": "切换到深色模式" "switchToDark": "切换到深色模式"
} }
} }

View File

@@ -60,7 +60,6 @@ export interface SSHHost {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
// Shared access metadata (view-only)
isShared?: boolean; isShared?: boolean;
permissionLevel?: "view"; permissionLevel?: "view";
sharedExpiresAt?: string; sharedExpiresAt?: string;
@@ -78,7 +77,7 @@ export interface QuickActionData {
export interface ProxyNode { export interface ProxyNode {
host: string; host: string;
port: number; port: number;
type: 4 | 5; // SOCKS4 or SOCKS5 type: 4 | 5;
username?: string; username?: string;
password?: string; password?: string;
} }
@@ -112,7 +111,6 @@ export interface SSHHostData {
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
notes?: string; notes?: string;
// SOCKS5 Proxy configuration
useSocks5?: boolean; useSocks5?: boolean;
socks5Host?: string; socks5Host?: string;
socks5Port?: number; socks5Port?: number;
@@ -213,11 +211,9 @@ export interface TunnelConnection {
export interface TunnelConfig { export interface TunnelConfig {
name: string; name: string;
// Unique identifiers for collision prevention
sourceHostId: number; sourceHostId: number;
tunnelIndex: number; tunnelIndex: number;
// User context for RBAC
requestingUserId?: string; requestingUserId?: string;
hostName: string; hostName: string;
@@ -249,7 +245,6 @@ export interface TunnelConfig {
autoStart: boolean; autoStart: boolean;
isPinned: boolean; isPinned: boolean;
// SOCKS5 Proxy configuration
useSocks5?: boolean; useSocks5?: boolean;
socks5Host?: string; socks5Host?: string;
socks5Port?: number; socks5Port?: number;
@@ -418,7 +413,7 @@ export interface SplitLayoutOption {
name: string; name: string;
description: string; description: string;
cellCount: number; cellCount: number;
icon: string; // lucide icon name icon: string;
} }
// ============================================================================ // ============================================================================

View File

@@ -43,8 +43,6 @@ function AppContent() {
const lastShiftPressTime = useRef(0); const lastShiftPressTime = useRef(0);
// DEBUG: Theme toggle - double-tap left Alt/Option to toggle light/dark mode
// Comment out the next line and the AltLeft handler below to disable
const lastAltPressTime = useRef(0); const lastAltPressTime = useRef(0);
useEffect(() => { useEffect(() => {
@@ -62,26 +60,20 @@ function AppContent() {
} }
} }
// DEBUG: Double-tap left Alt/Option to toggle light/dark theme
// Remove or comment out this block for production
/* DEBUG_THEME_TOGGLE_START */
if (event.code === "AltLeft" && !event.repeat) { if (event.code === "AltLeft" && !event.repeat) {
const now = Date.now(); const now = Date.now();
if (now - lastAltPressTime.current < 300) { if (now - lastAltPressTime.current < 300) {
// Use setTheme to properly update React state (not just DOM class)
const currentIsDark = const currentIsDark =
theme === "dark" || theme === "dark" ||
(theme === "system" && (theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches); window.matchMedia("(prefers-color-scheme: dark)").matches);
const newTheme = currentIsDark ? "light" : "dark"; const newTheme = currentIsDark ? "light" : "dark";
setTheme(newTheme); setTheme(newTheme);
console.log("[DEBUG] Theme toggled:", newTheme);
lastAltPressTime.current = 0; lastAltPressTime.current = 0;
} else { } else {
lastAltPressTime.current = now; lastAltPressTime.current = now;
} }
} }
/* DEBUG_THEME_TOGGLE_END */
if (event.key === "Escape") { if (event.key === "Escape") {
setIsCommandPaletteOpen(false); setIsCommandPaletteOpen(false);

View File

@@ -72,7 +72,6 @@ export function AdminSettings({
>([]); >([]);
const [usersLoading, setUsersLoading] = React.useState(false); const [usersLoading, setUsersLoading] = React.useState(false);
// New dialog states
const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false); const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false);
const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false); const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false);
const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{ const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{
@@ -216,7 +215,6 @@ export function AdminSettings({
} }
}; };
// New dialog handlers
const handleEditUser = (user: (typeof users)[0]) => { const handleEditUser = (user: (typeof users)[0]) => {
setSelectedUserForEdit(user); setSelectedUserForEdit(user);
setUserEditDialogOpen(true); setUserEditDialogOpen(true);

View File

@@ -34,7 +34,6 @@ export function CreateUserDialog({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Reset form when dialog closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setUsername(""); setUsername("");

View File

@@ -33,7 +33,6 @@ export function LinkAccountDialog({
const [linkTargetUsername, setLinkTargetUsername] = useState(""); const [linkTargetUsername, setLinkTargetUsername] = useState("");
const [linkLoading, setLinkLoading] = useState(false); const [linkLoading, setLinkLoading] = useState(false);
// Reset form when dialog closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setLinkTargetUsername(""); setLinkTargetUsername("");

View File

@@ -114,7 +114,6 @@ export function UserEditDialog({
return; return;
} }
// Close dialog temporarily to show confirmation toast on top
const userToUpdate = user; const userToUpdate = user;
onOpenChange(false); onOpenChange(false);
@@ -165,7 +164,6 @@ export function UserEditDialog({
const handlePasswordReset = async () => { const handlePasswordReset = async () => {
if (!user) return; if (!user) return;
// Close dialog temporarily to show confirmation toast on top
const userToReset = user; const userToReset = user;
onOpenChange(false); onOpenChange(false);
@@ -217,7 +215,6 @@ export function UserEditDialog({
const handleRemoveRole = async (roleId: number) => { const handleRemoveRole = async (roleId: number) => {
if (!user) return; if (!user) return;
// Close dialog temporarily to show confirmation toast on top
const userToUpdate = user; const userToUpdate = user;
onOpenChange(false); onOpenChange(false);
@@ -253,7 +250,6 @@ export function UserEditDialog({
const isRevokingSelf = isCurrentUser; const isRevokingSelf = isCurrentUser;
// Close dialog temporarily to show confirmation toast on top
const userToUpdate = user; const userToUpdate = user;
onOpenChange(false); onOpenChange(false);
@@ -302,7 +298,6 @@ export function UserEditDialog({
return; return;
} }
// Close dialog temporarily to show confirmation toast on top
const userToDelete = user; const userToDelete = user;
onOpenChange(false); onOpenChange(false);
@@ -315,7 +310,6 @@ export function UserEditDialog({
}); });
if (!confirmed) { if (!confirmed) {
// Reopen dialog if user cancels
onOpenChange(true); onOpenChange(true);
return; return;
} }
@@ -366,7 +360,6 @@ export function UserEditDialog({
</DialogHeader> </DialogHeader>
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto thin-scrollbar pr-2"> <div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto thin-scrollbar pr-2">
{/* READ-ONLY INFO SECTION */}
<div className="grid grid-cols-2 gap-4 p-4 bg-surface rounded-lg border border-edge"> <div className="grid grid-cols-2 gap-4 p-4 bg-surface rounded-lg border border-edge">
<div> <div>
<Label className="text-muted-foreground text-xs"> <Label className="text-muted-foreground text-xs">
@@ -402,7 +395,6 @@ export function UserEditDialog({
<Separator /> <Separator />
{/* ADMIN TOGGLE SECTION */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2"> <Label className="text-base font-semibold flex items-center gap-2">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
@@ -430,7 +422,6 @@ export function UserEditDialog({
<Separator /> <Separator />
{/* PASSWORD RESET SECTION */}
{showPasswordReset && ( {showPasswordReset && (
<> <>
<div className="space-y-3"> <div className="space-y-3">
@@ -460,7 +451,6 @@ export function UserEditDialog({
</> </>
)} )}
{/* ROLE MANAGEMENT SECTION */}
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-semibold flex items-center gap-2"> <Label className="text-base font-semibold flex items-center gap-2">
<UserCog className="h-4 w-4" /> <UserCog className="h-4 w-4" />
@@ -473,7 +463,6 @@ export function UserEditDialog({
</div> </div>
) : ( ) : (
<> <>
{/* Current Roles */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm text-muted-foreground"> <Label className="text-sm text-muted-foreground">
{t("rbac.currentRoles")} {t("rbac.currentRoles")}
@@ -520,7 +509,6 @@ export function UserEditDialog({
)} )}
</div> </div>
{/* Assign New Role */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm text-muted-foreground"> <Label className="text-sm text-muted-foreground">
{t("rbac.assignNewRole")} {t("rbac.assignNewRole")}
@@ -560,7 +548,6 @@ export function UserEditDialog({
<Separator /> <Separator />
{/* SESSION MANAGEMENT SECTION */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2"> <Label className="text-base font-semibold flex items-center gap-2">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
@@ -588,7 +575,6 @@ export function UserEditDialog({
<Separator /> <Separator />
{/* DANGER ZONE - DELETE USER */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base font-semibold text-destructive flex items-center gap-2"> <Label className="text-base font-semibold text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />

View File

@@ -39,14 +39,12 @@ export function RolesTab(): React.ReactElement {
const [roles, setRoles] = React.useState<Role[]>([]); const [roles, setRoles] = React.useState<Role[]>([]);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
// Create/Edit Role Dialog
const [roleDialogOpen, setRoleDialogOpen] = React.useState(false); const [roleDialogOpen, setRoleDialogOpen] = React.useState(false);
const [editingRole, setEditingRole] = React.useState<Role | null>(null); const [editingRole, setEditingRole] = React.useState<Role | null>(null);
const [roleName, setRoleName] = React.useState(""); const [roleName, setRoleName] = React.useState("");
const [roleDisplayName, setRoleDisplayName] = React.useState(""); const [roleDisplayName, setRoleDisplayName] = React.useState("");
const [roleDescription, setRoleDescription] = React.useState(""); const [roleDescription, setRoleDescription] = React.useState("");
// Load roles
const loadRoles = React.useCallback(async () => { const loadRoles = React.useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -65,7 +63,6 @@ export function RolesTab(): React.ReactElement {
loadRoles(); loadRoles();
}, [loadRoles]); }, [loadRoles]);
// Create role
const handleCreateRole = () => { const handleCreateRole = () => {
setEditingRole(null); setEditingRole(null);
setRoleName(""); setRoleName("");
@@ -74,7 +71,6 @@ export function RolesTab(): React.ReactElement {
setRoleDialogOpen(true); setRoleDialogOpen(true);
}; };
// Edit role
const handleEditRole = (role: Role) => { const handleEditRole = (role: Role) => {
setEditingRole(role); setEditingRole(role);
setRoleName(role.name); setRoleName(role.name);
@@ -83,7 +79,6 @@ export function RolesTab(): React.ReactElement {
setRoleDialogOpen(true); setRoleDialogOpen(true);
}; };
// Save role
const handleSaveRole = async () => { const handleSaveRole = async () => {
if (!roleDisplayName.trim()) { if (!roleDisplayName.trim()) {
toast.error(t("rbac.roleDisplayNameRequired")); toast.error(t("rbac.roleDisplayNameRequired"));
@@ -97,14 +92,12 @@ export function RolesTab(): React.ReactElement {
try { try {
if (editingRole) { if (editingRole) {
// Update existing role
await updateRole(editingRole.id, { await updateRole(editingRole.id, {
displayName: roleDisplayName, displayName: roleDisplayName,
description: roleDescription || null, description: roleDescription || null,
}); });
toast.success(t("rbac.roleUpdatedSuccessfully")); toast.success(t("rbac.roleUpdatedSuccessfully"));
} else { } else {
// Create new role
await createRole({ await createRole({
name: roleName, name: roleName,
displayName: roleDisplayName, displayName: roleDisplayName,
@@ -120,7 +113,6 @@ export function RolesTab(): React.ReactElement {
} }
}; };
// Delete role
const handleDeleteRole = async (role: Role) => { const handleDeleteRole = async (role: Role) => {
const confirmed = await confirmWithToast({ const confirmed = await confirmWithToast({
title: t("rbac.confirmDeleteRole"), title: t("rbac.confirmDeleteRole"),

View File

@@ -329,7 +329,6 @@ export function CommandPalette({
? host.name ? host.name
: `${host.username}@${host.ip}:${host.port}`; : `${host.username}@${host.ip}:${host.port}`;
// Parse statsConfig to determine if metrics should be shown
let shouldShowMetrics = true; let shouldShowMetrics = true;
try { try {
const statsConfig = host.statsConfig const statsConfig = host.statsConfig
@@ -340,7 +339,6 @@ export function CommandPalette({
shouldShowMetrics = true; shouldShowMetrics = true;
} }
// Check if host has at least one tunnel connection
let hasTunnelConnections = false; let hasTunnelConnections = false;
try { try {
const tunnelConnections = Array.isArray( const tunnelConnections = Array.isArray(

View File

@@ -600,10 +600,8 @@ export function Dashboard({
) : ( ) : (
recentActivity recentActivity
.filter((item, index, array) => { .filter((item, index, array) => {
// Always show the first item
if (index === 0) return true; if (index === 0) return true;
// Show if different from previous item (by hostId and type)
const prevItem = array[index - 1]; const prevItem = array[index - 1];
return !( return !(
item.hostId === prevItem.hostId && item.hostId === prevItem.hostId &&

View File

@@ -21,9 +21,6 @@ import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.ts
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx"; import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import { ContainerList } from "./components/ContainerList.tsx"; import { ContainerList } from "./components/ContainerList.tsx";
import { LogViewer } from "./components/LogViewer.tsx";
import { ContainerStats } from "./components/ContainerStats.tsx";
import { ConsoleTerminal } from "./components/ConsoleTerminal.tsx";
import { ContainerDetail } from "./components/ContainerDetail.tsx"; import { ContainerDetail } from "./components/ContainerDetail.tsx";
interface DockerManagerProps { interface DockerManagerProps {
@@ -105,7 +102,6 @@ export function DockerManager({
window.removeEventListener("ssh-hosts:changed", handleHostsChanged); window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]); }, [hostConfig?.id]);
// SSH session lifecycle
React.useEffect(() => { React.useEffect(() => {
const initSession = async () => { const initSession = async () => {
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) { if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
@@ -119,7 +115,6 @@ export function DockerManager({
await connectDockerSession(sid, currentHostConfig.id); await connectDockerSession(sid, currentHostConfig.id);
setSessionId(sid); setSessionId(sid);
// Validate Docker availability
setIsValidating(true); setIsValidating(true);
const validation = await validateDockerAvailability(sid); const validation = await validateDockerAvailability(sid);
setDockerValidation(validation); setDockerValidation(validation);
@@ -152,7 +147,6 @@ export function DockerManager({
}; };
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]); }, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
// Keepalive interval
React.useEffect(() => { React.useEffect(() => {
if (!sessionId || !isVisible) return; if (!sessionId || !isVisible) return;
@@ -163,12 +157,11 @@ export function DockerManager({
}); });
}, },
10 * 60 * 1000, 10 * 60 * 1000,
); // Every 10 minutes );
return () => clearInterval(keepalive); return () => clearInterval(keepalive);
}, [sessionId, isVisible]); }, [sessionId, isVisible]);
// Refresh containers function
const refreshContainers = React.useCallback(async () => { const refreshContainers = React.useCallback(async () => {
if (!sessionId) return; if (!sessionId) return;
try { try {
@@ -179,7 +172,6 @@ export function DockerManager({
} }
}, [sessionId]); }, [sessionId]);
// Poll containers
React.useEffect(() => { React.useEffect(() => {
if (!sessionId || !isVisible || !dockerValidation?.available) return; if (!sessionId || !isVisible || !dockerValidation?.available) return;
@@ -196,8 +188,8 @@ export function DockerManager({
} }
}; };
pollContainers(); // Initial fetch pollContainers();
const interval = setInterval(pollContainers, 5000); // Poll every 5 seconds const interval = setInterval(pollContainers, 5000);
return () => { return () => {
cancelled = true; cancelled = true;
@@ -229,7 +221,6 @@ export function DockerManager({
? "h-full w-full text-foreground overflow-hidden bg-transparent" ? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"; : "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
// Check if Docker is enabled
if (!currentHostConfig?.enableDocker) { if (!currentHostConfig?.enableDocker) {
return ( return (
<div style={wrapperStyle} className={containerClass}> <div style={wrapperStyle} className={containerClass}>
@@ -256,7 +247,6 @@ export function DockerManager({
); );
} }
// Loading state
if (isConnecting || isValidating) { if (isConnecting || isValidating) {
return ( return (
<div style={wrapperStyle} className={containerClass}> <div style={wrapperStyle} className={containerClass}>
@@ -287,7 +277,6 @@ export function DockerManager({
); );
} }
// Docker not available
if (dockerValidation && !dockerValidation.available) { if (dockerValidation && !dockerValidation.available) {
return ( return (
<div style={wrapperStyle} className={containerClass}> <div style={wrapperStyle} className={containerClass}>

View File

@@ -46,7 +46,6 @@ export function ConsoleTerminal({
const getWebSocketBaseUrl = React.useCallback(() => { const getWebSocketBaseUrl = React.useCallback(() => {
const isElectronApp = isElectron(); const isElectronApp = isElectron();
// Development mode check (similar to Terminal.tsx)
const isDev = const isDev =
!isElectronApp && !isElectronApp &&
process.env.NODE_ENV === "development" && process.env.NODE_ENV === "development" &&
@@ -55,28 +54,23 @@ export function ConsoleTerminal({
window.location.port === ""); window.location.port === "");
if (isDev) { if (isDev) {
// Development: connect directly to port 30008
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//localhost:30008`; return `${protocol}//localhost:30008`;
} }
if (isElectronApp) { if (isElectronApp) {
// Electron: construct URL from configured server
const baseUrl = const baseUrl =
(window as { configuredServerUrl?: string }).configuredServerUrl || (window as { configuredServerUrl?: string }).configuredServerUrl ||
"http://127.0.0.1:30001"; "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://"; const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, ""); const wsHost = baseUrl.replace(/^https?:\/\//, "");
// Use nginx path routing, not direct port
return `${wsProtocol}${wsHost}/docker/console/`; return `${wsProtocol}${wsHost}/docker/console/`;
} }
// Production web: use nginx proxy path (same as Terminal uses /ssh/websocket/)
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/docker/console/`; return `${protocol}//${window.location.host}/docker/console/`;
}, []); }, []);
// Initialize terminal
React.useEffect(() => { React.useEffect(() => {
if (!terminal) return; if (!terminal) return;
@@ -94,7 +88,6 @@ export function ConsoleTerminal({
terminal.options.fontSize = 14; terminal.options.fontSize = 14;
terminal.options.fontFamily = "monospace"; terminal.options.fontFamily = "monospace";
// Get theme colors from CSS variables
const backgroundColor = getComputedStyle(document.documentElement) const backgroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--bg-elevated") .getPropertyValue("--bg-elevated")
.trim(); .trim();
@@ -132,13 +125,10 @@ export function ConsoleTerminal({
return () => { return () => {
window.removeEventListener("resize", resizeHandler); window.removeEventListener("resize", resizeHandler);
// Clean up WebSocket before disposing terminal
if (wsRef.current) { if (wsRef.current) {
try { try {
wsRef.current.send(JSON.stringify({ type: "disconnect" })); wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) { } catch (error) {}
// Ignore errors during cleanup
}
wsRef.current.close(); wsRef.current.close();
wsRef.current = null; wsRef.current = null;
} }
@@ -151,9 +141,7 @@ export function ConsoleTerminal({
if (wsRef.current) { if (wsRef.current) {
try { try {
wsRef.current.send(JSON.stringify({ type: "disconnect" })); wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) { } catch (error) {}
// WebSocket might already be closed
}
wsRef.current.close(); wsRef.current.close();
wsRef.current = null; wsRef.current = null;
} }
@@ -161,9 +149,7 @@ export function ConsoleTerminal({
if (terminal) { if (terminal) {
try { try {
terminal.clear(); terminal.clear();
} catch (error) { } catch (error) {}
// Terminal might be disposed
}
} }
}, [terminal, t]); }, [terminal, t]);
@@ -185,7 +171,6 @@ export function ConsoleTerminal({
return; return;
} }
// Ensure terminal is fitted before connecting
if (fitAddonRef.current) { if (fitAddonRef.current) {
fitAddonRef.current.fit(); fitAddonRef.current.fit();
} }
@@ -194,7 +179,6 @@ export function ConsoleTerminal({
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
ws.onopen = () => { ws.onopen = () => {
// Double-check terminal dimensions
const cols = terminal.cols || 80; const cols = terminal.cols || 80;
const rows = terminal.rows || 24; const rows = terminal.rows || 24;
@@ -225,7 +209,6 @@ export function ConsoleTerminal({
setIsConnected(true); setIsConnected(true);
setIsConnecting(false); setIsConnecting(false);
// Check if shell was changed due to unavailability
if (msg.data?.shellChanged) { if (msg.data?.shellChanged) {
toast.warning( toast.warning(
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`, `Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
@@ -234,13 +217,11 @@ export function ConsoleTerminal({
toast.success(t("docker.connectedTo", { containerName })); toast.success(t("docker.connectedTo", { containerName }));
} }
// Fit terminal and send resize to ensure correct dimensions
setTimeout(() => { setTimeout(() => {
if (fitAddonRef.current) { if (fitAddonRef.current) {
fitAddonRef.current.fit(); fitAddonRef.current.fit();
} }
// Send resize message with correct dimensions
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -284,7 +265,6 @@ export function ConsoleTerminal({
toast.error(t("docker.failedToConnect")); toast.error(t("docker.failedToConnect"));
}; };
// Set up periodic ping to keep connection alive
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current); clearInterval(pingIntervalRef.current);
} }
@@ -292,7 +272,7 @@ export function ConsoleTerminal({
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" })); ws.send(JSON.stringify({ type: "ping" }));
} }
}, 30000); // Ping every 30 seconds }, 30000);
ws.onclose = () => { ws.onclose = () => {
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
@@ -308,7 +288,6 @@ export function ConsoleTerminal({
wsRef.current = ws; wsRef.current = ws;
// Handle terminal input
terminal.onData((data) => { terminal.onData((data) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send( ws.send(
@@ -335,7 +314,6 @@ export function ConsoleTerminal({
containerName, containerName,
]); ]);
// Cleanup WebSocket on unmount (terminal cleanup is handled in the terminal effect)
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
@@ -345,9 +323,7 @@ export function ConsoleTerminal({
if (wsRef.current) { if (wsRef.current) {
try { try {
wsRef.current.send(JSON.stringify({ type: "disconnect" })); wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) { } catch (error) {}
// Ignore errors during cleanup
}
wsRef.current.close(); wsRef.current.close();
wsRef.current = null; wsRef.current = null;
} }
@@ -373,7 +349,6 @@ export function ConsoleTerminal({
return ( return (
<div className="flex flex-col h-full gap-3"> <div className="flex flex-col h-full gap-3">
{/* Controls */}
<Card className="py-3"> <Card className="py-3">
<CardContent className="px-3"> <CardContent className="px-3">
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center"> <div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
@@ -431,17 +406,14 @@ export function ConsoleTerminal({
</CardContent> </CardContent>
</Card> </Card>
{/* Terminal */}
<Card className="flex-1 overflow-hidden pt-1 pb-0"> <Card className="flex-1 overflow-hidden pt-1 pb-0">
<CardContent className="p-0 h-full relative"> <CardContent className="p-0 h-full relative">
{/* Terminal container - always rendered */}
<div <div
ref={xtermRef} ref={xtermRef}
className="h-full w-full" className="h-full w-full"
style={{ display: isConnected ? "block" : "none" }} style={{ display: isConnected ? "block" : "none" }}
/> />
{/* Not connected message */}
{!isConnected && !isConnecting && ( {!isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
@@ -456,7 +428,6 @@ export function ConsoleTerminal({
</div> </div>
)} )}
{/* Connecting message */}
{isConnecting && ( {isConnecting && (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="text-center"> <div className="text-center">

View File

@@ -213,10 +213,8 @@ export function ContainerCard({
const isLoading = const isLoading =
isStarting || isStopping || isRestarting || isPausing || isRemoving; isStarting || isStopping || isRestarting || isPausing || isRemoving;
// Format the created date to be more readable
const formatCreatedDate = (dateStr: string): string => { const formatCreatedDate = (dateStr: string): string => {
try { try {
// Remove the timezone suffix like "+0000 UTC"
const cleanDate = dateStr.replace(/\s*\+\d{4}\s*UTC\s*$/, "").trim(); const cleanDate = dateStr.replace(/\s*\+\d{4}\s*UTC\s*$/, "").trim();
return cleanDate; return cleanDate;
} catch { } catch {
@@ -224,11 +222,9 @@ export function ContainerCard({
} }
}; };
// Parse ports into array of port mappings
const parsePorts = (portsStr: string | undefined): string[] => { const parsePorts = (portsStr: string | undefined): string[] => {
if (!portsStr || portsStr.trim() === "") return []; if (!portsStr || portsStr.trim() === "") return [];
// Split by comma and clean up
return portsStr return portsStr
.split(",") .split(",")
.map((p) => p.trim()) .map((p) => p.trim())

View File

@@ -52,7 +52,6 @@ export function ContainerDetail({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header with back button */}
<div className="flex items-center gap-4 px-4 pt-3 pb-3"> <div className="flex items-center gap-4 px-4 pt-3 pb-3">
<Button variant="ghost" onClick={onBack} size="sm"> <Button variant="ghost" onClick={onBack} size="sm">
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
@@ -67,7 +66,6 @@ export function ContainerDetail({
</div> </div>
<Separator className="p-0.25 w-full" /> <Separator className="p-0.25 w-full" />
{/* Tabs for Logs, Stats, Console */}
<div className="flex-1 overflow-hidden min-h-0"> <div className="flex-1 overflow-hidden min-h-0">
<Tabs <Tabs
value={activeTab} value={activeTab}

View File

@@ -70,7 +70,6 @@ export function ContainerList({
return ( return (
<div className="flex flex-col h-full gap-3"> <div className="flex flex-col h-full gap-3">
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1"> <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" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -106,7 +105,6 @@ export function ContainerList({
</div> </div>
</div> </div>
{/* Container Grid */}
{filteredContainers.length === 0 ? ( {filteredContainers.length === 0 ? (
<div className="flex items-center justify-center flex-1"> <div className="flex items-center justify-center flex-1">
<div className="text-center space-y-2"> <div className="text-center space-y-2">

View File

@@ -53,7 +53,6 @@ export function ContainerStats({
React.useEffect(() => { React.useEffect(() => {
fetchStats(); fetchStats();
// Poll stats every 2 seconds
const interval = setInterval(fetchStats, 2000); const interval = setInterval(fetchStats, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
@@ -114,7 +113,6 @@ export function ContainerStats({
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 h-full overflow-auto thin-scrollbar"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3 h-full overflow-auto thin-scrollbar">
{/* CPU Usage */}
<Card className="py-3"> <Card className="py-3">
<CardHeader className="pb-2 px-4"> <CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
@@ -137,7 +135,6 @@ export function ContainerStats({
</CardContent> </CardContent>
</Card> </Card>
{/* Memory Usage */}
<Card className="py-3"> <Card className="py-3">
<CardHeader className="pb-2 px-4"> <CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
@@ -168,7 +165,6 @@ export function ContainerStats({
</CardContent> </CardContent>
</Card> </Card>
{/* Network I/O */}
<Card className="py-3"> <Card className="py-3">
<CardHeader className="pb-2 px-4"> <CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
@@ -194,7 +190,6 @@ export function ContainerStats({
</CardContent> </CardContent>
</Card> </Card>
{/* Block I/O */}
<Card className="py-3"> <Card className="py-3">
<CardHeader className="pb-2 px-4"> <CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
@@ -228,7 +223,6 @@ export function ContainerStats({
</CardContent> </CardContent>
</Card> </Card>
{/* Container Info */}
<Card className="md:col-span-2 py-3"> <Card className="md:col-span-2 py-3">
<CardHeader className="pb-2 px-4"> <CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">

View File

@@ -59,18 +59,16 @@ export function LogViewer({
fetchLogs(); fetchLogs();
}, [fetchLogs]); }, [fetchLogs]);
// Auto-refresh
React.useEffect(() => { React.useEffect(() => {
if (!autoRefresh) return; if (!autoRefresh) return;
const interval = setInterval(() => { const interval = setInterval(() => {
fetchLogs(); fetchLogs();
}, 3000); // Refresh every 3 seconds }, 3000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]); }, [autoRefresh, fetchLogs]);
// Auto-scroll to bottom when new logs arrive
React.useEffect(() => { React.useEffect(() => {
if (autoRefresh && logsEndRef.current) { if (autoRefresh && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: "smooth" }); logsEndRef.current.scrollIntoView({ behavior: "smooth" });
@@ -115,11 +113,9 @@ export function LogViewer({
return ( return (
<div className="flex flex-col h-full gap-3"> <div className="flex flex-col h-full gap-3">
{/* Controls */}
<Card className="py-3"> <Card className="py-3">
<CardContent className="px-3"> <CardContent className="px-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Tail Lines */}
<div className="flex flex-col"> <div className="flex flex-col">
<Label htmlFor="tail-lines" className="mb-1"> <Label htmlFor="tail-lines" className="mb-1">
Lines to show Lines to show
@@ -138,7 +134,6 @@ export function LogViewer({
</Select> </Select>
</div> </div>
{/* Timestamps */}
<div className="flex flex-col"> <div className="flex flex-col">
<Label htmlFor="timestamps" className="mb-1"> <Label htmlFor="timestamps" className="mb-1">
Show Timestamps Show Timestamps
@@ -155,7 +150,6 @@ export function LogViewer({
</div> </div>
</div> </div>
{/* Auto Refresh */}
<div className="flex flex-col"> <div className="flex flex-col">
<Label htmlFor="auto-refresh" className="mb-1"> <Label htmlFor="auto-refresh" className="mb-1">
Auto Refresh Auto Refresh
@@ -172,7 +166,6 @@ export function LogViewer({
</div> </div>
</div> </div>
{/* Actions */}
<div className="flex flex-col"> <div className="flex flex-col">
<Label className="mb-1">Actions</Label> <Label className="mb-1">Actions</Label>
<div className="flex gap-2 h-10"> <div className="flex gap-2 h-10">
@@ -206,7 +199,6 @@ export function LogViewer({
</div> </div>
</div> </div>
{/* Search Filter */}
<div className="mt-2"> <div className="mt-2">
<div className="relative"> <div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -222,7 +214,6 @@ export function LogViewer({
</CardContent> </CardContent>
</Card> </Card>
{/* Logs Display */}
<Card className="flex-1 overflow-hidden py-0"> <Card className="flex-1 overflow-hidden py-0">
<CardContent className="p-0 h-full"> <CardContent className="p-0 h-full">
{isLoading && !logs ? ( {isLoading && !logs ? (

View File

@@ -60,7 +60,6 @@ export function TerminalWindow({
const handleMaximize = () => { const handleMaximize = () => {
maximizeWindow(windowId); maximizeWindow(windowId);
// Trigger resize after maximize/restore
if (resizeTimeoutRef.current) { if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current); clearTimeout(resizeTimeoutRef.current);
} }

View File

@@ -100,7 +100,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }; const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
// Auto-switch terminal theme based on app theme when using "termix" (default)
const isDarkMode = const isDarkMode =
appTheme === "dark" || appTheme === "dark" ||
(appTheme === "system" && (appTheme === "system" &&
@@ -108,7 +107,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
let themeColors; let themeColors;
if (config.theme === "termix") { if (config.theme === "termix") {
// Auto-switch between termixDark and termixLight based on app theme
themeColors = isDarkMode themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors ? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors; : TERMINAL_THEMES.termixLight.colors;
@@ -679,7 +677,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === "data") { if (msg.type === "data") {
if (typeof msg.data === "string") { if (typeof msg.data === "string") {
// Apply syntax highlighting if enabled (BETA - defaults to false/off)
const syntaxHighlightingEnabled = const syntaxHighlightingEnabled =
localStorage.getItem("terminalSyntaxHighlighting") === "true"; localStorage.getItem("terminalSyntaxHighlighting") === "true";
@@ -688,7 +685,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
: msg.data; : msg.data;
terminal.write(outputData); terminal.write(outputData);
// Sudo password prompt detection
const sudoPasswordPattern = const sudoPasswordPattern =
/(?:\[sudo\] password for \S+:|sudo: a password is required)/; /(?:\[sudo\] password for \S+:|sudo: a password is required)/;
const passwordToFill = const passwordToFill =
@@ -724,7 +720,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}, 15000); }, 15000);
} }
} else { } else {
// Apply syntax highlighting to non-string data as well (BETA - defaults to false/off)
const syntaxHighlightingEnabled = const syntaxHighlightingEnabled =
localStorage.getItem("terminalSyntaxHighlighting") === "true"; localStorage.getItem("terminalSyntaxHighlighting") === "true";
@@ -799,7 +794,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
...hostConfig.terminalConfig, ...hostConfig.terminalConfig,
}; };
// Send all environment variables immediately without delays
if ( if (
terminalConfig.environmentVariables && terminalConfig.environmentVariables &&
terminalConfig.environmentVariables.length > 0 terminalConfig.environmentVariables.length > 0
@@ -816,7 +810,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
} }
// Send startup snippet immediately after env vars
if (terminalConfig.startupSnippetId) { if (terminalConfig.startupSnippetId) {
try { try {
const snippets = await getSnippets(); const snippets = await getSnippets();
@@ -837,7 +830,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
} }
// Execute mosh command immediately if enabled
if (terminalConfig.autoMosh && ws.readyState === 1) { if (terminalConfig.autoMosh && ws.readyState === 1) {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -1019,8 +1011,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
setTimeout(() => { setTimeout(() => {
terminal?.focus(); terminal?.focus();
}, 50); }, 50);
console.log(`[Autocomplete] ${currentCmd}${selectedCommand}`);
}, },
[terminal, updateCurrentCommand], [terminal, updateCurrentCommand],
); );
@@ -1043,8 +1033,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
autocompleteHistory.current = autocompleteHistory.current.filter( autocompleteHistory.current = autocompleteHistory.current.filter(
(cmd) => cmd !== command, (cmd) => cmd !== command,
); );
console.log(`[Terminal] Command deleted from history: ${command}`);
} catch (error) { } catch (error) {
console.error("Failed to delete command from history:", error); console.error("Failed to delete command from history:", error);
} }
@@ -1064,7 +1052,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
...hostConfig.terminalConfig, ...hostConfig.terminalConfig,
}; };
// Auto-switch terminal theme based on app theme when using "termix" (default)
let themeColors; let themeColors;
if (config.theme === "termix") { if (config.theme === "termix") {
themeColors = isDarkMode themeColors = isDarkMode
@@ -1142,10 +1129,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.open(xtermRef.current); terminal.open(xtermRef.current);
// Immediately fit to establish correct dimensions
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
if (terminal.cols < 10 || terminal.rows < 3) { if (terminal.cols < 10 || terminal.rows < 3) {
// Terminal opened with invalid dimensions, retry fit in next frame
requestAnimationFrame(() => { requestAnimationFrame(() => {
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
}); });
@@ -1448,14 +1433,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.attachCustomKeyEventHandler(handleCustomKey); terminal.attachCustomKeyEventHandler(handleCustomKey);
}, [terminal]); }, [terminal]);
// Connection initialization effect
useEffect(() => { useEffect(() => {
if (!terminal || !hostConfig || !isVisible) return; if (!terminal || !hostConfig || !isVisible) return;
if (isConnected || isConnecting) return; if (isConnected || isConnecting) return;
// Ensure terminal has valid dimensions before connecting
if (terminal.cols < 10 || terminal.rows < 3) { if (terminal.cols < 10 || terminal.rows < 3) {
// Wait for next frame when dimensions will be valid
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (terminal.cols > 0 && terminal.rows > 0) { if (terminal.cols > 0 && terminal.rows > 0) {
setIsConnecting(true); setIsConnecting(true);
@@ -1475,7 +1457,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
}, [terminal, hostConfig, isVisible, isConnected, isConnecting]); }, [terminal, hostConfig, isVisible, isConnected, isConnecting]);
// Consolidated fitting and focus effect
useEffect(() => { useEffect(() => {
if (!terminal || !fitAddonRef.current || !isVisible) return; if (!terminal || !fitAddonRef.current || !isVisible) return;

View File

@@ -26,7 +26,6 @@ export function TerminalPreview({
}: TerminalPreviewProps) { }: TerminalPreviewProps) {
const { theme: appTheme } = useTheme(); const { theme: appTheme } = useTheme();
// Resolve "termix" to termixDark or termixLight based on app theme
const resolvedTheme = const resolvedTheme =
theme === "termix" theme === "termix"
? appTheme === "dark" || ? appTheme === "dark" ||

View File

@@ -132,15 +132,12 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
try { try {
if (action === "connect") { if (action === "connect") {
// Try to find endpoint host in user's accessible hosts
const endpointHost = allHosts.find( const endpointHost = allHosts.find(
(h) => (h) =>
h.name === tunnel.endpointHost || h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost, `${h.username}@${h.ip}` === tunnel.endpointHost,
); );
// For shared users who don't have access to endpoint host,
// send a minimal config and let backend resolve endpoint details
const tunnelConfig = { const tunnelConfig = {
name: tunnelName, name: tunnelName,
sourceHostId: host.id, sourceHostId: host.id,
@@ -190,20 +187,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
socks5Password: host.socks5Password, socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain, socks5ProxyChain: host.socks5ProxyChain,
}; };
console.log("Tunnel connect config:", {
tunnelName,
sourceHostId: tunnelConfig.sourceHostId,
sourceCredentialId: tunnelConfig.sourceCredentialId,
sourceUserId: tunnelConfig.sourceUserId,
hasSourcePassword: !!tunnelConfig.sourcePassword,
hasSourceKey: !!tunnelConfig.sourceSSHKey,
hasEndpointHost: !!endpointHost,
endpointHost: tunnel.endpointHost,
isShared: (host as any).isShared,
ownerId: (host as any).ownerId,
});
await connectTunnel(tunnelConfig); await connectTunnel(tunnelConfig);
} else if (action === "disconnect") { } else if (action === "disconnect") {
await disconnectTunnel(tunnelName); await disconnectTunnel(tunnelName);

View File

@@ -42,7 +42,6 @@ export function CredentialEditor({
const { t } = useTranslation(); const { t } = useTranslation();
const { theme: appTheme } = useTheme(); const { theme: appTheme } = useTheme();
// Determine CodeMirror theme based on app theme
const isDarkMode = const isDarkMode =
appTheme === "dark" || appTheme === "dark" ||
(appTheme === "system" && (appTheme === "system" &&

View File

@@ -30,6 +30,8 @@ export function HostManager({
hostConfig || null, hostConfig || null,
); );
useEffect(() => {}, [editingHost]);
const [editingCredential, setEditingCredential] = useState<{ const [editingCredential, setEditingCredential] = useState<{
id: number; id: number;
name?: string; name?: string;
@@ -39,30 +41,27 @@ export function HostManager({
const ignoreNextHostConfigChangeRef = useRef<boolean>(false); const ignoreNextHostConfigChangeRef = useRef<boolean>(false);
const lastProcessedHostIdRef = useRef<number | undefined>(undefined); const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
// Sync state when tab is updated externally (via updateTab or addTab)
useEffect(() => { useEffect(() => {
// Always sync on timestamp changes
if (_updateTimestamp !== undefined) { if (_updateTimestamp !== undefined) {
// Update activeTab if initialTab has changed
if (initialTab && initialTab !== activeTab) { if (initialTab && initialTab !== activeTab) {
setActiveTab(initialTab); setActiveTab(initialTab);
} }
// Update editingHost if hostConfig has changed
if (hostConfig && hostConfig.id !== editingHost?.id) { if (hostConfig && hostConfig.id !== editingHost?.id) {
setEditingHost(hostConfig); setEditingHost(hostConfig);
lastProcessedHostIdRef.current = hostConfig.id; lastProcessedHostIdRef.current = hostConfig.id;
} else if (!hostConfig && editingHost) { } else if (
// Clear editingHost if hostConfig is now undefined !hostConfig &&
editingHost &&
editingHost.id !== lastProcessedHostIdRef.current
) {
setEditingHost(null); setEditingHost(null);
} }
// Clear editingCredential if switching away from add_credential
if (initialTab !== "add_credential" && editingCredential) { if (initialTab !== "add_credential" && editingCredential) {
setEditingCredential(null); setEditingCredential(null);
} }
} else { } else {
// Initial mount - set state from props
if (initialTab) { if (initialTab) {
setActiveTab(initialTab); setActiveTab(initialTab);
} }
@@ -78,7 +77,6 @@ export function HostManager({
setActiveTab("add_host"); setActiveTab("add_host");
lastProcessedHostIdRef.current = host.id; lastProcessedHostIdRef.current = host.id;
// Persist to tab context
if (updateTab && currentTabId !== undefined) { if (updateTab && currentTabId !== undefined) {
updateTab(currentTabId, { initialTab: "add_host" }); updateTab(currentTabId, { initialTab: "add_host" });
} }
@@ -101,7 +99,6 @@ export function HostManager({
setEditingCredential(credential); setEditingCredential(credential);
setActiveTab("add_credential"); setActiveTab("add_credential");
// Persist to tab context
if (updateTab && currentTabId !== undefined) { if (updateTab && currentTabId !== undefined) {
updateTab(currentTabId, { initialTab: "add_credential" }); updateTab(currentTabId, { initialTab: "add_credential" });
} }
@@ -121,7 +118,6 @@ export function HostManager({
} }
setActiveTab(value); setActiveTab(value);
// Persist to tab context
if (updateTab && currentTabId !== undefined) { if (updateTab && currentTabId !== undefined) {
updateTab(currentTabId, { initialTab: value }); updateTab(currentTabId, { initialTab: value });
} }

File diff suppressed because it is too large Load Diff

View File

@@ -363,7 +363,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
), ),
); );
// Wrap in hosts array for valid import format
const exportFormat = { const exportFormat = {
hosts: [cleanExportData], hosts: [cleanExportData],
}; };

View File

@@ -0,0 +1,29 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import type { HostDockerTabProps } from "./shared/tab-types";
export function HostDockerTab({ form, t }: HostDockerTabProps) {
return (
<div className="space-y-4">
<FormField
control={form.control}
name="enableDocker"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableDocker")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>{t("hosts.enableDockerDesc")}</FormDescription>
</FormItem>
)}
/>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import type { HostFileManagerTabProps } from "./shared/tab-types";
export function HostFileManagerTab({ form, t }: HostFileManagerTabProps) {
return (
<div>
<FormField
control={form.control}
name="enableFileManager"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableFileManager")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>
{t("hosts.enableFileManagerDesc")}
</FormDescription>
</FormItem>
)}
/>
{form.watch("enableFileManager") && (
<div className="mt-4">
<FormField
control={form.control}
name="defaultPath"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.defaultPath")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.homePath")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>{t("hosts.defaultPathDesc")}</FormDescription>
</FormItem>
)}
/>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,560 @@
import React from "react";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getRoles,
getUserList,
getUserInfo,
shareHost,
getHostAccess,
revokeHostAccess,
getSSHHostById,
type Role,
type AccessRecord,
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import {
Plus,
Check,
ChevronsUpDown,
AlertCircle,
Trash2,
Users,
Shield,
Clock,
UserCircle,
} from "lucide-react";
import type { SSHHost } from "@/types";
import type { HostSharingTabProps } from "./shared/tab-types";
interface User {
id: string;
username: string;
is_admin: boolean;
}
interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
}
export function HostSharingTab({
hostId,
isNewHost,
}: SharingTabContentProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [shareType, setShareType] = React.useState<"user" | "role">("user");
const [selectedUserId, setSelectedUserId] = React.useState<string>("");
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
null,
);
const [permissionLevel, setPermissionLevel] = React.useState("view");
const [expiresInHours, setExpiresInHours] = React.useState<string>("");
const [roles, setRoles] = React.useState<Role[]>([]);
const [users, setUsers] = React.useState<User[]>([]);
const [accessList, setAccessList] = React.useState<AccessRecord[]>([]);
const [loading, setLoading] = React.useState(false);
const [currentUserId, setCurrentUserId] = React.useState<string>("");
const [hostData, setHostData] = React.useState<SSHHost | null>(null);
const [userComboOpen, setUserComboOpen] = React.useState(false);
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
const loadRoles = React.useCallback(async () => {
try {
const response = await getRoles();
setRoles(response.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
setRoles([]);
}
}, []);
const loadUsers = React.useCallback(async () => {
try {
const response = await getUserList();
const mappedUsers = (response.users || []).map((user) => ({
id: user.id,
username: user.username,
is_admin: user.is_admin,
}));
setUsers(mappedUsers);
} catch (error) {
console.error("Failed to load users:", error);
setUsers([]);
}
}, []);
const loadAccessList = React.useCallback(async () => {
if (!hostId) return;
setLoading(true);
try {
const response = await getHostAccess(hostId);
setAccessList(response.accessList || []);
} catch (error) {
console.error("Failed to load access list:", error);
setAccessList([]);
} finally {
setLoading(false);
}
}, [hostId]);
const loadHostData = React.useCallback(async () => {
if (!hostId) return;
try {
const host = await getSSHHostById(hostId);
setHostData(host);
} catch (error) {
console.error("Failed to load host data:", error);
setHostData(null);
}
}, [hostId]);
React.useEffect(() => {
loadRoles();
loadUsers();
if (!isNewHost) {
loadAccessList();
loadHostData();
}
}, [loadRoles, loadUsers, loadAccessList, loadHostData, isNewHost]);
React.useEffect(() => {
const fetchCurrentUser = async () => {
try {
const userInfo = await getUserInfo();
setCurrentUserId(userInfo.userId);
} catch (error) {
console.error("Failed to load current user:", error);
}
};
fetchCurrentUser();
}, []);
const handleShare = async () => {
if (!hostId) {
toast.error(t("rbac.saveHostFirst"));
return;
}
if (shareType === "user" && !selectedUserId) {
toast.error(t("rbac.selectUser"));
return;
}
if (shareType === "role" && !selectedRoleId) {
toast.error(t("rbac.selectRole"));
return;
}
if (shareType === "user" && selectedUserId === currentUserId) {
toast.error(t("rbac.cannotShareWithSelf"));
return;
}
try {
await shareHost(hostId, {
targetType: shareType,
targetUserId: shareType === "user" ? selectedUserId : undefined,
targetRoleId: shareType === "role" ? selectedRoleId : undefined,
permissionLevel,
durationHours: expiresInHours
? parseInt(expiresInHours, 10)
: undefined,
});
toast.success(t("rbac.sharedSuccessfully"));
setSelectedUserId("");
setSelectedRoleId(null);
setExpiresInHours("");
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToShare"));
}
};
const handleRevoke = async (accessId: number) => {
if (!hostId) return;
const confirmed = await confirmWithToast({
title: t("rbac.confirmRevokeAccess"),
description: t("rbac.confirmRevokeAccessDescription"),
confirmText: t("common.revoke"),
cancelText: t("common.cancel"),
});
if (!confirmed) return;
try {
await revokeHostAccess(hostId, accessId);
toast.success(t("rbac.accessRevokedSuccessfully"));
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToRevokeAccess"));
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleString();
};
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
const availableUsers = React.useMemo(() => {
return users.filter((user) => user.id !== currentUserId);
}, [users, currentUserId]);
const selectedUser = availableUsers.find((u) => u.id === selectedUserId);
const selectedRole = roles.find((r) => r.id === selectedRoleId);
if (isNewHost) {
return (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.saveHostFirst")}</AlertTitle>
<AlertDescription>
{t("rbac.saveHostFirstDescription")}
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{!hostData?.credentialId && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.credentialRequired")}</AlertTitle>
<AlertDescription>
{t("rbac.credentialRequiredDescription")}
</AlertDescription>
</Alert>
)}
{hostData?.credentialId && (
<>
<div className="space-y-4 border rounded-lg p-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Plus className="h-5 w-5" />
{t("rbac.shareHost")}
</h3>
<Tabs
value={shareType}
onValueChange={(v) => setShareType(v as "user" | "role")}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="user" className="flex items-center gap-2">
<UserCircle className="h-4 w-4" />
{t("rbac.shareWithUser")}
</TabsTrigger>
<TabsTrigger value="role" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("rbac.shareWithRole")}
</TabsTrigger>
</TabsList>
<TabsContent value="user" className="space-y-4">
<div className="space-y-2">
<label htmlFor="user-select">{t("rbac.selectUser")}</label>
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={userComboOpen}
className="w-full justify-between"
>
{selectedUser
? `${selectedUser.username}${selectedUser.is_admin ? " (Admin)" : ""}`
: t("rbac.selectUserPlaceholder")}
<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)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchUsers")} />
<CommandEmpty>{t("rbac.noUserFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{availableUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.username} ${user.id}`}
onSelect={() => {
setSelectedUserId(user.id);
setUserComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedUserId === user.id
? "opacity-100"
: "opacity-0",
)}
/>
{user.username}
{user.is_admin ? " (Admin)" : ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
<TabsContent value="role" className="space-y-4">
<div className="space-y-2">
<label htmlFor="role-select">{t("rbac.selectRole")}</label>
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={roleComboOpen}
className="w-full justify-between"
>
{selectedRole
? `${t(selectedRole.displayName)}${selectedRole.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
: t("rbac.selectRolePlaceholder")}
<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)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchRoles")} />
<CommandEmpty>{t("rbac.noRoleFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{roles.map((role) => (
<CommandItem
key={role.id}
value={`${role.displayName} ${role.name} ${role.id}`}
onSelect={() => {
setSelectedRoleId(role.id);
setRoleComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedRoleId === role.id
? "opacity-100"
: "opacity-0",
)}
/>
{t(role.displayName)}
{role.isSystem
? ` (${t("rbac.systemRole")})`
: ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
</Tabs>
<div className="space-y-2">
<label>{t("rbac.permissionLevel")}</label>
<div className="text-sm text-muted-foreground">
{t("rbac.view")} - {t("rbac.viewDesc")}
</div>
</div>
<div className="space-y-2">
<label htmlFor="expires-in">{t("rbac.durationHours")}</label>
<Input
id="expires-in"
type="number"
value={expiresInHours}
onChange={(e) => {
const value = e.target.value;
if (value === "" || /^\d+$/.test(value)) {
setExpiresInHours(value);
}
}}
placeholder={t("rbac.neverExpires")}
min="1"
/>
</div>
<Button
type="button"
onClick={handleShare}
className="w-full"
disabled={!hostData?.credentialId}
>
<Plus className="h-4 w-4 mr-2" />
{t("rbac.share")}
</Button>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Users className="h-5 w-5" />
{t("rbac.accessList")}
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("rbac.type")}</TableHead>
<TableHead>{t("rbac.target")}</TableHead>
<TableHead>{t("rbac.permissionLevel")}</TableHead>
<TableHead>{t("rbac.grantedBy")}</TableHead>
<TableHead>{t("rbac.expires")}</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("common.loading")}
</TableCell>
</TableRow>
) : accessList.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("rbac.noAccessRecords")}
</TableCell>
</TableRow>
) : (
accessList.map((access) => (
<TableRow
key={access.id}
className={
isExpired(access.expiresAt) ? "opacity-50" : ""
}
>
<TableCell>
{access.targetType === "user" ? (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<UserCircle className="h-3 w-3" />
{t("rbac.user")}
</Badge>
) : (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<Shield className="h-3 w-3" />
{t("rbac.role")}
</Badge>
)}
</TableCell>
<TableCell>
{access.targetType === "user"
? access.username
: t(access.roleDisplayName || access.roleName || "")}
</TableCell>
<TableCell>
<Badge variant="secondary">
{access.permissionLevel}
</Badge>
</TableCell>
<TableCell>{access.grantedByUsername}</TableCell>
<TableCell>
{access.expiresAt ? (
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
<span
className={
isExpired(access.expiresAt)
? "text-red-500"
: ""
}
>
{formatDate(access.expiresAt)}
{isExpired(access.expiresAt) && (
<span className="ml-2">
({t("rbac.expired")})
</span>
)}
</span>
</div>
) : (
t("rbac.never")
)}
</TableCell>
<TableCell className="text-right">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRevoke(access.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,340 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Plus, X } from "lucide-react";
import type { HostStatisticsTabProps } from "./shared/tab-types";
import { QuickActionItem } from "./shared/QuickActionItem";
export function HostStatisticsTab({
form,
statusIntervalUnit,
setStatusIntervalUnit,
metricsIntervalUnit,
setMetricsIntervalUnit,
snippets,
t,
}: HostStatisticsTabProps) {
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-3">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/server-stats", "_blank")
}
>
{t("common.documentation")}
</Button>
<FormField
control={form.control}
name="statsConfig.statusCheckEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.statusCheckEnabled")}</FormLabel>
<FormDescription>
{t("hosts.statusCheckEnabledDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("statsConfig.statusCheckEnabled") && (
<FormField
control={form.control}
name="statsConfig.statusCheckInterval"
render={({ field }) => {
const displayValue =
statusIntervalUnit === "minutes"
? Math.round((field.value || 30) / 60)
: field.value || 30;
const handleIntervalChange = (value: string) => {
const numValue = parseInt(value) || 0;
const seconds =
statusIntervalUnit === "minutes" ? numValue * 60 : numValue;
field.onChange(seconds);
};
return (
<FormItem>
<FormLabel>{t("hosts.statusCheckInterval")}</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input
type="number"
value={displayValue}
onChange={(e) => handleIntervalChange(e.target.value)}
className="flex-1"
/>
</FormControl>
<Select
value={statusIntervalUnit}
onValueChange={(value: "seconds" | "minutes") => {
setStatusIntervalUnit(value);
const currentSeconds = field.value || 30;
if (value === "minutes") {
const minutes = Math.round(currentSeconds / 60);
field.onChange(minutes * 60);
}
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seconds">
{t("hosts.intervalSeconds")}
</SelectItem>
<SelectItem value="minutes">
{t("hosts.intervalMinutes")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FormDescription>
{t("hosts.statusCheckIntervalDesc")}
</FormDescription>
</FormItem>
);
}}
/>
)}
</div>
<div className="space-y-3">
<FormField
control={form.control}
name="statsConfig.metricsEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.metricsEnabled")}</FormLabel>
<FormDescription>
{t("hosts.metricsEnabledDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("statsConfig.metricsEnabled") && (
<FormField
control={form.control}
name="statsConfig.metricsInterval"
render={({ field }) => {
const displayValue =
metricsIntervalUnit === "minutes"
? Math.round((field.value || 30) / 60)
: field.value || 30;
const handleIntervalChange = (value: string) => {
const numValue = parseInt(value) || 0;
const seconds =
metricsIntervalUnit === "minutes"
? numValue * 60
: numValue;
field.onChange(seconds);
};
return (
<FormItem>
<FormLabel>{t("hosts.metricsInterval")}</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input
type="number"
value={displayValue}
onChange={(e) => handleIntervalChange(e.target.value)}
className="flex-1"
/>
</FormControl>
<Select
value={metricsIntervalUnit}
onValueChange={(value: "seconds" | "minutes") => {
setMetricsIntervalUnit(value);
const currentSeconds = field.value || 30;
if (value === "minutes") {
const minutes = Math.round(currentSeconds / 60);
field.onChange(minutes * 60);
}
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seconds">
{t("hosts.intervalSeconds")}
</SelectItem>
<SelectItem value="minutes">
{t("hosts.intervalMinutes")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FormDescription>
{t("hosts.metricsIntervalDesc")}
</FormDescription>
</FormItem>
);
}}
/>
)}
</div>
</div>
{form.watch("statsConfig.metricsEnabled") && (
<>
<FormField
control={form.control}
name="statsConfig.enabledWidgets"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enabledWidgets")}</FormLabel>
<FormDescription>
{t("hosts.enabledWidgetsDesc")}
</FormDescription>
<div className="space-y-3 mt-3">
{(
[
"cpu",
"memory",
"disk",
"network",
"uptime",
"processes",
"system",
"login_stats",
] as const
).map((widget) => (
<div key={widget} className="flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(widget)}
onCheckedChange={(checked) => {
const currentWidgets = field.value || [];
if (checked) {
field.onChange([...currentWidgets, widget]);
} else {
field.onChange(
currentWidgets.filter((w) => w !== widget),
);
}
}}
/>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{widget === "cpu" && t("serverStats.cpuUsage")}
{widget === "memory" && t("serverStats.memoryUsage")}
{widget === "disk" && t("serverStats.diskUsage")}
{widget === "network" &&
t("serverStats.networkInterfaces")}
{widget === "uptime" && t("serverStats.uptime")}
{widget === "processes" && t("serverStats.processes")}
{widget === "system" && t("serverStats.systemInfo")}
{widget === "login_stats" &&
t("serverStats.loginStats")}
</label>
</div>
))}
</div>
</FormItem>
)}
/>
</>
)}
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("hosts.quickActions")}</h3>
<Alert>
<AlertDescription>
{t("hosts.quickActionsDescription")}
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="quickActions"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.quickActionsList")}</FormLabel>
<FormControl>
<div className="space-y-3">
{field.value.map((quickAction, index) => (
<QuickActionItem
key={index}
quickAction={quickAction}
index={index}
snippets={snippets}
onUpdate={(name, snippetId) => {
const newQuickActions = [...field.value];
newQuickActions[index] = {
name,
snippetId,
};
field.onChange(newQuickActions);
}}
onRemove={() => {
const newQuickActions = field.value.filter(
(_, i) => i !== index,
);
field.onChange(newQuickActions);
}}
t={t}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
field.onChange([
...field.value,
{ name: "", snippetId: 0 },
]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addQuickAction")}
</Button>
</div>
</FormControl>
<FormDescription>{t("hosts.quickActionsOrder")}</FormDescription>
</FormItem>
)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,767 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import { Slider } from "@/components/ui/slider.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
CURSOR_STYLES,
BELL_STYLES,
FAST_SCROLL_MODIFIERS,
} from "@/constants/terminal-themes.ts";
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
import type { HostTerminalTabProps } from "./shared/tab-types";
import React from "react";
export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
return (
<div className="space-y-1">
<FormField
control={form.control}
name="enableTerminal"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableTerminal")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>{t("hosts.enableTerminalDesc")}</FormDescription>
</FormItem>
)}
/>
<h1 className="text-xl font-semibold mt-7">
{t("hosts.terminalCustomization")}
</h1>
<Accordion
type="multiple"
className="w-full"
defaultValue={["appearance", "behavior", "advanced"]}
>
<AccordionItem value="appearance">
<AccordionTrigger>{t("hosts.appearance")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("hosts.themePreview")}
</label>
<TerminalPreview
theme={form.watch("terminalConfig.theme")}
fontSize={form.watch("terminalConfig.fontSize")}
fontFamily={form.watch("terminalConfig.fontFamily")}
cursorStyle={form.watch("terminalConfig.cursorStyle")}
cursorBlink={form.watch("terminalConfig.cursorBlink")}
letterSpacing={form.watch("terminalConfig.letterSpacing")}
lineHeight={form.watch("terminalConfig.lineHeight")}
/>
</div>
<FormField
control={form.control}
name="terminalConfig.theme"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.theme")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectTheme")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
<SelectItem key={key} value={key}>
{theme.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseColorTheme")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fontFamily")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectFont")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{TERMINAL_FONTS.map((font) => (
<SelectItem key={font.value} value={font.value}>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>{t("hosts.selectFontDesc")}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fontSizeValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={8}
max={24}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>{t("hosts.adjustFontSize")}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.letterSpacing"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.letterSpacingValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={-2}
max={10}
step={0.5}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.adjustLetterSpacing")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.lineHeight"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.lineHeightValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
max={2}
step={0.1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.adjustLineHeight")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.cursorStyle"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.cursorStyle")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectCursorStyle")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="block">
{t("hosts.cursorStyleBlock")}
</SelectItem>
<SelectItem value="underline">
{t("hosts.cursorStyleUnderline")}
</SelectItem>
<SelectItem value="bar">
{t("hosts.cursorStyleBar")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseCursorAppearance")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.cursorBlink"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.cursorBlink")}</FormLabel>
<FormDescription>
{t("hosts.enableCursorBlink")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="behavior">
<AccordionTrigger>{t("hosts.behavior")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={form.control}
name="terminalConfig.scrollback"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.scrollbackBufferValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1000}
max={100000}
step={1000}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.scrollbackBufferDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.bellStyle"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.bellStyle")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectBellStyle")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">
{t("hosts.bellStyleNone")}
</SelectItem>
<SelectItem value="sound">
{t("hosts.bellStyleSound")}
</SelectItem>
<SelectItem value="visual">
{t("hosts.bellStyleVisual")}
</SelectItem>
<SelectItem value="both">
{t("hosts.bellStyleBoth")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>{t("hosts.bellStyleDesc")}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.rightClickSelectsWord"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.rightClickSelectsWord")}</FormLabel>
<FormDescription>
{t("hosts.rightClickSelectsWordDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fastScrollModifier"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fastScrollModifier")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectModifier")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="alt">
{t("hosts.modifierAlt")}
</SelectItem>
<SelectItem value="ctrl">
{t("hosts.modifierCtrl")}
</SelectItem>
<SelectItem value="shift">
{t("hosts.modifierShift")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.fastScrollModifierDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fastScrollSensitivity"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fastScrollSensitivityValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
max={10}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.fastScrollSensitivityDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.minimumContrastRatio"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.minimumContrastRatioValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
max={21}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.minimumContrastRatioDesc")}
</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="advanced">
<AccordionTrigger>{t("hosts.advanced")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={form.control}
name="terminalConfig.agentForwarding"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.sshAgentForwarding")}</FormLabel>
<FormDescription>
{t("hosts.sshAgentForwardingDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.backspaceMode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.backspaceMode")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectBackspaceMode")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="normal">
{t("hosts.backspaceModeNormal")}
</SelectItem>
<SelectItem value="control-h">
{t("hosts.backspaceModeControlH")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.backspaceModeDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.startupSnippetId"
render={({ field }) => {
const [open, setOpen] = React.useState(false);
const selectedSnippet = snippets.find(
(s) => s.id === field.value,
);
return (
<FormItem>
<FormLabel>{t("hosts.startupSnippet")}</FormLabel>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedSnippet
? selectedSnippet.name
: t("hosts.selectSnippet")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{
width: "var(--radix-popover-trigger-width)",
}}
>
<Command>
<CommandInput
placeholder={t("hosts.searchSnippets")}
/>
<CommandEmpty>
{t("hosts.noSnippetFound")}
</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
<CommandItem
value="none"
onSelect={() => {
field.onChange(null);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!field.value ? "opacity-100" : "opacity-0",
)}
/>
{t("hosts.snippetNone")}
</CommandItem>
{snippets.map((snippet) => (
<CommandItem
key={snippet.id}
value={`${snippet.name} ${snippet.content} ${snippet.id}`}
onSelect={() => {
field.onChange(snippet.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === snippet.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{snippet.name}
</span>
<span className="text-xs text-muted-foreground truncate max-w-[350px]">
{snippet.content}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
{t("hosts.executeSnippetOnConnect")}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="terminalConfig.autoMosh"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.autoMosh")}</FormLabel>
<FormDescription>{t("hosts.autoMoshDesc")}</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("terminalConfig.autoMosh") && (
<FormField
control={form.control}
name="terminalConfig.moshCommand"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.moshCommand")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.moshCommand")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>
{t("hosts.moshCommandDesc")}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="terminalConfig.sudoPasswordAutoFill"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{t("hosts.sudoPasswordAutoFill")}</FormLabel>
<FormDescription>
{t("hosts.sudoPasswordAutoFillDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("terminalConfig.sudoPasswordAutoFill") && (
<FormField
control={form.control}
name="terminalConfig.sudoPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.sudoPassword")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.sudoPassword")}
{...field}
/>
</FormControl>
<FormDescription>
{t("hosts.sudoPasswordDesc")}
</FormDescription>
</FormItem>
)}
/>
)}
<div className="space-y-2">
<label className="text-sm font-medium">
{t("hosts.environmentVariables")}
</label>
<FormDescription>
{t("hosts.environmentVariablesDesc")}
</FormDescription>
{form
.watch("terminalConfig.environmentVariables")
?.map((_, index) => (
<div key={index} className="flex gap-2">
<FormField
control={form.control}
name={`terminalConfig.environmentVariables.${index}.key`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder={t("hosts.variableName")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`terminalConfig.environmentVariables.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder={t("hosts.variableValue")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const current = form.getValues(
"terminalConfig.environmentVariables",
);
form.setValue(
"terminalConfig.environmentVariables",
current.filter((_, i) => i !== index),
);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const current =
form.getValues("terminalConfig.environmentVariables") || [];
form.setValue("terminalConfig.environmentVariables", [
...current,
{ key: "", value: "" },
]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addVariable")}
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,361 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import { Button } from "@/components/ui/button.tsx";
import type { HostTunnelTabProps } from "./shared/tab-types";
export function HostTunnelTab({
form,
sshConfigDropdownOpen,
setSshConfigDropdownOpen,
sshConfigInputRefs,
sshConfigDropdownRefs,
getFilteredSshConfigs,
handleSshConfigClick,
t,
}: HostTunnelTabProps) {
return (
<div>
<FormField
control={form.control}
name="enableTunnel"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableTunnel")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>{t("hosts.enableTunnelDesc")}</FormDescription>
</FormItem>
)}
/>
{form.watch("enableTunnel") && (
<>
<Alert className="mt-4">
<AlertDescription>
<strong>{t("hosts.sshpassRequired")}</strong>
<div>
{t("hosts.sshpassRequiredDesc")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo apt install sshpass
</code>{" "}
{t("hosts.debianUbuntuEquivalent")}
</div>
<div className="mt-2">
<strong>{t("hosts.otherInstallMethods")}</strong>
<div>
{t("hosts.centosRhelFedora")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo yum install sshpass
</code>{" "}
{t("hosts.or")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo dnf install sshpass
</code>
</div>
<div>
{t("hosts.macos")}{" "}
<code className="bg-muted px-1 rounded inline">
brew install hudochenkov/sshpass/sshpass
</code>
</div>
<div> {t("hosts.windows")}</div>
</div>
</AlertDescription>
</Alert>
<Alert className="mt-4">
<AlertDescription>
<strong>{t("hosts.sshServerConfigRequired")}</strong>
<div>{t("hosts.sshServerConfigDesc")}</div>
<div>
{" "}
<code className="bg-muted px-1 rounded inline">
GatewayPorts yes
</code>{" "}
{t("hosts.gatewayPortsYes")}
</div>
<div>
{" "}
<code className="bg-muted px-1 rounded inline">
AllowTcpForwarding yes
</code>{" "}
{t("hosts.allowTcpForwardingYes")}
</div>
<div>
{" "}
<code className="bg-muted px-1 rounded inline">
PermitRootLogin yes
</code>{" "}
{t("hosts.permitRootLoginYes")}
</div>
<div className="mt-2">{t("hosts.editSshConfig")}</div>
</AlertDescription>
</Alert>
<div className="mt-3 flex justify-between">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/tunnels", "_blank")
}
>
{t("common.documentation")}
</Button>
</div>
<FormField
control={form.control}
name="tunnelConnections"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("hosts.tunnelConnections")}</FormLabel>
<FormControl>
<div className="space-y-4">
{field.value.map((connection, index) => (
<div
key={index}
className="p-4 border rounded-lg bg-muted/50"
>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold">
{t("hosts.connection")} {index + 1}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const newConnections = field.value.filter(
(_, i) => i !== index,
);
field.onChange(newConnections);
}}
>
{t("hosts.remove")}
</Button>
</div>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name={`tunnelConnections.${index}.sourcePort`}
render={({ field: sourcePortField }) => (
<FormItem className="col-span-4">
<FormLabel>
{t("hosts.sourcePort")}
{t("hosts.sourcePortDesc")}
</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.defaultPort")}
{...sourcePortField}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.endpointPort`}
render={({ field: endpointPortField }) => (
<FormItem className="col-span-4">
<FormLabel>{t("hosts.endpointPort")}</FormLabel>
<FormControl>
<Input
placeholder={t(
"placeholders.defaultEndpointPort",
)}
{...endpointPortField}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.endpointHost`}
render={({ field: endpointHostField }) => (
<FormItem className="col-span-4 relative">
<FormLabel>
{t("hosts.endpointSshConfig")}
</FormLabel>
<FormControl>
<Input
ref={(el) => {
sshConfigInputRefs.current[index] = el;
}}
placeholder={t("placeholders.sshConfig")}
className="min-h-[40px]"
autoComplete="off"
value={endpointHostField.value}
onFocus={() =>
setSshConfigDropdownOpen((prev) => ({
...prev,
[index]: true,
}))
}
onChange={(e) => {
endpointHostField.onChange(e);
setSshConfigDropdownOpen((prev) => ({
...prev,
[index]: true,
}));
}}
onBlur={(e) => {
endpointHostField.onChange(
e.target.value.trim(),
);
endpointHostField.onBlur();
}}
/>
</FormControl>
{sshConfigDropdownOpen[index] &&
getFilteredSshConfigs(index).length > 0 && (
<div
ref={(el) => {
sshConfigDropdownRefs.current[index] =
el;
}}
className="absolute top-full left-0 z-50 mt-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{getFilteredSshConfigs(index).map(
(config) => (
<Button
key={config}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
onClick={() =>
handleSshConfigClick(
config,
index,
)
}
>
{config}
</Button>
),
)}
</div>
</div>
)}
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground mt-2">
{t("hosts.tunnelForwardDescription", {
sourcePort:
form.watch(
`tunnelConnections.${index}.sourcePort`,
) || "22",
endpointPort:
form.watch(
`tunnelConnections.${index}.endpointPort`,
) || "224",
})}
</p>
<div className="grid grid-cols-12 gap-4 mt-4">
<FormField
control={form.control}
name={`tunnelConnections.${index}.maxRetries`}
render={({ field: maxRetriesField }) => (
<FormItem className="col-span-4">
<FormLabel>{t("hosts.maxRetries")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.maxRetries")}
{...maxRetriesField}
/>
</FormControl>
<FormDescription>
{t("hosts.maxRetriesDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.retryInterval`}
render={({ field: retryIntervalField }) => (
<FormItem className="col-span-4">
<FormLabel>
{t("hosts.retryInterval")}
</FormLabel>
<FormControl>
<Input
placeholder={t(
"placeholders.retryInterval",
)}
{...retryIntervalField}
/>
</FormControl>
<FormDescription>
{t("hosts.retryIntervalDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.autoStart`}
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel>
{t("hosts.autoStartContainer")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t("hosts.autoStartDesc")}
</FormDescription>
</FormItem>
)}
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => {
field.onChange([
...field.value,
{
sourcePort: 22,
endpointPort: 224,
endpointHost: "",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
},
]);
}}
>
{t("hosts.addConnection")}
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { import {
Popover, Popover,
@@ -13,7 +14,6 @@ import {
CommandItem, CommandItem,
} from "@/components/ui/command.tsx"; } from "@/components/ui/command.tsx";
import { Check, ChevronsUpDown, X } from "lucide-react"; import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import type { JumpHostItemProps } from "./tab-types"; import type { JumpHostItemProps } from "./tab-types";
export function JumpHostItem({ export function JumpHostItem({

View File

@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { import {
@@ -14,7 +15,6 @@ import {
CommandItem, CommandItem,
} from "@/components/ui/command.tsx"; } from "@/components/ui/command.tsx";
import { Check, ChevronsUpDown, X } from "lucide-react"; import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import type { QuickActionItemProps } from "./tab-types"; import type { QuickActionItemProps } from "./tab-types";
export function QuickActionItem({ export function QuickActionItem({

View File

@@ -1,4 +1,84 @@
import type { SSHHost } from "@/types"; import type { UseFormReturn } from "react-hook-form";
import type React from "react";
import type { SSHHost, Credential } from "@/types";
export interface HostGeneralTabProps {
form: UseFormReturn<FormData>;
authTab: "password" | "key" | "credential" | "none";
setAuthTab: (value: "password" | "key" | "credential" | "none") => void;
keyInputMethod: "upload" | "paste";
setKeyInputMethod: (value: "upload" | "paste") => void;
proxyMode: "single" | "chain";
setProxyMode: (value: "single" | "chain") => void;
tagInput: string;
setTagInput: (value: string) => void;
folderDropdownOpen: boolean;
setFolderDropdownOpen: (value: boolean) => void;
folderInputRef: React.RefObject<HTMLInputElement>;
folderDropdownRef: React.RefObject<HTMLDivElement>;
filteredFolders: string[];
handleFolderClick: (folder: string) => void;
keyTypeDropdownOpen: boolean;
setKeyTypeDropdownOpen: (value: boolean) => void;
keyTypeButtonRef: React.RefObject<HTMLButtonElement>;
keyTypeDropdownRef: React.RefObject<HTMLDivElement>;
keyTypeOptions: Array<{ value: string; label: string }>;
ipInputRef: React.RefObject<HTMLInputElement>;
editorTheme: unknown;
hosts: SSHHost[];
editingHost?: SSHHost | null;
folders: string[];
credentials: Credential[];
t: (key: string) => string;
}
export interface HostTerminalTabProps {
form: UseFormReturn<FormData>;
snippets: Array<{ id: number; name: string; content: string }>;
t: (key: string) => string;
}
export interface HostDockerTabProps {
form: UseFormReturn<FormData>;
t: (key: string) => string;
}
export interface HostTunnelTabProps {
form: UseFormReturn<FormData>;
sshConfigDropdownOpen: { [key: number]: boolean };
setSshConfigDropdownOpen: React.Dispatch<
React.SetStateAction<{ [key: number]: boolean }>
>;
sshConfigInputRefs: React.MutableRefObject<{
[key: number]: HTMLInputElement | null;
}>;
sshConfigDropdownRefs: React.MutableRefObject<{
[key: number]: HTMLDivElement | null;
}>;
getFilteredSshConfigs: (index: number) => string[];
handleSshConfigClick: (config: string, index: number) => void;
t: (key: string) => string;
}
export interface HostFileManagerTabProps {
form: UseFormReturn<FormData>;
t: (key: string) => string;
}
export interface HostStatisticsTabProps {
form: UseFormReturn<FormData>;
statusIntervalUnit: "seconds" | "minutes";
setStatusIntervalUnit: (value: "seconds" | "minutes") => void;
metricsIntervalUnit: "seconds" | "minutes";
setMetricsIntervalUnit: (value: "seconds" | "minutes") => void;
snippets: Array<{ id: number; name: string; content: string }>;
t: (key: string) => string;
}
export interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
}
export interface JumpHostItemProps { export interface JumpHostItemProps {
jumpHost: { hostId: number }; jumpHost: { hostId: number };

View File

@@ -56,7 +56,6 @@ export function AppView({
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
const { theme: appTheme } = useTheme(); const { theme: appTheme } = useTheme();
// Auto-switch terminal theme based on app theme
const isDarkMode = useMemo(() => { const isDarkMode = useMemo(() => {
if (appTheme === "dark") return true; if (appTheme === "dark") return true;
if (appTheme === "light") return false; if (appTheme === "light") return false;
@@ -698,7 +697,6 @@ export function AppView({
...DEFAULT_TERMINAL_CONFIG, ...DEFAULT_TERMINAL_CONFIG,
...(currentTabData?.hostConfig as any)?.terminalConfig, ...(currentTabData?.hostConfig as any)?.terminalConfig,
}; };
// Auto-switch between termixDark and termixLight based on app theme
let containerThemeColors; let containerThemeColors;
if (terminalConfig.theme === "termix") { if (terminalConfig.theme === "termix") {
containerThemeColors = isDarkMode containerThemeColors = isDarkMode

View File

@@ -366,11 +366,9 @@ export function LeftSidebar({
const searchQuery = debouncedSearch.trim().toLowerCase(); const searchQuery = debouncedSearch.trim().toLowerCase();
return hosts.filter((h) => { return hosts.filter((h) => {
// Check for field-specific search patterns
const fieldMatches: Record<string, string> = {}; const fieldMatches: Record<string, string> = {};
let remainingQuery = searchQuery; let remainingQuery = searchQuery;
// Extract field-specific queries (e.g., "tag:production", "user:root", "ip:192.168")
const fieldPattern = /(\w+):([^\s]+)/g; const fieldPattern = /(\w+):([^\s]+)/g;
let match; let match;
while ((match = fieldPattern.exec(searchQuery)) !== null) { while ((match = fieldPattern.exec(searchQuery)) !== null) {
@@ -379,7 +377,6 @@ export function LeftSidebar({
remainingQuery = remainingQuery.replace(fullMatch, "").trim(); remainingQuery = remainingQuery.replace(fullMatch, "").trim();
} }
// Handle field-specific searches
for (const [field, value] of Object.entries(fieldMatches)) { for (const [field, value] of Object.entries(fieldMatches)) {
switch (field) { switch (field) {
case "tag": case "tag":
@@ -418,7 +415,6 @@ export function LeftSidebar({
} }
} }
// If there's remaining query text (not field-specific), search across all fields
if (remainingQuery) { if (remainingQuery) {
const searchableText = [ const searchableText = [
h.name || "", h.name || "",

View File

@@ -233,7 +233,8 @@ export function SSHAuthDialog({
".cm-scroller": { ".cm-scroller": {
overflow: "auto", overflow: "auto",
scrollbarWidth: "thin", scrollbarWidth: "thin",
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)", scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
}, },
}), }),
]} ]}

View File

@@ -52,7 +52,9 @@ export function SimpleLoader({
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="simple-spinner"></div> <div className="simple-spinner"></div>
{message && ( {message && (
<p className="text-sm text-foreground-secondary font-medium">{message}</p> <p className="text-sm text-foreground-secondary font-medium">
{message}
</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -84,7 +84,6 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
const shouldShowStatus = statsConfig.statusCheckEnabled !== false; const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
const shouldShowMetrics = statsConfig.metricsEnabled !== false; const shouldShowMetrics = statsConfig.metricsEnabled !== false;
// Check if host has at least one tunnel connection
const hasTunnelConnections = useMemo(() => { const hasTunnelConnections = useMemo(() => {
if (!host.tunnelConnections) return false; if (!host.tunnelConnections) return false;
try { try {

View File

@@ -153,7 +153,8 @@ export function Tab({
onClick={!disableActivate ? onActivate : undefined} onClick={!disableActivate ? onActivate : undefined}
style={{ style={{
marginBottom: "-2px", marginBottom: "-2px",
borderBottom: isActive || isSplit ? "2px solid var(--foreground)" : "none", borderBottom:
isActive || isSplit ? "2px solid var(--foreground)" : "none",
}} }}
> >
<div className="flex items-center gap-1.5 flex-1 min-w-0"> <div className="flex items-center gap-1.5 flex-1 min-w-0">

View File

@@ -56,7 +56,6 @@ export function TabProvider({ children }: TabProviderProps) {
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]); const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2); const nextTabId = useRef(2);
// Update home tab title when translation changes
React.useEffect(() => { React.useEffect(() => {
setTabs((prev) => setTabs((prev) =>
prev.map((tab) => prev.map((tab) =>
@@ -174,10 +173,8 @@ export function TabProvider({ children }: TabProviderProps) {
setTabs((prev) => prev.filter((tab) => tab.id !== tabId)); setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
// Remove from split screen
setAllSplitScreenTab((prev) => { setAllSplitScreenTab((prev) => {
const newSplits = prev.filter((id) => id !== tabId); const newSplits = prev.filter((id) => id !== tabId);
// Auto-clear split mode if only 1 or fewer tabs remain in split
if (newSplits.length <= 1) { if (newSplits.length <= 1) {
return []; return [];
} }
@@ -187,7 +184,6 @@ export function TabProvider({ children }: TabProviderProps) {
if (currentTab === tabId) { if (currentTab === tabId) {
const remainingTabs = tabs.filter((tab) => tab.id !== tabId); const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
if (remainingTabs.length > 0) { if (remainingTabs.length > 0) {
// Try to set current tab to another split tab first, if any remain
const remainingSplitTabs = allSplitScreenTab.filter( const remainingSplitTabs = allSplitScreenTab.filter(
(id) => id !== tabId, (id) => id !== tabId,
); );
@@ -197,7 +193,7 @@ export function TabProvider({ children }: TabProviderProps) {
setCurrentTab(remainingTabs[0].id); setCurrentTab(remainingTabs[0].id);
} }
} else { } else {
setCurrentTab(1); // Home tab setCurrentTab(1);
} }
} }
}; };

View File

@@ -18,7 +18,15 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select.tsx"; } from "@/components/ui/select.tsx";
import { User, Shield, AlertCircle, Palette, Sun, Moon, Monitor } from "lucide-react"; import {
User,
Shield,
AlertCircle,
Palette,
Sun,
Moon,
Monitor,
} from "lucide-react";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx"; import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
import { import {
@@ -154,7 +162,6 @@ export function UserProfile({
totp_enabled: info.totp_enabled || false, totp_enabled: info.totp_enabled || false,
}); });
// Fetch user roles
try { try {
const rolesResponse = await getUserRoles(info.userId); const rolesResponse = await getUserRoles(info.userId);
setUserRoles(rolesResponse.roles || []); setUserRoles(rolesResponse.roles || []);
@@ -473,7 +480,10 @@ export function UserProfile({
{t("profile.theme", "Theme")} {t("profile.theme", "Theme")}
</Label> </Label>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{t("profile.appearanceDesc", "Choose your preferred theme")} {t(
"profile.appearanceDesc",
"Choose your preferred theme",
)}
</p> </p>
</div> </div>
<Select value={theme} onValueChange={setTheme}> <Select value={theme} onValueChange={setTheme}>

View File

@@ -1964,7 +1964,7 @@ export async function getServerStatusById(id: number): Promise<ServerStatus> {
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "fetch server status"); handleApiError(error, "fetch server status");
throw error; // Explicit throw to propagate error throw error;
} }
} }
@@ -1974,7 +1974,7 @@ export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "fetch server metrics"); handleApiError(error, "fetch server metrics");
throw error; // Explicit throw to propagate error throw error;
} }
} }

View File

@@ -30,14 +30,12 @@ const AppContent: FC = () => {
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
setAuthLoading(true); setAuthLoading(true);
// Don't optimistically set isAuthenticated before checking
getUserInfo() getUserInfo()
.then((meRes) => { .then((meRes) => {
if (typeof meRes === "string" || !meRes.username) { if (typeof meRes === "string" || !meRes.username) {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
// Clear invalid token
localStorage.removeItem("jwt"); localStorage.removeItem("jwt");
} else { } else {
setIsAuthenticated(true); setIsAuthenticated(true);
@@ -50,7 +48,6 @@ const AppContent: FC = () => {
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
// Clear invalid token on any auth error
localStorage.removeItem("jwt"); localStorage.removeItem("jwt");
const errorCode = err?.response?.data?.code; const errorCode = err?.response?.data?.code;

View File

@@ -68,7 +68,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null); const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 140; const DEBOUNCE_MS = 140;
// Auto-switch terminal theme based on app theme
const isDarkMode = const isDarkMode =
appTheme === "dark" || appTheme === "dark" ||
(appTheme === "system" && (appTheme === "system" &&

View File

@@ -19,8 +19,10 @@ export function TerminalKeyboard({
const [isAlt, setIsAlt] = useState(false); const [isAlt, setIsAlt] = useState(false);
const { theme: appTheme } = useTheme(); const { theme: appTheme } = useTheme();
const isDarkMode = appTheme === "dark" || const isDarkMode =
(appTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
useEffect(() => { useEffect(() => {
if (onLayoutChange) { if (onLayoutChange) {

View File

@@ -2328,4 +2328,4 @@
"noContainersMatchFiltersHint": "التبديل إلى الوضع الداكن" "noContainersMatchFiltersHint": "التبديل إلى الوضع الداكن"
}, },
"theme": {} "theme": {}
} }

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