feat: Add many terminal customizations

This commit is contained in:
LukeGus
2025-10-22 20:54:28 -05:00
parent ee3101c5c6
commit 785cf44a08
17 changed files with 2276 additions and 1127 deletions

34
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
@@ -3128,6 +3129,39 @@
} }
} }
}, },
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.3",
"license": "MIT", "license": "MIT",

View File

@@ -47,6 +47,7 @@
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",

View File

@@ -167,6 +167,7 @@ async function initializeCompleteDatabase(): Promise<void> {
enable_file_manager INTEGER NOT NULL DEFAULT 1, enable_file_manager INTEGER NOT NULL DEFAULT 1,
default_path TEXT, default_path TEXT,
stats_config TEXT, stats_config TEXT,
terminal_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) FOREIGN KEY (user_id) REFERENCES users (id)
@@ -404,6 +405,7 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists("ssh_data", "stats_config", "TEXT"); addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");

View File

@@ -66,6 +66,7 @@ export const sshData = sqliteTable("ssh_data", {
.default(true), .default(true),
defaultPath: text("default_path"), defaultPath: text("default_path"),
statsConfig: text("stats_config"), statsConfig: text("stats_config"),
terminalConfig: text("terminal_config"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),

View File

@@ -235,6 +235,7 @@ router.post(
defaultPath, defaultPath,
tunnelConnections, tunnelConnections,
statsConfig, statsConfig,
terminalConfig,
} = hostData; } = hostData;
if ( if (
!isNonEmptyString(userId) || !isNonEmptyString(userId) ||
@@ -271,6 +272,7 @@ router.post(
enableFileManager: enableFileManager ? 1 : 0, enableFileManager: enableFileManager ? 1 : 0,
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
}; };
if (effectiveAuthType === "password") { if (effectiveAuthType === "password") {
@@ -421,6 +423,7 @@ router.put(
defaultPath, defaultPath,
tunnelConnections, tunnelConnections,
statsConfig, statsConfig,
terminalConfig,
} = hostData; } = hostData;
if ( if (
!isNonEmptyString(userId) || !isNonEmptyString(userId) ||
@@ -458,6 +461,7 @@ router.put(
enableFileManager: enableFileManager ? 1 : 0, enableFileManager: enableFileManager ? 1 : 0,
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
}; };
if (effectiveAuthType === "password") { if (effectiveAuthType === "password") {
@@ -604,6 +608,9 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
statsConfig: row.statsConfig statsConfig: row.statsConfig
? JSON.parse(row.statsConfig as string) ? JSON.parse(row.statsConfig as string)
: undefined, : undefined,
terminalConfig: row.terminalConfig
? JSON.parse(row.terminalConfig as string)
: undefined,
}; };
return (await resolveHostCredentials(baseHost)) || baseHost; return (await resolveHostCredentials(baseHost)) || baseHost;
@@ -671,6 +678,9 @@ router.get(
statsConfig: host.statsConfig statsConfig: host.statsConfig
? JSON.parse(host.statsConfig) ? JSON.parse(host.statsConfig)
: undefined, : undefined,
terminalConfig: host.terminalConfig
? JSON.parse(host.terminalConfig)
: undefined,
}; };
res.json((await resolveHostCredentials(result)) || result); res.json((await resolveHostCredentials(result)) || result);

View File

@@ -331,20 +331,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
} }
} else if (resolvedCredentials.authType === "password") { } else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password || !resolvedCredentials.password.trim()) { if (!resolvedCredentials.password || !resolvedCredentials.password.trim()) {
fileLogger.warn(
"Password authentication requested but no password provided",
{
operation: "file_connect",
sessionId,
hostId,
},
);
return res return res
.status(400) .status(400)
.json({ error: "Password required for password authentication" }); .json({ error: "Password required for password authentication" });
} }
// Set password to offer both password and keyboard-interactive methods } else if (resolvedCredentials.authType === "none") {
config.password = resolvedCredentials.password; // Don't set password in config - rely on keyboard-interactive
} else { } else {
fileLogger.warn( fileLogger.warn(
"No valid authentication method provided for file manager", "No valid authentication method provided for file manager",
@@ -458,15 +450,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
finish: (responses: string[]) => void, finish: (responses: string[]) => void,
) => { ) => {
const promptTexts = prompts.map((p) => p.prompt); const promptTexts = prompts.map((p) => p.prompt);
fileLogger.info("Keyboard-interactive authentication requested", {
operation: "file_keyboard_interactive",
hostId,
sessionId,
promptsCount: prompts.length,
prompts: promptTexts,
alreadyResponded: keyboardInteractiveResponded,
});
const totpPromptIndex = prompts.findIndex((p) => const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt, p.prompt,
@@ -474,26 +457,26 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
); );
if (totpPromptIndex !== -1) { if (totpPromptIndex !== -1) {
// TOTP prompt detected - need user input
if (responseSent) { if (responseSent) {
fileLogger.warn("Response already sent, ignoring TOTP prompt", { const responses = prompts.map((p) => {
operation: "file_keyboard_interactive", if (/password/i.test(p.prompt) && resolvedCredentials.password) {
hostId, return resolvedCredentials.password;
sessionId, }
return "";
}); });
finish(responses);
return; return;
} }
responseSent = true; responseSent = true;
if (pendingTOTPSessions[sessionId]) { if (pendingTOTPSessions[sessionId]) {
fileLogger.warn( const responses = prompts.map((p) => {
"TOTP session already exists, ignoring duplicate keyboard-interactive", if (/password/i.test(p.prompt) && resolvedCredentials.password) {
{ return resolvedCredentials.password;
operation: "file_keyboard_interactive", }
hostId, return "";
sessionId, });
}, finish(responses);
);
return; return;
} }
@@ -515,36 +498,75 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
resolvedPassword: resolvedCredentials.password, resolvedPassword: resolvedCredentials.password,
}; };
fileLogger.info("Created TOTP session", {
operation: "file_keyboard_interactive_totp",
hostId,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
promptsCount: prompts.length,
});
res.json({ res.json({
requires_totp: true, requires_totp: true,
sessionId, sessionId,
prompt: prompts[totpPromptIndex].prompt, prompt: prompts[totpPromptIndex].prompt,
}); });
} else { } else {
// Non-TOTP prompts (password, etc.) - respond automatically // Non-TOTP prompts (password, etc.)
if (keyboardInteractiveResponded) { const hasStoredPassword =
fileLogger.warn( resolvedCredentials.password &&
"Already responded to keyboard-interactive, ignoring subsequent prompt", resolvedCredentials.authType !== "none";
{
operation: "file_keyboard_interactive", // Check if this is a password prompt
hostId, const passwordPromptIndex = prompts.findIndex((p) =>
sessionId, /password/i.test(p.prompt),
prompts: promptTexts, );
},
); // If no stored password (including authType "none"), prompt the user
if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip,
port,
username,
userId,
prompts,
totpPromptIndex: passwordPromptIndex,
resolvedPassword: resolvedCredentials.password,
};
res.json({
requires_totp: true,
sessionId,
prompt: prompts[passwordPromptIndex].prompt,
isPassword: true,
});
return; return;
} }
keyboardInteractiveResponded = true; // Auto-respond with stored credentials if available
const responses = prompts.map((p) => { const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) { if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password; return resolvedCredentials.password;
@@ -552,16 +574,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
return ""; return "";
}); });
fileLogger.info("Auto-responding to keyboard-interactive prompts", { keyboardInteractiveResponded = true;
operation: "file_keyboard_interactive_response",
hostId,
sessionId,
hasPassword: !!resolvedCredentials.password,
responsesProvided: responses.filter((r) => r !== "").length,
totalPrompts: prompts.length,
prompts: promptTexts,
});
finish(responses); finish(responses);
} }
}, },
@@ -619,14 +632,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
.json({ error: "TOTP session timeout. Please reconnect." }); .json({ error: "TOTP session timeout. Please reconnect." });
} }
fileLogger.info("Submitting TOTP code to SSH server", {
operation: "file_totp_verify",
sessionId,
userId,
codeLength: totpCode.length,
promptsCount: session.prompts?.length || 0,
});
// Build responses for ALL prompts, just like in terminal.ts // Build responses for ALL prompts, just like in terminal.ts
const responses = (session.prompts || []).map((p, index) => { const responses = (session.prompts || []).map((p, index) => {
if (index === session.totpPromptIndex) { if (index === session.totpPromptIndex) {
@@ -649,9 +654,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
let responseSent = false; let responseSent = false;
let responseTimeout: NodeJS.Timeout; let responseTimeout: NodeJS.Timeout;
// Remove old event listeners from /connect endpoint to avoid conflicts // Don't remove event listeners - just add our own 'once' handlers
session.client.removeAllListeners("ready"); // The ssh2 library manages multiple listeners correctly
session.client.removeAllListeners("error"); // Removing them can cause the connection to become unstable
// CRITICAL: Attach event listeners BEFORE calling finish() to avoid race condition // CRITICAL: Attach event listeners BEFORE calling finish() to avoid race condition
session.client.once("ready", () => { session.client.once("ready", () => {
@@ -661,78 +666,69 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId]; delete pendingTOTPSessions[sessionId];
sshSessions[sessionId] = { // Add a small delay to let SSH2 stabilize the connection after keyboard-interactive
client: session.client, // This prevents "Not connected" errors when immediately trying to exec commands
isConnected: true, setTimeout(() => {
lastActive: Date.now(), sshSessions[sessionId] = {
}; client: session.client,
scheduleSessionCleanup(sessionId); isConnected: true,
lastActive: Date.now(),
};
scheduleSessionCleanup(sessionId);
fileLogger.success("TOTP verification successful", { res.json({
operation: "file_totp_verify", status: "success",
sessionId, message: "TOTP verified, SSH connection established",
userId, });
});
res.json({ // Log activity to dashboard API after connection is stable
status: "success", if (session.hostId && session.userId) {
message: "TOTP verified, SSH connection established", (async () => {
}); try {
const hosts = await SimpleDBOps.select(
// Log activity to dashboard API getDb()
if (session.hostId && session.userId) { .select()
(async () => { .from(sshData)
try { .where(
const hosts = await SimpleDBOps.select( and(
getDb() eq(sshData.id, session.hostId!),
.select() eq(sshData.userId, session.userId!),
.from(sshData) ),
.where(
and(
eq(sshData.id, session.hostId!),
eq(sshData.userId, session.userId!),
), ),
), "ssh_data",
"ssh_data", session.userId!,
session.userId!, );
);
const hostName = const hostName =
hosts.length > 0 && hosts[0].name hosts.length > 0 && hosts[0].name
? hosts[0].name ? hosts[0].name
: `${session.username}@${session.ip}:${session.port}`; : `${session.username}@${session.ip}:${session.port}`;
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
await axios.post( await axios.post(
"http://localhost:30006/activity/log", "http://localhost:30006/activity/log",
{ {
type: "file_manager", type: "file_manager",
hostId: session.hostId, hostId: session.hostId,
hostName, hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
}, },
}, {
); headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
fileLogger.info("File manager activity logged (TOTP)", { },
operation: "activity_log", },
userId: session.userId, );
hostId: session.hostId, } catch (error) {
hostName, fileLogger.warn("Failed to log file manager activity (TOTP)", {
}); operation: "activity_log_error",
} catch (error) { userId: session.userId,
fileLogger.warn("Failed to log file manager activity (TOTP)", { hostId: session.hostId,
operation: "activity_log_error", error: error instanceof Error ? error.message : "Unknown error",
userId: session.userId, });
hostId: session.hostId, }
error: error instanceof Error ? error.message : "Unknown error", })();
}); }
} }, 200); // Give SSH2 connection 200ms to fully stabilize after keyboard-interactive
})();
}
}); });
session.client.once("error", (err) => { session.client.once("error", (err) => {

View File

@@ -112,13 +112,9 @@ class SSHConnectionPool {
); );
if (totpPrompt) { if (totpPrompt) {
statsLogger.warn( // Record TOTP failure as permanent - never retry
`Server Stats cannot handle TOTP for host ${host.ip}. Connection will fail.`, // The recordFailure method will log this once
{ authFailureTracker.recordFailure(host.id, "TOTP", true);
operation: "server_stats_totp_detected",
hostId: host.id,
},
);
client.end(); client.end();
reject( reject(
new Error( new Error(
@@ -272,9 +268,109 @@ class MetricsCache {
} }
} }
interface AuthFailureRecord {
count: number;
lastFailure: number;
reason: "TOTP" | "AUTH" | "TIMEOUT";
permanent: boolean; // If true, don't retry at all
}
class AuthFailureTracker {
private failures = new Map<number, AuthFailureRecord>();
private maxRetries = 3;
private backoffBase = 60000; // 1 minute base backoff
recordFailure(
hostId: number,
reason: "TOTP" | "AUTH" | "TIMEOUT",
permanent = false,
): void {
const existing = this.failures.get(hostId);
if (existing) {
existing.count++;
existing.lastFailure = Date.now();
existing.reason = reason;
if (permanent) existing.permanent = true;
} else {
this.failures.set(hostId, {
count: 1,
lastFailure: Date.now(),
reason,
permanent,
});
}
}
shouldSkip(hostId: number): boolean {
const record = this.failures.get(hostId);
if (!record) return false;
// Always skip TOTP hosts
if (record.reason === "TOTP" || record.permanent) {
return true;
}
// Skip if we've exceeded max retries
if (record.count >= this.maxRetries) {
return true;
}
// Calculate exponential backoff
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
return timeSinceFailure < backoffTime;
}
getSkipReason(hostId: number): string | null {
const record = this.failures.get(hostId);
if (!record) return null;
if (record.reason === "TOTP") {
return "TOTP authentication required (metrics unavailable)";
}
if (record.permanent) {
return "Authentication permanently failed";
}
if (record.count >= this.maxRetries) {
return `Too many authentication failures (${record.count} attempts)`;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
if (timeSinceFailure < backoffTime) {
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
}
return null;
}
reset(hostId: number): void {
this.failures.delete(hostId);
// Don't log reset - it's not important
}
cleanup(): void {
// Clean up old failures (older than 1 hour)
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, record] of this.failures.entries()) {
if (!record.permanent && now - record.lastFailure > maxAge) {
this.failures.delete(hostId);
}
}
}
}
const connectionPool = new SSHConnectionPool(); const connectionPool = new SSHConnectionPool();
const requestQueue = new RequestQueue(); const requestQueue = new RequestQueue();
const metricsCache = new MetricsCache(); const metricsCache = new MetricsCache();
const authFailureTracker = new AuthFailureTracker();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
type HostStatus = "online" | "offline"; type HostStatus = "online" | "offline";
@@ -729,6 +825,13 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
os: string | null; os: string | null;
}; };
}> { }> {
// Check if we should skip this host due to auth failures
if (authFailureTracker.shouldSkip(host.id)) {
const reason = authFailureTracker.getSkipReason(host.id);
// Don't log - just skip silently to avoid spam
throw new Error(reason || "Authentication failed");
}
const cached = metricsCache.get(host.id); const cached = metricsCache.get(host.id);
if (cached) { if (cached) {
return cached as ReturnType<typeof collectMetrics> extends Promise<infer T> return cached as ReturnType<typeof collectMetrics> extends Promise<infer T>
@@ -1070,11 +1173,32 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
return result; return result;
}); });
} catch (error) { } catch (error) {
if ( // Record authentication failures for backoff
error instanceof Error && if (error instanceof Error) {
error.message.includes("TOTP authentication required") if (error.message.includes("TOTP authentication required")) {
) { // TOTP failures are already recorded in keyboard-interactive handler
throw error; throw error;
} else if (
error.message.includes("No password available") ||
error.message.includes("Unsupported authentication type") ||
error.message.includes("No SSH key available")
) {
// Configuration errors - permanent failures, don't retry
// recordFailure will log once when first detected
authFailureTracker.recordFailure(host.id, "AUTH", true);
} else if (
error.message.includes("authentication") ||
error.message.includes("Permission denied") ||
error.message.includes("All configured authentication methods failed")
) {
// recordFailure will log once when first detected
authFailureTracker.recordFailure(host.id, "AUTH");
} else if (
error.message.includes("timeout") ||
error.message.includes("ETIMEDOUT")
) {
authFailureTracker.recordFailure(host.id, "TIMEOUT");
}
} }
throw error; throw error;
} }
@@ -1257,13 +1381,17 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
const metrics = await collectMetrics(host); const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() }); res.json({ ...metrics, lastChecked: new Date().toISOString() });
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
// Check if this is a skip due to auth failure tracking
if ( if (
err instanceof Error && errorMessage.includes("TOTP authentication required") ||
err.message.includes("TOTP authentication required") errorMessage.includes("metrics unavailable")
) { ) {
// Don't log as error - this is expected for TOTP hosts
return res.status(403).json({ return res.status(403).json({
error: "TOTP_REQUIRED", error: "TOTP_REQUIRED",
message: "Server Stats unavailable for TOTP-enabled servers", message: errorMessage,
cpu: { percent: null, cores: null, load: null }, cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null }, memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { disk: {
@@ -1280,7 +1408,43 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
}); });
} }
statsLogger.error("Failed to collect metrics", err); // Check if this is a skip due to too many failures or config issues
if (
errorMessage.includes("Too many authentication failures") ||
errorMessage.includes("Retry in") ||
errorMessage.includes("Invalid configuration") ||
errorMessage.includes("Authentication failed")
) {
// Don't log - return error silently to avoid spam
return res.status(429).json({
error: "UNAVAILABLE",
message: errorMessage,
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
},
network: { interfaces: [] },
uptime: { seconds: null, formatted: null },
processes: { total: null, running: null, top: [] },
system: { hostname: null, kernel: null, os: null },
lastChecked: new Date().toISOString(),
});
}
// Only log unexpected errors
if (
!errorMessage.includes("timeout") &&
!errorMessage.includes("offline") &&
!errorMessage.includes("permanently") &&
!errorMessage.includes("none") &&
!errorMessage.includes("No password")
) {
statsLogger.error("Failed to collect metrics", err);
}
if (err instanceof Error && err.message.includes("timeout")) { if (err instanceof Error && err.message.includes("timeout")) {
return res.status(504).json({ return res.status(504).json({
@@ -1339,4 +1503,12 @@ app.listen(PORT, async () => {
operation: "auth_init_error", operation: "auth_init_error",
}); });
} }
// Cleanup old auth failures every 10 minutes
setInterval(
() => {
authFailureTracker.cleanup();
},
10 * 60 * 1000,
);
}); });

View File

@@ -313,12 +313,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
const totpData = data as TOTPResponseData; const totpData = data as TOTPResponseData;
if (keyboardInteractiveFinish && totpData?.code) { if (keyboardInteractiveFinish && totpData?.code) {
const totpCode = totpData.code; const totpCode = totpData.code;
sshLogger.info("TOTP code received from user", {
operation: "totp_response",
userId,
codeLength: totpCode.length,
});
keyboardInteractiveFinish([totpCode]); keyboardInteractiveFinish([totpCode]);
keyboardInteractiveFinish = null; keyboardInteractiveFinish = null;
} else { } else {
@@ -512,177 +506,167 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("ready", () => { sshConn.on("ready", () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
// Small delay to let connection stabilize after keyboard-interactive auth // Immediately try to create shell - don't delay as it can cause connection to be cleaned up
// This helps prevent "No response from server" errors with TOTP // The connection is already ready at this point
setTimeout(() => { if (!sshConn) {
// Check if connection still exists (might have been cleaned up) sshLogger.warn(
if (!sshConn) { "SSH connection was cleaned up before shell could be created",
sshLogger.warn( {
"SSH connection was cleaned up before shell could be created", operation: "ssh_shell",
{ hostId: id,
ip,
port,
username,
},
);
ws.send(
JSON.stringify({
type: "error",
message:
"SSH connection was closed before terminal could be created",
}),
);
return;
}
sshConn.shell(
{
rows: data.rows,
cols: data.cols,
term: "xterm-256color",
} as PseudoTtyOptions,
(err, stream) => {
if (err) {
sshLogger.error("Shell error", err, {
operation: "ssh_shell", operation: "ssh_shell",
hostId: id, hostId: id,
ip, ip,
port, port,
username, username,
},
);
ws.send(
JSON.stringify({
type: "error",
message:
"SSH connection was closed before terminal could be created",
}),
);
return;
}
sshConn.shell(
{
rows: data.rows,
cols: data.cols,
term: "xterm-256color",
} as PseudoTtyOptions,
(err, stream) => {
if (err) {
sshLogger.error("Shell error", err, {
operation: "ssh_shell",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({
type: "error",
message: "Shell error: " + err.message,
}),
);
return;
}
sshStream = stream;
stream.on("data", (data: Buffer) => {
try {
const utf8String = data.toString("utf-8");
ws.send(JSON.stringify({ type: "data", data: utf8String }));
} catch (error) {
sshLogger.error("Error encoding terminal data", error, {
operation: "terminal_data_encoding",
hostId: id,
dataLength: data.length,
});
ws.send(
JSON.stringify({
type: "data",
data: data.toString("latin1"),
}),
);
}
}); });
stream.on("close", () => {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Connection lost",
}),
);
});
stream.on("error", (err: Error) => {
sshLogger.error("SSH stream error", err, {
operation: "ssh_stream",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({
type: "error",
message: "SSH stream error: " + err.message,
}),
);
});
setupPingInterval();
if (initialPath && initialPath.trim() !== "") {
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
stream.write(cdCommand);
}
if (executeCommand && executeCommand.trim() !== "") {
setTimeout(() => {
const command = `${executeCommand}\n`;
stream.write(command);
}, 500);
}
ws.send( ws.send(
JSON.stringify({ type: "connected", message: "SSH connected" }), JSON.stringify({
type: "error",
message: "Shell error: " + err.message,
}),
); );
return;
}
// Log activity to dashboard API sshStream = stream;
if (id && hostConfig.userId) {
(async () => { stream.on("data", (data: Buffer) => {
try { try {
// Fetch host name from database const utf8String = data.toString("utf-8");
const hosts = await SimpleDBOps.select( ws.send(JSON.stringify({ type: "data", data: utf8String }));
getDb() } catch (error) {
.select() sshLogger.error("Error encoding terminal data", error, {
.from(sshData) operation: "terminal_data_encoding",
.where( hostId: id,
and( dataLength: data.length,
eq(sshData.id, id), });
eq(sshData.userId, hostConfig.userId!), ws.send(
), JSON.stringify({
type: "data",
data: data.toString("latin1"),
}),
);
}
});
stream.on("close", () => {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Connection lost",
}),
);
});
stream.on("error", (err: Error) => {
sshLogger.error("SSH stream error", err, {
operation: "ssh_stream",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({
type: "error",
message: "SSH stream error: " + err.message,
}),
);
});
setupPingInterval();
if (initialPath && initialPath.trim() !== "") {
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
stream.write(cdCommand);
}
if (executeCommand && executeCommand.trim() !== "") {
setTimeout(() => {
const command = `${executeCommand}\n`;
stream.write(command);
}, 500);
}
ws.send(
JSON.stringify({ type: "connected", message: "SSH connected" }),
);
// Log activity to dashboard API
if (id && hostConfig.userId) {
(async () => {
try {
// Fetch host name from database
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(
and(
eq(sshData.id, id),
eq(sshData.userId, hostConfig.userId!),
), ),
"ssh_data", ),
hostConfig.userId!, "ssh_data",
); hostConfig.userId!,
);
const hostName = const hostName =
hosts.length > 0 && hosts[0].name hosts.length > 0 && hosts[0].name
? hosts[0].name ? hosts[0].name
: `${username}@${ip}:${port}`; : `${username}@${ip}:${port}`;
await axios.post( await axios.post(
"http://localhost:30006/activity/log", "http://localhost:30006/activity/log",
{ {
type: "terminal", type: "terminal",
hostId: id,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
},
},
);
sshLogger.info("Terminal activity logged", {
operation: "activity_log",
userId: hostConfig.userId,
hostId: id, hostId: id,
hostName, hostName,
}); },
} catch (error) { {
sshLogger.warn("Failed to log terminal activity", { headers: {
operation: "activity_log_error", Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
userId: hostConfig.userId, },
hostId: id, },
error: );
error instanceof Error ? error.message : "Unknown error", } catch (error) {
}); sshLogger.warn("Failed to log terminal activity", {
} operation: "activity_log_error",
})(); userId: hostConfig.userId,
} hostId: id,
}, error:
); error instanceof Error ? error.message : "Unknown error",
}, 100); // Small delay to stabilize connection after keyboard-interactive auth });
}
})();
}
},
);
}); });
sshConn.on("error", (err: Error) => { sshConn.on("error", (err: Error) => {
@@ -738,6 +722,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("close", () => { sshConn.on("close", () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
sshLogger.warn("SSH connection closed by server", {
operation: "ssh_close",
hostId: id,
ip,
port,
hadStream: !!sshStream,
});
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
}); });
@@ -751,17 +742,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
finish: (responses: string[]) => void, finish: (responses: string[]) => void,
) => { ) => {
const promptTexts = prompts.map((p) => p.prompt); const promptTexts = prompts.map((p) => p.prompt);
sshLogger.info("Keyboard-interactive authentication requested", {
operation: "ssh_keyboard_interactive",
hostId: id,
promptsCount: prompts.length,
instructions: instructions || "none",
});
console.log(
`[SSH Keyboard-Interactive] Host ${id}: ${prompts.length} prompts:`,
promptTexts,
);
const totpPromptIndex = prompts.findIndex((p) => const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt, p.prompt,
@@ -769,11 +749,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
); );
if (totpPromptIndex !== -1) { if (totpPromptIndex !== -1) {
// TOTP prompt detected - need user input
if (totpPromptSent) { if (totpPromptSent) {
sshLogger.warn("TOTP prompt already sent, ignoring duplicate", { sshLogger.warn("TOTP prompt asked again - ignoring duplicate", {
operation: "ssh_keyboard_interactive", operation: "ssh_keyboard_interactive_totp_duplicate",
hostId: id, hostId: id,
prompts: promptTexts,
}); });
return; return;
} }
@@ -783,7 +763,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
keyboardInteractiveFinish = (totpResponses: string[]) => { keyboardInteractiveFinish = (totpResponses: string[]) => {
const totpCode = (totpResponses[0] || "").trim(); const totpCode = (totpResponses[0] || "").trim();
// Respond to ALL prompts, not just TOTP
const responses = prompts.map((p, index) => { const responses = prompts.map((p, index) => {
if (index === totpPromptIndex) { if (index === totpPromptIndex) {
return totpCode; return totpCode;
@@ -794,14 +773,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
return ""; return "";
}); });
sshLogger.info("TOTP response being sent to SSH server", {
operation: "totp_verification",
hostId: id,
totpCodeLength: totpCode.length,
totalPrompts: prompts.length,
responsesProvided: responses.filter((r) => r !== "").length,
});
finish(responses); finish(responses);
}; };
ws.send( ws.send(
@@ -811,68 +782,57 @@ wss.on("connection", async (ws: WebSocket, req) => {
}), }),
); );
} else { } else {
// Non-TOTP prompts (password, etc.)
if (keyboardInteractiveResponded) {
sshLogger.warn(
"Already responded to keyboard-interactive, ignoring subsequent prompt",
{
operation: "ssh_keyboard_interactive",
hostId: id,
prompts: promptTexts,
},
);
return;
}
keyboardInteractiveResponded = true;
// Check if we have stored credentials for auto-response
const hasStoredPassword = const hasStoredPassword =
resolvedCredentials.password && resolvedCredentials.password &&
resolvedCredentials.authType !== "none"; resolvedCredentials.authType !== "none";
if (!hasStoredPassword && resolvedCredentials.authType === "none") { // Check if this is a password prompt
// For "none" auth type, prompt user for all keyboard-interactive inputs const passwordPromptIndex = prompts.findIndex((p) =>
const passwordPromptIndex = prompts.findIndex((p) => /password/i.test(p.prompt),
/password/i.test(p.prompt), );
);
if (passwordPromptIndex !== -1) { // If no stored password (including authType "none"), prompt the user
// Ask user for password if (!hasStoredPassword && passwordPromptIndex !== -1) {
keyboardInteractiveFinish = (userResponses: string[]) => { if (keyboardInteractiveResponded) {
const userInput = (userResponses[0] || "").trim();
// Build responses for all prompts
const responses = prompts.map((p, index) => {
if (index === passwordPromptIndex) {
return userInput;
}
return "";
});
sshLogger.info(
"User-provided password being sent to SSH server",
{
operation: "interactive_password_verification",
hostId: id,
passwordLength: userInput.length,
totalPrompts: prompts.length,
},
);
finish(responses);
};
ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[passwordPromptIndex].prompt,
}),
);
return; return;
} }
keyboardInteractiveResponded = true;
keyboardInteractiveFinish = (userResponses: string[]) => {
const userInput = (userResponses[0] || "").trim();
// Build responses for all prompts
const responses = prompts.map((p, index) => {
if (index === passwordPromptIndex) {
return userInput;
}
return "";
});
sshLogger.info(
"User-provided password being sent to SSH server",
{
operation: "interactive_password_verification",
hostId: id,
passwordLength: userInput.length,
totalPrompts: prompts.length,
},
);
finish(responses);
};
ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[passwordPromptIndex].prompt,
}),
);
return;
} }
// Auto-respond with stored credentials // Auto-respond with stored credentials if available
// Allow multiple responses - the server might ask multiple times during auth flow
const responses = prompts.map((p) => { const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) { if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password; return resolvedCredentials.password;
@@ -880,18 +840,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
return ""; return "";
}); });
sshLogger.info("Responding to keyboard-interactive prompts", { keyboardInteractiveResponded = true;
operation: "ssh_keyboard_interactive_response",
hostId: id,
hasPassword: !!resolvedCredentials.password,
responsesProvided: responses.filter((r) => r !== "").length,
totalPrompts: prompts.length,
prompts: promptTexts,
});
console.log(
`[SSH Auto Response] Host ${id}: Sending ${responses.length} responses, ${responses.filter((r) => r !== "").length} non-empty`,
);
finish(responses); finish(responses);
} }
}, },
@@ -963,18 +912,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}; };
if (resolvedCredentials.authType === "none") { if (resolvedCredentials.authType === "none") {
// No credentials provided - rely entirely on keyboard-interactive authentication // Don't set password in config - rely on keyboard-interactive
// This mimics the behavior of the ssh command-line client where it prompts for password/TOTP
sshLogger.info(
"Using interactive authentication (no stored credentials)",
{
operation: "ssh_auth_none",
hostId: id,
ip,
username,
},
);
// Don't set password or privateKey - let keyboard-interactive handle everything
} else if ( } else if (
resolvedCredentials.authType === "key" && resolvedCredentials.authType === "key" &&
resolvedCredentials.key resolvedCredentials.key
@@ -1030,8 +968,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
); );
return; return;
} }
// Set password to offer both password and keyboard-interactive methods
connectConfig.password = resolvedCredentials.password;
} else { } else {
sshLogger.error("No valid authentication method provided"); sshLogger.error("No valid authentication method provided");
ws.send( ws.send(

View File

@@ -1264,7 +1264,9 @@
"enterNewPassword": "Enter your new password for user:", "enterNewPassword": "Enter your new password for user:",
"passwordResetSuccess": "Success!", "passwordResetSuccess": "Success!",
"passwordResetSuccessDesc": "Your password has been successfully reset! You can now log in with your new password.", "passwordResetSuccessDesc": "Your password has been successfully reset! You can now log in with your new password.",
"signUp": "Sign Up" "signUp": "Sign Up",
"authenticationDisabled": "Authentication Disabled",
"authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator."
}, },
"errors": { "errors": {
"notFound": "Page not found", "notFound": "Page not found",

View File

@@ -36,6 +36,7 @@ export interface SSHHost {
defaultPath: string; defaultPath: string;
tunnelConnections: TunnelConnection[]; tunnelConnections: TunnelConnection[];
statsConfig?: string; statsConfig?: string;
terminalConfig?: TerminalConfig;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -60,6 +61,7 @@ export interface SSHHostData {
defaultPath?: string; defaultPath?: string;
tunnelConnections?: TunnelConnection[]; tunnelConnections?: TunnelConnection[];
statsConfig?: string; statsConfig?: string;
terminalConfig?: TerminalConfig;
} }
// ============================================================================ // ============================================================================
@@ -248,6 +250,37 @@ export interface TermixAlert {
actionText?: string; actionText?: string;
} }
// ============================================================================
// TERMINAL CONFIGURATION TYPES
// ============================================================================
export interface TerminalConfig {
// Appearance
cursorBlink: boolean;
cursorStyle: "block" | "underline" | "bar";
fontSize: number;
fontFamily: string;
letterSpacing: number;
lineHeight: number;
theme: string; // Theme key from TERMINAL_THEMES
// Behavior
scrollback: number;
bellStyle: "none" | "sound" | "visual" | "both";
rightClickSelectsWord: boolean;
fastScrollModifier: "alt" | "ctrl" | "shift";
fastScrollSensitivity: number;
minimumContrastRatio: number;
// Advanced
backspaceMode: "normal" | "control-h";
agentForwarding: boolean;
environmentVariables: Array<{ key: string; value: string }>;
startupSnippetId: number | null;
autoMosh: boolean;
moshCommand: string;
}
// ============================================================================ // ============================================================================
// TAB TYPES // TAB TYPES
// ============================================================================ // ============================================================================

View File

@@ -32,6 +32,7 @@ import {
updateSSHHost, updateSSHHost,
enableAutoStart, enableAutoStart,
disableAutoStart, disableAutoStart,
getSnippets,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CredentialSelector } from "@/ui/Desktop/Apps/Credentials/CredentialSelector.tsx"; import { CredentialSelector } from "@/ui/Desktop/Apps/Credentials/CredentialSelector.tsx";
@@ -41,6 +42,31 @@ import { EditorView } from "@codemirror/view";
import type { StatsConfig } from "@/types/stats-widgets"; import type { StatsConfig } from "@/types/stats-widgets";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { Checkbox } from "@/components/ui/checkbox.tsx"; import { Checkbox } from "@/components/ui/checkbox.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Slider } from "@/components/ui/slider.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
CURSOR_STYLES,
BELL_STYLES,
FAST_SCROLL_MODIFIERS,
DEFAULT_TERMINAL_CONFIG,
} from "@/constants/terminal-themes";
import { TerminalPreview } from "@/ui/Desktop/Apps/Terminal/TerminalPreview.tsx";
import type { TerminalConfig } from "@/types";
import { Plus, X } from "lucide-react";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -69,6 +95,7 @@ interface SSHHost {
autoStart: boolean; autoStart: boolean;
}>; }>;
statsConfig?: StatsConfig; statsConfig?: StatsConfig;
terminalConfig?: TerminalConfig;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
credentialId?: number; credentialId?: number;
@@ -89,6 +116,9 @@ export function HostManagerEditor({
const [credentials, setCredentials] = useState< const [credentials, setCredentials] = useState<
Array<{ id: number; username: string; authType: string }> Array<{ id: number; username: string; authType: string }>
>([]); >([]);
const [snippets, setSnippets] = useState<
Array<{ id: number; name: string; content: string }>
>([]);
const [authTab, setAuthTab] = useState< const [authTab, setAuthTab] = useState<
"password" | "key" | "credential" | "none" "password" | "key" | "credential" | "none"
@@ -103,11 +133,13 @@ export function HostManagerEditor({
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [hostsData, credentialsData] = await Promise.all([ const [hostsData, credentialsData, snippetsData] = await Promise.all([
getSSHHosts(), getSSHHosts(),
getCredentials(), getCredentials(),
getSnippets(),
]); ]);
setCredentials(credentialsData); setCredentials(credentialsData);
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
const uniqueFolders = [ const uniqueFolders = [
...new Set( ...new Set(
@@ -239,6 +271,34 @@ export function HostManagerEditor({
"system", "system",
], ],
}), }),
terminalConfig: z
.object({
cursorBlink: z.boolean(),
cursorStyle: z.enum(["block", "underline", "bar"]),
fontSize: z.number().min(8).max(24),
fontFamily: z.string(),
letterSpacing: z.number().min(-2).max(5),
lineHeight: z.number().min(1.0).max(2.0),
theme: z.string(),
scrollback: z.number().min(1000).max(50000),
bellStyle: z.enum(["none", "sound", "visual", "both"]),
rightClickSelectsWord: z.boolean(),
fastScrollModifier: z.enum(["alt", "ctrl", "shift"]),
fastScrollSensitivity: z.number().min(1).max(10),
minimumContrastRatio: z.number().min(1).max(21),
backspaceMode: z.enum(["normal", "control-h"]),
agentForwarding: z.boolean(),
environmentVariables: z.array(
z.object({
key: z.string(),
value: z.string(),
}),
),
startupSnippetId: z.number().nullable(),
autoMosh: z.boolean(),
moshCommand: z.string(),
})
.optional(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.authType === "none") { if (data.authType === "none") {
@@ -327,6 +387,7 @@ export function HostManagerEditor({
defaultPath: "/", defaultPath: "/",
tunnelConnections: [], tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG, statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
}, },
}); });
@@ -386,6 +447,7 @@ export function HostManagerEditor({
defaultPath: cleanedHost.defaultPath || "/", defaultPath: cleanedHost.defaultPath || "/",
tunnelConnections: cleanedHost.tunnelConnections || [], tunnelConnections: cleanedHost.tunnelConnections || [],
statsConfig: cleanedHost.statsConfig || DEFAULT_STATS_CONFIG, statsConfig: cleanedHost.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
}; };
if (defaultAuthType === "password") { if (defaultAuthType === "password") {
@@ -432,6 +494,7 @@ export function HostManagerEditor({
defaultPath: "/", defaultPath: "/",
tunnelConnections: [], tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG, statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
}; };
form.reset(defaultFormData); form.reset(defaultFormData);
@@ -471,6 +534,7 @@ export function HostManagerEditor({
defaultPath: data.defaultPath || "/", defaultPath: data.defaultPath || "/",
tunnelConnections: data.tunnelConnections || [], tunnelConnections: data.tunnelConnections || [],
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG, statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
}; };
submitData.credentialId = null; submitData.credentialId = null;
@@ -1178,7 +1242,7 @@ export function HostManagerEditor({
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</TabsContent> </TabsContent>
<TabsContent value="terminal"> <TabsContent value="terminal" className="space-y-1">
<FormField <FormField
control={form.control} control={form.control}
name="enableTerminal" name="enableTerminal"
@@ -1197,6 +1261,615 @@ export function HostManagerEditor({
</FormItem> </FormItem>
)} )}
/> />
<h1 className="text-xl font-semibold mt-7">
Terminal Customization
</h1>
<Accordion type="multiple" className="w-full">
<AccordionItem value="appearance">
<AccordionTrigger>Appearance</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Theme Preview
</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>Theme</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select theme" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(TERMINAL_THEMES).map(
([key, theme]) => (
<SelectItem key={key} value={key}>
{theme.name}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormDescription>
Choose a color theme for the terminal
</FormDescription>
</FormItem>
)}
/>
{/* Font Family */}
<FormField
control={form.control}
name="terminalConfig.fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>Font Family</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select font" />
</SelectTrigger>
</FormControl>
<SelectContent>
{TERMINAL_FONTS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the font to use in the terminal
</FormDescription>
</FormItem>
)}
/>
{/* Font Size */}
<FormField
control={form.control}
name="terminalConfig.fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>Font Size: {field.value}px</FormLabel>
<FormControl>
<Slider
min={8}
max={24}
step={1}
value={[field.value]}
onValueChange={([value]) =>
field.onChange(value)
}
/>
</FormControl>
<FormDescription>
Adjust the terminal font size
</FormDescription>
</FormItem>
)}
/>
{/* Letter Spacing */}
<FormField
control={form.control}
name="terminalConfig.letterSpacing"
render={({ field }) => (
<FormItem>
<FormLabel>
Letter Spacing: {field.value}px
</FormLabel>
<FormControl>
<Slider
min={-2}
max={10}
step={0.5}
value={[field.value]}
onValueChange={([value]) =>
field.onChange(value)
}
/>
</FormControl>
<FormDescription>
Adjust spacing between characters
</FormDescription>
</FormItem>
)}
/>
{/* Line Height */}
<FormField
control={form.control}
name="terminalConfig.lineHeight"
render={({ field }) => (
<FormItem>
<FormLabel>Line Height: {field.value}</FormLabel>
<FormControl>
<Slider
min={1}
max={2}
step={0.1}
value={[field.value]}
onValueChange={([value]) =>
field.onChange(value)
}
/>
</FormControl>
<FormDescription>
Adjust spacing between lines
</FormDescription>
</FormItem>
)}
/>
{/* Cursor Style */}
<FormField
control={form.control}
name="terminalConfig.cursorStyle"
render={({ field }) => (
<FormItem>
<FormLabel>Cursor Style</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select cursor style" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="block">Block</SelectItem>
<SelectItem value="underline">
Underline
</SelectItem>
<SelectItem value="bar">Bar</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the cursor appearance
</FormDescription>
</FormItem>
)}
/>
{/* Cursor Blink */}
<FormField
control={form.control}
name="terminalConfig.cursorBlink"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Cursor Blink</FormLabel>
<FormDescription>
Enable cursor blinking animation
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
{/* Behavior Settings */}
<AccordionItem value="behavior">
<AccordionTrigger>Behavior</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
{/* Scrollback Buffer */}
<FormField
control={form.control}
name="terminalConfig.scrollback"
render={({ field }) => (
<FormItem>
<FormLabel>
Scrollback Buffer: {field.value} lines
</FormLabel>
<FormControl>
<Slider
min={1000}
max={100000}
step={1000}
value={[field.value]}
onValueChange={([value]) =>
field.onChange(value)
}
/>
</FormControl>
<FormDescription>
Number of lines to keep in scrollback history
</FormDescription>
</FormItem>
)}
/>
{/* Bell Style */}
<FormField
control={form.control}
name="terminalConfig.bellStyle"
render={({ field }) => (
<FormItem>
<FormLabel>Bell Style</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select bell style" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="sound">Sound</SelectItem>
<SelectItem value="visual">Visual</SelectItem>
<SelectItem value="both">Both</SelectItem>
</SelectContent>
</Select>
<FormDescription>
How to handle terminal bell (BEL character)
</FormDescription>
</FormItem>
)}
/>
{/* Right Click Selects Word */}
<FormField
control={form.control}
name="terminalConfig.rightClickSelectsWord"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Right Click Selects Word</FormLabel>
<FormDescription>
Right-clicking selects the word under cursor
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{/* Fast Scroll Modifier */}
<FormField
control={form.control}
name="terminalConfig.fastScrollModifier"
render={({ field }) => (
<FormItem>
<FormLabel>Fast Scroll Modifier</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select modifier" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="alt">Alt</SelectItem>
<SelectItem value="ctrl">Ctrl</SelectItem>
<SelectItem value="shift">Shift</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Modifier key for fast scrolling
</FormDescription>
</FormItem>
)}
/>
{/* Fast Scroll Sensitivity */}
<FormField
control={form.control}
name="terminalConfig.fastScrollSensitivity"
render={({ field }) => (
<FormItem>
<FormLabel>
Fast Scroll Sensitivity: {field.value}
</FormLabel>
<FormControl>
<Slider
min={1}
max={10}
step={1}
value={[field.value]}
onValueChange={([value]) =>
field.onChange(value)
}
/>
</FormControl>
<FormDescription>
Scroll speed multiplier when modifier is held
</FormDescription>
</FormItem>
)}
/>
{/* Minimum Contrast Ratio */}
<FormField
control={form.control}
name="terminalConfig.minimumContrastRatio"
render={({ field }) => (
<FormItem>
<FormLabel>
Minimum Contrast Ratio: {field.value}
</FormLabel>
<FormControl>
<Slider
min={1}
max={21}
step={1}
value={[field.value]}
onValueChange={([value]) =>
field.onChange(value)
}
/>
</FormControl>
<FormDescription>
Automatically adjust colors for better readability
</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
{/* Advanced Settings */}
<AccordionItem value="advanced">
<AccordionTrigger>Advanced</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
{/* Agent Forwarding */}
<FormField
control={form.control}
name="terminalConfig.agentForwarding"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>SSH Agent Forwarding</FormLabel>
<FormDescription>
Forward SSH authentication agent to remote host
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{/* Backspace Mode */}
<FormField
control={form.control}
name="terminalConfig.backspaceMode"
render={({ field }) => (
<FormItem>
<FormLabel>Backspace Mode</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select backspace mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="normal">
Normal (DEL)
</SelectItem>
<SelectItem value="control-h">
Control-H (^H)
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Backspace key behavior for compatibility
</FormDescription>
</FormItem>
)}
/>
{/* Startup Snippet */}
<FormField
control={form.control}
name="terminalConfig.startupSnippetId"
render={({ field }) => (
<FormItem>
<FormLabel>Startup Snippet</FormLabel>
<Select
onValueChange={(value) =>
field.onChange(
value === "none" ? null : parseInt(value),
)
}
value={field.value?.toString() || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select snippet" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{snippets.map((snippet) => (
<SelectItem
key={snippet.id}
value={snippet.id.toString()}
>
{snippet.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Execute a snippet when the terminal connects
</FormDescription>
</FormItem>
)}
/>
{/* Auto MOSH */}
<FormField
control={form.control}
name="terminalConfig.autoMosh"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Auto-MOSH</FormLabel>
<FormDescription>
Automatically run MOSH command on connect
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{/* MOSH Command */}
{form.watch("terminalConfig.autoMosh") && (
<FormField
control={form.control}
name="terminalConfig.moshCommand"
render={({ field }) => (
<FormItem>
<FormLabel>MOSH Command</FormLabel>
<FormControl>
<Input
placeholder="mosh user@server"
{...field}
/>
</FormControl>
<FormDescription>
The MOSH command to execute
</FormDescription>
</FormItem>
)}
/>
)}
{/* Environment Variables */}
<div className="space-y-2">
<label className="text-sm font-medium">
Environment Variables
</label>
<FormDescription>
Set custom environment variables for the terminal
session
</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="Variable name"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`terminalConfig.environmentVariables.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Value" {...field} />
</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" />
Add Variable
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</TabsContent> </TabsContent>
<TabsContent value="tunnel"> <TabsContent value="tunnel">
<FormField <FormField

View File

@@ -12,8 +12,19 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { getCookie, isElectron, logActivity } from "@/ui/main-axios.ts"; import {
getCookie,
isElectron,
logActivity,
getSnippets,
} from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/components/TOTPDialog"; import { TOTPDialog } from "@/ui/components/TOTPDialog";
import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
} from "@/constants/terminal-themes";
import type { TerminalConfig } from "@/types";
interface HostConfig { interface HostConfig {
id?: number; id?: number;
@@ -26,6 +37,7 @@ interface HostConfig {
keyType?: string; keyType?: string;
authType?: string; authType?: string;
credentialId?: number; credentialId?: number;
terminalConfig?: TerminalConfig;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -72,6 +84,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const { t } = useTranslation(); const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm(); const { instance: terminal, ref: xtermRef } = useXTerm();
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
const themeColors =
TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
const backgroundColor = themeColors.background;
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null); const webSocketRef = useRef<WebSocket | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null); const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
@@ -84,6 +101,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const [, setIsAuthenticated] = useState(false); const [, setIsAuthenticated] = useState(false);
const [totpRequired, setTotpRequired] = useState(false); const [totpRequired, setTotpRequired] = useState(false);
const [totpPrompt, setTotpPrompt] = useState<string>(""); const [totpPrompt, setTotpPrompt] = useState<string>("");
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
const isVisibleRef = useRef<boolean>(false); const isVisibleRef = useRef<boolean>(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0); const reconnectAttempts = useRef(0);
@@ -172,12 +190,13 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (webSocketRef.current && code) { if (webSocketRef.current && code) {
webSocketRef.current.send( webSocketRef.current.send(
JSON.stringify({ JSON.stringify({
type: "totp_response", type: isPasswordPrompt ? "password_response" : "totp_response",
data: { code }, data: { code },
}), }),
); );
setTotpRequired(false); setTotpRequired(false);
setTotpPrompt(""); setTotpPrompt("");
setIsPasswordPrompt(false);
} }
} }
@@ -500,6 +519,65 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
// Log activity for recent connections // Log activity for recent connections
logTerminalActivity(); logTerminalActivity();
// Execute post-connection actions
setTimeout(async () => {
// Merge default config with host-specific config
const terminalConfig = {
...DEFAULT_TERMINAL_CONFIG,
...hostConfig.terminalConfig,
};
// Set environment variables
if (
terminalConfig.environmentVariables &&
terminalConfig.environmentVariables.length > 0
) {
for (const envVar of terminalConfig.environmentVariables) {
if (envVar.key && envVar.value && ws.readyState === 1) {
ws.send(
JSON.stringify({
type: "input",
data: `export ${envVar.key}="${envVar.value}"\n`,
}),
);
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
// Execute startup snippet
if (terminalConfig.startupSnippetId) {
try {
const snippets = await getSnippets();
const snippet = snippets.find(
(s: { id: number }) =>
s.id === terminalConfig.startupSnippetId,
);
if (snippet && ws.readyState === 1) {
ws.send(
JSON.stringify({
type: "input",
data: snippet.content + "\n",
}),
);
await new Promise((resolve) => setTimeout(resolve, 200));
}
} catch (err) {
console.warn("Failed to execute startup snippet:", err);
}
}
// Execute MOSH command
if (terminalConfig.autoMosh && ws.readyState === 1) {
ws.send(
JSON.stringify({
type: "input",
data: terminalConfig.moshCommand + "\n",
}),
);
}
}, 500);
} else if (msg.type === "disconnected") { } else if (msg.type === "disconnected") {
wasDisconnectedBySSH.current = true; wasDisconnectedBySSH.current = true;
setIsConnected(false); setIsConnected(false);
@@ -513,6 +591,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
} else if (msg.type === "totp_required") { } else if (msg.type === "totp_required") {
setTotpRequired(true); setTotpRequired(true);
setTotpPrompt(msg.prompt || "Verification code:"); setTotpPrompt(msg.prompt || "Verification code:");
setIsPasswordPrompt(false);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
} else if (msg.type === "password_required") {
setTotpRequired(true);
setTotpPrompt(msg.prompt || "Password:");
setIsPasswordPrompt(true);
if (connectionTimeoutRef.current) { if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current); clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null; connectionTimeoutRef.current = null;
@@ -606,27 +693,66 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
useEffect(() => { useEffect(() => {
if (!terminal || !xtermRef.current) return; if (!terminal || !xtermRef.current) return;
// Merge default config with host-specific config
const config = {
...DEFAULT_TERMINAL_CONFIG,
...hostConfig.terminalConfig,
};
// Get theme colors
const themeColors =
TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
// Get font family with fallback
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === config.fontFamily,
);
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
terminal.options = { terminal.options = {
cursorBlink: true, cursorBlink: config.cursorBlink,
cursorStyle: "bar", cursorStyle: config.cursorStyle,
scrollback: 10000, scrollback: config.scrollback,
fontSize: 14, fontSize: config.fontSize,
fontFamily: fontFamily,
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
allowTransparency: true, allowTransparency: true,
convertEol: true, convertEol: true,
windowsMode: false, windowsMode: false,
macOptionIsMeta: false, macOptionIsMeta: false,
macOptionClickForcesSelection: false, macOptionClickForcesSelection: false,
rightClickSelectsWord: false, rightClickSelectsWord: config.rightClickSelectsWord,
fastScrollModifier: "alt", fastScrollModifier: config.fastScrollModifier,
fastScrollSensitivity: 5, fastScrollSensitivity: config.fastScrollSensitivity,
allowProposedApi: true, allowProposedApi: true,
minimumContrastRatio: 1, minimumContrastRatio: config.minimumContrastRatio,
letterSpacing: 0, letterSpacing: config.letterSpacing,
lineHeight: 1.2, lineHeight: config.lineHeight,
bellStyle: config.bellStyle as "none" | "sound",
theme: { background: "#18181b", foreground: "#f7f7f7" }, theme: {
background: themeColors.background,
foreground: themeColors.foreground,
cursor: themeColors.cursor,
cursorAccent: themeColors.cursorAccent,
selectionBackground: themeColors.selectionBackground,
selectionForeground: themeColors.selectionForeground,
black: themeColors.black,
red: themeColors.red,
green: themeColors.green,
yellow: themeColors.yellow,
blue: themeColors.blue,
magenta: themeColors.magenta,
cyan: themeColors.cyan,
white: themeColors.white,
brightBlack: themeColors.brightBlack,
brightRed: themeColors.brightRed,
brightGreen: themeColors.brightGreen,
brightYellow: themeColors.brightYellow,
brightBlue: themeColors.brightBlue,
brightMagenta: themeColors.brightMagenta,
brightCyan: themeColors.brightCyan,
brightWhite: themeColors.brightWhite,
},
}; };
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
@@ -671,6 +797,24 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
navigator.platform.toUpperCase().indexOf("MAC") >= 0 || navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
// Handle backspace mode (Control-H)
if (
config.backspaceMode === "control-h" &&
e.key === "Backspace" &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey
) {
e.preventDefault();
e.stopPropagation();
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: "\x08" }),
);
}
return false;
}
if (!isMacOS) return; if (!isMacOS) return;
if (e.altKey && !e.metaKey && !e.ctrlKey) { if (e.altKey && !e.metaKey && !e.ctrlKey) {
@@ -739,7 +883,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
webSocketRef.current?.close(); webSocketRef.current?.close();
}; };
}, [xtermRef, terminal]); }, [xtermRef, terminal, hostConfig]);
useEffect(() => { useEffect(() => {
if (!terminal || !hostConfig || !visible) return; if (!terminal || !hostConfig || !visible) return;
@@ -813,7 +957,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}, [splitScreen, isVisible, terminal]); }, [splitScreen, isVisible, terminal]);
return ( return (
<div className="h-full w-full relative"> <div className="h-full w-full relative" style={{ backgroundColor }}>
<div <div
ref={xtermRef} ref={xtermRef}
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"}`} className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"}`}
@@ -829,10 +973,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
prompt={totpPrompt} prompt={totpPrompt}
onSubmit={handleTotpSubmit} onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel} onCancel={handleTotpCancel}
backgroundColor={backgroundColor}
/> />
{isConnecting && ( {isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg"> <div
className="absolute inset-0 flex items-center justify-center"
style={{ backgroundColor }}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div> <div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">{t("terminal.connecting")}</span> <span className="text-gray-300">{t("terminal.connecting")}</span>
@@ -846,6 +994,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const style = document.createElement("style"); const style = document.createElement("style");
style.innerHTML = ` style.innerHTML = `
/* Import popular terminal fonts from Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@font-face { @font-face {
font-family: 'Caskaydia Cove Nerd Font Mono'; font-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype'); src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');

View File

@@ -722,364 +722,412 @@ export function Auth({
{!loggedIn && !authLoading && !totpRequired && ( {!loggedIn && !authLoading && !totpRequired && (
<> <>
<div className="flex gap-2 mb-6"> {(() => {
{passwordLoginAllowed && ( // Check if any authentication method is available
<button const hasLogin = passwordLoginAllowed && !firstUser;
type="button" const hasSignup =
className={cn( (passwordLoginAllowed || firstUser) && registrationAllowed;
"flex-1 py-2 text-base font-medium rounded-md transition-all", const hasOIDC = oidcConfigured;
tab === "login" const hasAnyAuth = hasLogin || hasSignup || hasOIDC;
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
{t("common.login")}
</button>
)}
{passwordLoginAllowed && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
{t("common.register")}
</button>
)}
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t("auth.external")}
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2>
</div>
{tab === "external" || tab === "reset" ? ( if (!hasAnyAuth) {
<div className="flex flex-col gap-5"> return (
{tab === "external" && ( <div className="text-center">
<> <h2 className="text-xl font-bold mb-1">
<div className="text-center text-muted-foreground mb-4"> {t("auth.authenticationDisabled")}
<p>{t("auth.loginWithExternalDesc")}</p> </h2>
</div> <p className="text-muted-foreground">
{(() => { {t("auth.authenticationDisabledDesc")}
if (isElectron()) { </p>
return ( </div>
<div className="text-center p-4 bg-muted/50 rounded-lg border"> );
<p className="text-muted-foreground text-sm"> }
{t("auth.externalNotSupportedInElectron")}
</p> return (
</div> <>
); <div className="flex gap-2 mb-6">
} else { {passwordLoginAllowed && (
return ( <button
<Button type="button"
type="button" className={cn(
className="w-full h-11 mt-2 text-base font-semibold" "flex-1 py-2 text-base font-medium rounded-md transition-all",
disabled={oidcLoading} tab === "login"
onClick={handleOIDCLogin} ? "bg-primary text-primary-foreground shadow"
> : "bg-muted text-muted-foreground hover:bg-accent",
{oidcLoading ? Spinner : t("auth.loginWithExternal")} )}
</Button> onClick={() => {
); setTab("login");
} if (tab === "reset") resetPasswordState();
})()} if (tab === "signup") clearFormFields();
</> }}
)} aria-selected={tab === "login"}
{tab === "reset" && ( disabled={loading || firstUser}
<> >
{resetStep === "initiate" && ( {t("common.login")}
<> </button>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : t("auth.sendResetCode")}
</Button>
</div>
</>
)} )}
{(passwordLoginAllowed || firstUser) &&
{resetStep === "verify" && ( registrationAllowed && (
<> <button
<div className="text-center text-muted-foreground mb-4"> type="button"
<p> className={cn(
{t("auth.enterResetCode")}{" "} "flex-1 py-2 text-base font-medium rounded-md transition-all",
<strong>{localUsername}</strong> tab === "signup"
</p> ? "bg-primary text-primary-foreground shadow"
</div> : "bg-muted text-muted-foreground hover:bg-accent",
<div className="flex flex-col gap-4"> )}
<div className="flex flex-col gap-2"> onClick={() => {
<Label htmlFor="reset-code"> setTab("signup");
{t("auth.resetCode")} if (tab === "reset") resetPasswordState();
</Label> if (tab === "login") clearFormFields();
<Input }}
id="reset-code" aria-selected={tab === "signup"}
type="text" disabled={loading}
required >
maxLength={6} {t("common.register")}
className="h-11 text-base text-center text-lg tracking-widest" </button>
value={resetCode} )}
onChange={(e) => {oidcConfigured && (
setResetCode(e.target.value.replace(/\D/g, "")) <button
} type="button"
disabled={resetLoading} className={cn(
placeholder="000000" "flex-1 py-2 text-base font-medium rounded-md transition-all",
/> tab === "external"
</div> ? "bg-primary text-primary-foreground shadow"
<Button : "bg-muted text-muted-foreground hover:bg-accent",
type="button" )}
className="w-full h-11 text-base font-semibold" onClick={() => {
disabled={resetLoading || resetCode.length !== 6} setTab("external");
onClick={handleVerifyResetCode} if (tab === "reset") resetPasswordState();
> if (tab === "login" || tab === "signup")
{resetLoading ? Spinner : t("auth.verifyCodeButton")} clearFormFields();
</Button> }}
<Button aria-selected={tab === "external"}
type="button" disabled={oidcLoading}
variant="outline" >
className="w-full h-11 text-base font-semibold" {t("auth.external")}
disabled={resetLoading} </button>
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)} )}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2>
</div>
{resetStep === "newPassword" && !resetSuccess && ( {tab === "external" || tab === "reset" ? (
<> <div className="flex flex-col gap-5">
<div className="text-center text-muted-foreground mb-4"> {tab === "external" && (
<p> <>
{t("auth.enterNewPassword")}{" "} <div className="text-center text-muted-foreground mb-4">
<strong>{localUsername}</strong> <p>{t("auth.loginWithExternalDesc")}</p>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-p assword">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div> </div>
<div className="flex flex-col gap-2"> {(() => {
<Label htmlFor="confirm-password"> if (isElectron()) {
{t("auth.confirmNewPassword")} return (
</Label> <div className="text-center p-4 bg-muted/50 rounded-lg border">
<PasswordInput <p className="text-muted-foreground text-sm">
id="confirm-password" {t("auth.externalNotSupportedInElectron")}
required </p>
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200" </div>
value={confirmPassword} );
onChange={(e) => setConfirmPassword(e.target.value)} } else {
disabled={resetLoading} return (
autoComplete="new-password" <Button
/> type="button"
</div> className="w-full h-11 mt-2 text-base font-semibold"
<Button disabled={oidcLoading}
type="button" onClick={handleOIDCLogin}
className="w-full h-11 text-base font-semibold" >
disabled={ {oidcLoading
resetLoading || !newPassword || !confirmPassword ? Spinner
: t("auth.loginWithExternal")}
</Button>
);
} }
onClick={handleCompletePasswordReset} })()}
> </>
{resetLoading )}
? Spinner {tab === "reset" && (
: t("auth.resetPasswordButton")} <>
</Button> {resetStep === "initiate" && (
<Button <>
type="button" <div className="text-center text-muted-foreground mb-4">
variant="outline" <p>{t("auth.resetCodeDesc")}</p>
className="w-full h-11 text-base font-semibold" </div>
disabled={resetLoading} <div className="flex flex-col gap-4">
onClick={() => { <div className="flex flex-col gap-2">
setResetStep("verify"); <Label htmlFor="reset-username">
setNewPassword(""); {t("common.username")}
setConfirmPassword(""); </Label>
}} <Input
> id="reset-username"
{t("common.back")} type="text"
</Button> required
</div> className="h-11 text-base"
</> value={localUsername}
)} onChange={(e) =>
</> setLocalUsername(e.target.value)
)} }
</div> disabled={resetLoading}
) : ( />
<form className="flex flex-col gap-5" onSubmit={handleSubmit}> </div>
<div className="flex flex-col gap-2"> <Button
<Label htmlFor="username">{t("common.username")}</Label> type="button"
<Input className="w-full h-11 text-base font-semibold"
id="username" disabled={resetLoading || !localUsername.trim()}
type="text" onClick={handleInitiatePasswordReset}
required >
className="h-11 text-base" {resetLoading
value={localUsername} ? Spinner
onChange={(e) => setLocalUsername(e.target.value)} : t("auth.sendResetCode")}
disabled={loading || loggedIn} </Button>
/> </div>
</div> </>
<div className="flex flex-col gap-2"> )}
<Label htmlFor="password">{t("common.password")}</Label>
<PasswordInput
id="password"
required
className="h-11 text-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading || loggedIn}
/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="signup-confirm-password"
required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={(e) => setSignupConfirmPassword(e.target.value)}
disabled={loading || loggedIn}
/>
</div>
)}
<Button
type="submit"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}
>
{loading
? Spinner
: tab === "login"
? t("common.login")
: t("auth.signUp")}
</Button>
{tab === "login" && (
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || loggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
{t("auth.resetPasswordButton")}
</Button>
)}
</form>
)}
<div className="mt-6 pt-4 border-t border-dark-border space-y-4"> {resetStep === "verify" && (
<div className="flex items-center justify-between"> <>
<div> <div className="text-center text-muted-foreground mb-4">
<Label className="text-sm text-muted-foreground"> <p>
{t("common.language")} {t("auth.enterResetCode")}{" "}
</Label> <strong>{localUsername}</strong>
</div> </p>
<LanguageSwitcher /> </div>
</div> <div className="flex flex-col gap-4">
{isElectron() && currentServerUrl && ( <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <Label htmlFor="reset-code">
<div> {t("auth.resetCode")}
<Label className="text-sm text-muted-foreground"> </Label>
Server <Input
</Label> id="reset-code"
<div className="text-xs text-muted-foreground truncate max-w-[200px]"> type="text"
{currentServerUrl} required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(
e.target.value.replace(/\D/g, ""),
)
}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || resetCode.length !== 6
}
onClick={handleVerifyResetCode}
>
{resetLoading
? Spinner
: t("auth.verifyCodeButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-p assword">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) =>
setNewPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("auth.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) =>
setConfirmPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading ||
!newPassword ||
!confirmPassword
}
onClick={handleCompletePasswordReset}
>
{resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
</>
)}
</div> </div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">{t("common.username")}</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={loading || loggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">{t("common.password")}</Label>
<PasswordInput
id="password"
required
className="h-11 text-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading || loggedIn}
/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="signup-confirm-password"
required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={(e) =>
setSignupConfirmPassword(e.target.value)
}
disabled={loading || loggedIn}
/>
</div>
)}
<Button
type="submit"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}
>
{loading
? Spinner
: tab === "login"
? t("common.login")
: t("auth.signUp")}
</Button>
{tab === "login" && (
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || loggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
{t("auth.resetPasswordButton")}
</Button>
)}
</form>
)}
<div className="mt-6 pt-4 border-t border-dark-border space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
{t("common.language")}
</Label>
</div>
<LanguageSwitcher />
</div>
{isElectron() && currentServerUrl && (
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
Server
</Label>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
{currentServerUrl}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowServerConfig(true)}
className="h-8 px-3"
>
Edit
</Button>
</div>
)}
</div> </div>
<Button </>
type="button" );
variant="outline" })()}
size="sm"
onClick={() => setShowServerConfig(true)}
className="h-8 px-3"
>
Edit
</Button>
</div>
)}
</div>
</> </>
)} )}
</div> </div>

View File

@@ -12,6 +12,10 @@ import * as ResizablePrimitive from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar.tsx"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
} from "@/constants/terminal-themes";
interface TabData { interface TabData {
id: number; id: number;
@@ -24,7 +28,7 @@ interface TabData {
refresh?: () => void; refresh?: () => void;
}; };
}; };
hostConfig?: unknown; hostConfig?: any;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -258,9 +262,24 @@ export function AppView({
const effectiveVisible = isVisible && ready; const effectiveVisible = isVisible && ready;
const isTerminal = t.type === "terminal";
const terminalConfig = {
...DEFAULT_TERMINAL_CONFIG,
...(t.hostConfig as any)?.terminalConfig,
};
const themeColors =
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
TERMINAL_THEMES.termix.colors;
const backgroundColor = themeColors.background;
return ( return (
<div key={t.id} style={finalStyle}> <div key={t.id} style={finalStyle}>
<div className="absolute inset-0 rounded-md bg-dark-bg"> <div
className="absolute inset-0 rounded-md overflow-hidden"
style={{
backgroundColor: isTerminal ? backgroundColor : "#18181b",
}}
>
{t.type === "terminal" ? ( {t.type === "terminal" ? (
<Terminal <Terminal
ref={t.terminalRef} ref={t.terminalRef}
@@ -605,21 +624,37 @@ export function AppView({
const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab); const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab);
const isFileManager = currentTabData?.type === "file_manager"; const isFileManager = currentTabData?.type === "file_manager";
const isTerminal = currentTabData?.type === "terminal";
const isSplitScreen = allSplitScreenTab.length > 0; const isSplitScreen = allSplitScreenTab.length > 0;
// Get terminal background color for the current tab
const terminalConfig = {
...DEFAULT_TERMINAL_CONFIG,
...(currentTabData?.hostConfig as any)?.terminalConfig,
};
const themeColors =
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
TERMINAL_THEMES.termix.colors;
const terminalBackgroundColor = themeColors.background;
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;
// Determine background color based on current tab type
let containerBackground = "var(--color-dark-bg)";
if (isFileManager && !isSplitScreen) {
containerBackground = "var(--color-dark-bg-darkest)";
} else if (isTerminal) {
containerBackground = terminalBackgroundColor;
}
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="border-2 border-dark-border rounded-lg overflow-hidden overflow-x-hidden relative" className="border-2 border-dark-border rounded-lg overflow-hidden overflow-x-hidden relative"
style={{ style={{
background: background: containerBackground,
isFileManager && !isSplitScreen
? "var(--color-dark-bg-darkest)"
: "var(--color-dark-bg)",
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: 17,
marginTop: topMarginPx, marginTop: topMarginPx,

View File

@@ -620,342 +620,388 @@ export function Auth({
{!internalLoggedIn && !authLoading && !totpRequired && ( {!internalLoggedIn && !authLoading && !totpRequired && (
<> <>
<div className="flex gap-2 mb-6"> {(() => {
{passwordLoginAllowed && ( // Check if any authentication method is available
<button const hasLogin = passwordLoginAllowed && !firstUser;
type="button" const hasSignup =
className={cn( (passwordLoginAllowed || firstUser) && registrationAllowed;
"flex-1 py-2 text-base font-medium rounded-md transition-all", const hasOIDC = oidcConfigured;
tab === "login" const hasAnyAuth = hasLogin || hasSignup || hasOIDC;
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
{t("common.login")}
</button>
)}
{passwordLoginAllowed && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
{t("common.register")}
</button>
)}
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t("auth.external")}
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2>
</div>
{tab === "external" || tab === "reset" ? ( if (!hasAnyAuth) {
<div className="flex flex-col gap-5"> return (
{tab === "external" && ( <div className="text-center">
<> <h2 className="text-xl font-bold mb-1">
<div className="text-center text-muted-foreground mb-4"> {t("auth.authenticationDisabled")}
<p>{t("auth.loginWithExternalDesc")}</p> </h2>
<p className="text-muted-foreground">
{t("auth.authenticationDisabledDesc")}
</p>
</div>
);
}
return (
<>
<div className="flex gap-2 mb-6">
{passwordLoginAllowed && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
{t("common.login")}
</button>
)}
{(passwordLoginAllowed || firstUser) &&
registrationAllowed && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
disabled={loading}
>
{t("common.register")}
</button>
)}
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup")
clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t("auth.external")}
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2>
</div>
{tab === "external" || tab === "reset" ? (
<div className="flex flex-col gap-5">
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.loginWithExternalDesc")}</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
</Button>
</>
)}
{tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) =>
setLocalUsername(e.target.value)
}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading
? Spinner
: t("auth.sendResetCode")}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterResetCode")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">
{t("auth.resetCode")}
</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(
e.target.value.replace(/\D/g, ""),
)
}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || resetCode.length !== 6
}
onClick={handleVerifyResetCode}
>
{resetLoading
? Spinner
: t("auth.verifyCodeButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) =>
setNewPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("auth.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) =>
setConfirmPassword(e.target.value)
}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading ||
!newPassword ||
!confirmPassword
}
onClick={handleCompletePasswordReset}
>
{resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
</>
)}
</div> </div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">{t("common.username")}</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">{t("common.password")}</Label>
<PasswordInput
id="password"
required
className="h-11 text-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="signup-confirm-password"
required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={(e) =>
setSignupConfirmPassword(e.target.value)
}
disabled={loading || internalLoggedIn}
/>
</div>
)}
<Button
type="submit"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}
>
{loading
? Spinner
: tab === "login"
? t("common.login")
: t("auth.signUp")}
</Button>
{tab === "login" && (
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
{t("auth.resetPasswordButton")}
</Button>
)}
</form>
)}
<div className="mt-6 pt-4 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
{t("common.language")}
</Label>
</div>
<LanguageSwitcher />
</div>
</div>
<div className="mt-4">
<Button <Button
type="button" type="button"
className="w-full h-11 mt-2 text-base font-semibold" variant="outline"
disabled={oidcLoading} className="w-full h-11 text-base font-semibold"
onClick={handleOIDCLogin} onClick={() =>
window.open("https://docs.termix.site/install", "_blank")
}
> >
{oidcLoading ? Spinner : t("auth.loginWithExternal")} {t("mobile.viewMobileAppDocs")}
</Button> </Button>
</>
)}
{tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : t("auth.sendResetCode")}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterResetCode")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">
{t("auth.resetCode")}
</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(e.target.value.replace(/\D/g, ""))
}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : t("auth.verifyCodeButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">
{t("auth.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("auth.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={
resetLoading || !newPassword || !confirmPassword
}
onClick={handleCompletePasswordReset}
>
{resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t("common.back")}
</Button>
</div>
</>
)}
</>
)}
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">{t("common.username")}</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">{t("common.password")}</Label>
<PasswordInput
id="password"
required
className="h-11 text-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="signup-confirm-password"
required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={(e) => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div> </div>
)} </>
<Button );
type="submit" })()}
className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}
>
{loading
? Spinner
: tab === "login"
? t("common.login")
: t("auth.signUp")}
</Button>
{tab === "login" && (
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
{t("auth.resetPasswordButton")}
</Button>
)}
</form>
)}
<div className="mt-6 pt-4 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
{t("common.language")}
</Label>
</div>
<LanguageSwitcher />
</div>
</div>
<div className="mt-4">
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
onClick={() =>
window.open("https://docs.termix.site/install", "_blank")
}
>
{t("mobile.viewMobileAppDocs")}
</Button>
</div>
</> </>
)} )}
</div> </div>

View File

@@ -10,6 +10,7 @@ interface TOTPDialogProps {
prompt: string; prompt: string;
onSubmit: (code: string) => void; onSubmit: (code: string) => void;
onCancel: () => void; onCancel: () => void;
backgroundColor?: string;
} }
export function TOTPDialog({ export function TOTPDialog({
@@ -17,6 +18,7 @@ export function TOTPDialog({
prompt, prompt,
onSubmit, onSubmit,
onCancel, onCancel,
backgroundColor,
}: TOTPDialogProps) { }: TOTPDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,7 +26,10 @@ export function TOTPDialog({
return ( return (
<div className="absolute inset-0 flex items-center justify-center z-50"> <div className="absolute inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 bg-dark-bg rounded-md" /> <div
className="absolute inset-0 bg-dark-bg rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10"> <div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-primary" /> <Shield className="w-5 h-5 text-primary" />

View File

@@ -747,6 +747,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
defaultPath: hostData.defaultPath || "/", defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
statsConfig: hostData.statsConfig || null, statsConfig: hostData.statsConfig || null,
terminalConfig: hostData.terminalConfig || null,
}; };
if (!submitData.enableTunnel) { if (!submitData.enableTunnel) {
@@ -804,6 +805,7 @@ export async function updateSSHHost(
defaultPath: hostData.defaultPath || "/", defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
statsConfig: hostData.statsConfig || null, statsConfig: hostData.statsConfig || null,
terminalConfig: hostData.terminalConfig || null,
}; };
if (!submitData.enableTunnel) { if (!submitData.enableTunnel) {