chore: cleanup files (possible RC)
This commit is contained in:
28
.github/workflows/translate.yml
vendored
28
.github/workflows/translate.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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" ||
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" &&
|
||||||
|
|||||||
@@ -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
@@ -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],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1047
src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx
Normal file
1047
src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
560
src/ui/desktop/apps/host-manager/hosts/tabs/HostSharingTab.tsx
Normal file
560
src/ui/desktop/apps/host-manager/hosts/tabs/HostSharingTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
767
src/ui/desktop/apps/host-manager/hosts/tabs/HostTerminalTab.tsx
Normal file
767
src/ui/desktop/apps/host-manager/hosts/tabs/HostTerminalTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx
Normal file
361
src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
|
|||||||
@@ -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)",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" &&
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user