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-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@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": {
"version": "1.2.3",
"license": "MIT",

View File

@@ -47,6 +47,7 @@
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@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,
default_path TEXT,
stats_config TEXT,
terminal_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
@@ -404,6 +405,7 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");

View File

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

View File

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

View File

@@ -112,13 +112,9 @@ class SSHConnectionPool {
);
if (totpPrompt) {
statsLogger.warn(
`Server Stats cannot handle TOTP for host ${host.ip}. Connection will fail.`,
{
operation: "server_stats_totp_detected",
hostId: host.id,
},
);
// Record TOTP failure as permanent - never retry
// The recordFailure method will log this once
authFailureTracker.recordFailure(host.id, "TOTP", true);
client.end();
reject(
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 requestQueue = new RequestQueue();
const metricsCache = new MetricsCache();
const authFailureTracker = new AuthFailureTracker();
const authManager = AuthManager.getInstance();
type HostStatus = "online" | "offline";
@@ -729,6 +825,13 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
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);
if (cached) {
return cached as ReturnType<typeof collectMetrics> extends Promise<infer T>
@@ -1070,11 +1173,32 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
return result;
});
} catch (error) {
if (
error instanceof Error &&
error.message.includes("TOTP authentication required")
) {
throw error;
// Record authentication failures for backoff
if (error instanceof Error) {
if (error.message.includes("TOTP authentication required")) {
// TOTP failures are already recorded in keyboard-interactive handler
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;
}
@@ -1257,13 +1381,17 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
// Check if this is a skip due to auth failure tracking
if (
err instanceof Error &&
err.message.includes("TOTP authentication required")
errorMessage.includes("TOTP authentication required") ||
errorMessage.includes("metrics unavailable")
) {
// Don't log as error - this is expected for TOTP hosts
return res.status(403).json({
error: "TOTP_REQUIRED",
message: "Server Stats unavailable for TOTP-enabled servers",
message: errorMessage,
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
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")) {
return res.status(504).json({
@@ -1339,4 +1503,12 @@ app.listen(PORT, async () => {
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;
if (keyboardInteractiveFinish && totpData?.code) {
const totpCode = totpData.code;
sshLogger.info("TOTP code received from user", {
operation: "totp_response",
userId,
codeLength: totpCode.length,
});
keyboardInteractiveFinish([totpCode]);
keyboardInteractiveFinish = null;
} else {
@@ -512,177 +506,167 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
// Small delay to let connection stabilize after keyboard-interactive auth
// This helps prevent "No response from server" errors with TOTP
setTimeout(() => {
// Check if connection still exists (might have been cleaned up)
if (!sshConn) {
sshLogger.warn(
"SSH connection was cleaned up before shell could be created",
{
// Immediately try to create shell - don't delay as it can cause connection to be cleaned up
// The connection is already ready at this point
if (!sshConn) {
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",
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",
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(
JSON.stringify({ type: "connected", message: "SSH connected" }),
JSON.stringify({
type: "error",
message: "Shell error: " + err.message,
}),
);
return;
}
// 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!),
),
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(
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 =
hosts.length > 0 && hosts[0].name
? hosts[0].name
: `${username}@${ip}:${port}`;
const hostName =
hosts.length > 0 && hosts[0].name
? hosts[0].name
: `${username}@${ip}:${port}`;
await axios.post(
"http://localhost:30006/activity/log",
{
type: "terminal",
hostId: id,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
},
},
);
sshLogger.info("Terminal activity logged", {
operation: "activity_log",
userId: hostConfig.userId,
await axios.post(
"http://localhost:30006/activity/log",
{
type: "terminal",
hostId: id,
hostName,
});
} 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
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
},
},
);
} 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",
});
}
})();
}
},
);
});
sshConn.on("error", (err: Error) => {
@@ -738,6 +722,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("close", () => {
clearTimeout(connectionTimeout);
sshLogger.warn("SSH connection closed by server", {
operation: "ssh_close",
hostId: id,
ip,
port,
hadStream: !!sshStream,
});
cleanupSSH(connectionTimeout);
});
@@ -751,17 +742,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
finish: (responses: string[]) => void,
) => {
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) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
@@ -769,11 +749,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
if (totpPromptIndex !== -1) {
// TOTP prompt detected - need user input
if (totpPromptSent) {
sshLogger.warn("TOTP prompt already sent, ignoring duplicate", {
operation: "ssh_keyboard_interactive",
sshLogger.warn("TOTP prompt asked again - ignoring duplicate", {
operation: "ssh_keyboard_interactive_totp_duplicate",
hostId: id,
prompts: promptTexts,
});
return;
}
@@ -783,7 +763,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
keyboardInteractiveFinish = (totpResponses: string[]) => {
const totpCode = (totpResponses[0] || "").trim();
// Respond to ALL prompts, not just TOTP
const responses = prompts.map((p, index) => {
if (index === totpPromptIndex) {
return totpCode;
@@ -794,14 +773,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
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);
};
ws.send(
@@ -811,68 +782,57 @@ wss.on("connection", async (ws: WebSocket, req) => {
}),
);
} 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 =
resolvedCredentials.password &&
resolvedCredentials.authType !== "none";
if (!hasStoredPassword && resolvedCredentials.authType === "none") {
// For "none" auth type, prompt user for all keyboard-interactive inputs
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
// Check if this is a password prompt
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
if (passwordPromptIndex !== -1) {
// Ask user for password
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,
}),
);
// If no stored password (including authType "none"), prompt the user
if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (keyboardInteractiveResponded) {
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) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
@@ -880,18 +840,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
return "";
});
sshLogger.info("Responding to keyboard-interactive prompts", {
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`,
);
keyboardInteractiveResponded = true;
finish(responses);
}
},
@@ -963,18 +912,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
};
if (resolvedCredentials.authType === "none") {
// No credentials provided - rely entirely on keyboard-interactive authentication
// 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
// Don't set password in config - rely on keyboard-interactive
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.key
@@ -1030,8 +968,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
return;
}
// Set password to offer both password and keyboard-interactive methods
connectConfig.password = resolvedCredentials.password;
} else {
sshLogger.error("No valid authentication method provided");
ws.send(

View File

@@ -1264,7 +1264,9 @@
"enterNewPassword": "Enter your new password for user:",
"passwordResetSuccess": "Success!",
"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": {
"notFound": "Page not found",

View File

@@ -36,6 +36,7 @@ export interface SSHHost {
defaultPath: string;
tunnelConnections: TunnelConnection[];
statsConfig?: string;
terminalConfig?: TerminalConfig;
createdAt: string;
updatedAt: string;
}
@@ -60,6 +61,7 @@ export interface SSHHostData {
defaultPath?: string;
tunnelConnections?: TunnelConnection[];
statsConfig?: string;
terminalConfig?: TerminalConfig;
}
// ============================================================================
@@ -248,6 +250,37 @@ export interface TermixAlert {
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
// ============================================================================

View File

@@ -32,6 +32,7 @@ import {
updateSSHHost,
enableAutoStart,
disableAutoStart,
getSnippets,
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
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 { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
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 {
id: number;
@@ -69,6 +95,7 @@ interface SSHHost {
autoStart: boolean;
}>;
statsConfig?: StatsConfig;
terminalConfig?: TerminalConfig;
createdAt: string;
updatedAt: string;
credentialId?: number;
@@ -89,6 +116,9 @@ export function HostManagerEditor({
const [credentials, setCredentials] = useState<
Array<{ id: number; username: string; authType: string }>
>([]);
const [snippets, setSnippets] = useState<
Array<{ id: number; name: string; content: string }>
>([]);
const [authTab, setAuthTab] = useState<
"password" | "key" | "credential" | "none"
@@ -103,11 +133,13 @@ export function HostManagerEditor({
useEffect(() => {
const fetchData = async () => {
try {
const [hostsData, credentialsData] = await Promise.all([
const [hostsData, credentialsData, snippetsData] = await Promise.all([
getSSHHosts(),
getCredentials(),
getSnippets(),
]);
setCredentials(credentialsData);
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
const uniqueFolders = [
...new Set(
@@ -239,6 +271,34 @@ export function HostManagerEditor({
"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) => {
if (data.authType === "none") {
@@ -327,6 +387,7 @@ export function HostManagerEditor({
defaultPath: "/",
tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
},
});
@@ -386,6 +447,7 @@ export function HostManagerEditor({
defaultPath: cleanedHost.defaultPath || "/",
tunnelConnections: cleanedHost.tunnelConnections || [],
statsConfig: cleanedHost.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
};
if (defaultAuthType === "password") {
@@ -432,6 +494,7 @@ export function HostManagerEditor({
defaultPath: "/",
tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
};
form.reset(defaultFormData);
@@ -471,6 +534,7 @@ export function HostManagerEditor({
defaultPath: data.defaultPath || "/",
tunnelConnections: data.tunnelConnections || [],
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
};
submitData.credentialId = null;
@@ -1178,7 +1242,7 @@ export function HostManagerEditor({
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="terminal">
<TabsContent value="terminal" className="space-y-1">
<FormField
control={form.control}
name="enableTerminal"
@@ -1197,6 +1261,615 @@ export function HostManagerEditor({
</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 value="tunnel">
<FormField

View File

@@ -12,8 +12,19 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
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 {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
} from "@/constants/terminal-themes";
import type { TerminalConfig } from "@/types";
interface HostConfig {
id?: number;
@@ -26,6 +37,7 @@ interface HostConfig {
keyType?: string;
authType?: string;
credentialId?: number;
terminalConfig?: TerminalConfig;
[key: string]: unknown;
}
@@ -72,6 +84,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const { t } = useTranslation();
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 webSocketRef = useRef<WebSocket | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
@@ -84,6 +101,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const [, setIsAuthenticated] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
const isVisibleRef = useRef<boolean>(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
@@ -172,12 +190,13 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (webSocketRef.current && code) {
webSocketRef.current.send(
JSON.stringify({
type: "totp_response",
type: isPasswordPrompt ? "password_response" : "totp_response",
data: { code },
}),
);
setTotpRequired(false);
setTotpPrompt("");
setIsPasswordPrompt(false);
}
}
@@ -500,6 +519,65 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
// Log activity for recent connections
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") {
wasDisconnectedBySSH.current = true;
setIsConnected(false);
@@ -513,6 +591,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
} else if (msg.type === "totp_required") {
setTotpRequired(true);
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) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
@@ -606,27 +693,66 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
useEffect(() => {
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 = {
cursorBlink: true,
cursorStyle: "bar",
scrollback: 10000,
fontSize: 14,
fontFamily:
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
cursorBlink: config.cursorBlink,
cursorStyle: config.cursorStyle,
scrollback: config.scrollback,
fontSize: config.fontSize,
fontFamily,
allowTransparency: true,
convertEol: true,
windowsMode: false,
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
fastScrollModifier: "alt",
fastScrollSensitivity: 5,
rightClickSelectsWord: config.rightClickSelectsWord,
fastScrollModifier: config.fastScrollModifier,
fastScrollSensitivity: config.fastScrollSensitivity,
allowProposedApi: true,
minimumContrastRatio: 1,
letterSpacing: 0,
lineHeight: 1.2,
minimumContrastRatio: config.minimumContrastRatio,
letterSpacing: config.letterSpacing,
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();
@@ -671,6 +797,24 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
navigator.platform.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 (e.altKey && !e.metaKey && !e.ctrlKey) {
@@ -739,7 +883,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
webSocketRef.current?.close();
};
}, [xtermRef, terminal]);
}, [xtermRef, terminal, hostConfig]);
useEffect(() => {
if (!terminal || !hostConfig || !visible) return;
@@ -813,7 +957,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}, [splitScreen, isVisible, terminal]);
return (
<div className="h-full w-full relative">
<div className="h-full w-full relative" style={{ backgroundColor }}>
<div
ref={xtermRef}
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}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
backgroundColor={backgroundColor}
/>
{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="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>
@@ -846,6 +994,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const style = document.createElement("style");
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-family: 'Caskaydia Cove Nerd Font Mono';
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');

View File

@@ -722,364 +722,412 @@ export function Auth({
{!loggedIn && !authLoading && !totpRequired && (
<>
<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 && (
<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>
{(() => {
// Check if any authentication method is available
const hasLogin = passwordLoginAllowed && !firstUser;
const hasSignup =
(passwordLoginAllowed || firstUser) && registrationAllowed;
const hasOIDC = oidcConfigured;
const hasAnyAuth = hasLogin || hasSignup || hasOIDC;
{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>
{(() => {
if (isElectron()) {
return (
<div className="text-center p-4 bg-muted/50 rounded-lg border">
<p className="text-muted-foreground text-sm">
{t("auth.externalNotSupportedInElectron")}
</p>
</div>
);
} else {
return (
<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>
</>
if (!hasAnyAuth) {
return (
<div className="text-center">
<h2 className="text-xl font-bold mb-1">
{t("auth.authenticationDisabled")}
</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>
)}
{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>
</>
{(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>
{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"
/>
{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>
<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
{(() => {
if (isElectron()) {
return (
<div className="text-center p-4 bg-muted/50 rounded-lg border">
<p className="text-muted-foreground text-sm">
{t("auth.externalNotSupportedInElectron")}
</p>
</div>
);
} else {
return (
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading
? Spinner
: t("auth.loginWithExternal")}
</Button>
);
}
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 || 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>
)}
})()}
</>
)}
{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>
</>
)}
<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}
{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-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>
) : (
<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>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowServerConfig(true)}
className="h-8 px-3"
>
Edit
</Button>
</div>
)}
</div>
</>
);
})()}
</>
)}
</div>

View File

@@ -12,6 +12,10 @@ import * as ResizablePrimitive from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
} from "@/constants/terminal-themes";
interface TabData {
id: number;
@@ -24,7 +28,7 @@ interface TabData {
refresh?: () => void;
};
};
hostConfig?: unknown;
hostConfig?: any;
[key: string]: unknown;
}
@@ -258,9 +262,24 @@ export function AppView({
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 (
<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" ? (
<Terminal
ref={t.terminalRef}
@@ -605,21 +624,37 @@ export function AppView({
const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab);
const isFileManager = currentTabData?.type === "file_manager";
const isTerminal = currentTabData?.type === "terminal";
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 leftMarginPx = sidebarState === "collapsed" ? 26 : 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 (
<div
ref={containerRef}
className="border-2 border-dark-border rounded-lg overflow-hidden overflow-x-hidden relative"
style={{
background:
isFileManager && !isSplitScreen
? "var(--color-dark-bg-darkest)"
: "var(--color-dark-bg)",
background: containerBackground,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,

View File

@@ -620,342 +620,388 @@ export function Auth({
{!internalLoggedIn && !authLoading && !totpRequired && (
<>
<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 && (
<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>
{(() => {
// Check if any authentication method is available
const hasLogin = passwordLoginAllowed && !firstUser;
const hasSignup =
(passwordLoginAllowed || firstUser) && registrationAllowed;
const hasOIDC = oidcConfigured;
const hasAnyAuth = hasLogin || hasSignup || hasOIDC;
{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>
if (!hasAnyAuth) {
return (
<div className="text-center">
<h2 className="text-xl font-bold mb-1">
{t("auth.authenticationDisabled")}
</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>
) : (
<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
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
variant="outline"
className="w-full h-11 text-base font-semibold"
onClick={() =>
window.open("https://docs.termix.site/install", "_blank")
}
>
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
{t("mobile.viewMobileAppDocs")}
</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>
)}
<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>

View File

@@ -10,6 +10,7 @@ interface TOTPDialogProps {
prompt: string;
onSubmit: (code: string) => void;
onCancel: () => void;
backgroundColor?: string;
}
export function TOTPDialog({
@@ -17,6 +18,7 @@ export function TOTPDialog({
prompt,
onSubmit,
onCancel,
backgroundColor,
}: TOTPDialogProps) {
const { t } = useTranslation();
@@ -24,7 +26,10 @@ export function TOTPDialog({
return (
<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="mb-4 flex items-center gap-2">
<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 || "/",
tunnelConnections: hostData.tunnelConnections || [],
statsConfig: hostData.statsConfig || null,
terminalConfig: hostData.terminalConfig || null,
};
if (!submitData.enableTunnel) {
@@ -804,6 +805,7 @@ export async function updateSSHHost(
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
statsConfig: hostData.statsConfig || null,
terminalConfig: hostData.terminalConfig || null,
};
if (!submitData.enableTunnel) {