v1.8.0 #429
34
package-lock.json
generated
34
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user