Add SSH TOTP authentication support #350

Merged
ZacharyZcR merged 3 commits from feature-ssh-totp into dev-1.8.0 2025-10-08 01:01:48 +00:00
10 changed files with 511 additions and 16 deletions

View File

@@ -94,7 +94,16 @@ interface SSHSession {
timeout?: NodeJS.Timeout;
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

medium

For better type safety and maintainability, the config property should be typed more specifically than any. The ConnectConfig type from the ssh2 library would be appropriate here.

  config: import("ssh2").ConnectConfig;
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) For better type safety and maintainability, the `config` property should be typed more specifically than `any`. The `ConnectConfig` type from the `ssh2` library would be appropriate here. ```suggestion config: import("ssh2").ConnectConfig; ```
}
interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: import("ssh2").ConnectConfig;
createdAt: number;
sessionId: string;
}
const sshSessions: Record<string, SSHSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

high

The pendingTOTPSessions object stores sessions awaiting TOTP verification. If a client initiates a connection but never completes the TOTP step, the corresponding entry in this object is never removed, leading to a memory leak. Consider implementing a periodic cleanup mechanism to remove stale entries from pendingTOTPSessions.

![high](https://www.gstatic.com/codereviewagent/high-priority.svg) The `pendingTOTPSessions` object stores sessions awaiting TOTP verification. If a client initiates a connection but never completes the TOTP step, the corresponding entry in this object is never removed, leading to a memory leak. Consider implementing a periodic cleanup mechanism to remove stale entries from `pendingTOTPSessions`.
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
@@ -239,6 +248,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
host: ip,
port: port || 22,
username,
tryKeyboard: true,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
@@ -364,9 +374,142 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
cleanupSession(sessionId);
});
client.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
fileLogger.info("Keyboard-interactive authentication requested", {
operation: "file_keyboard_interactive",
hostId,
sessionId,
promptsCount: prompts.length,
});
const totpPrompt = prompts.find((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPrompt) {
if (responseSent) return;
responseSent = true;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
};
res.json({
requires_totp: true,
sessionId,
prompt: totpPrompt.prompt,
});
} else {
if (resolvedCredentials.password) {
const responses = prompts.map(() => resolvedCredentials.password || "");
finish(responses);
} else {
finish(prompts.map(() => ""));
}
}
},
);
client.connect(config);
});
app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
const { sessionId, totpCode } = req.body;
const userId = (req as any).userId;
if (!userId) {
fileLogger.error("TOTP verification rejected: no authenticated user", {
operation: "file_totp_auth",
sessionId,
});
return res.status(401).json({ error: "Authentication required" });
}
if (!sessionId || !totpCode) {
return res.status(400).json({ error: "Session ID and TOTP code required" });
}
const session = pendingTOTPSessions[sessionId];
if (!session) {
fileLogger.warn("TOTP session not found or expired", {
operation: "file_totp_verify",
sessionId,
userId,
});
return res.status(404).json({ error: "TOTP session expired. Please reconnect." });
}
delete pendingTOTPSessions[sessionId];
if (Date.now() - session.createdAt > 120000) {
try {
session.client.end();
} catch {}
return res.status(408).json({ error: "TOTP session timeout. Please reconnect." });
}
session.finish([totpCode]);
let responseSent = false;
session.client.on("ready", () => {
if (responseSent) return;
responseSent = true;
sshSessions[sessionId] = {
client: session.client,
isConnected: true,
lastActive: Date.now(),
};
scheduleSessionCleanup(sessionId);
fileLogger.success("TOTP verification successful", {
operation: "file_totp_verify",
sessionId,
userId,
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

medium

The timeout value 120000 is a magic number. It's better to define it as a named constant at the top of the file for better readability and easier maintenance (e.g., const PENDING_TOTP_SESSION_TIMEOUT_MS = 120 * 1000;).

![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) The timeout value `120000` is a magic number. It's better to define it as a named constant at the top of the file for better readability and easier maintenance (e.g., `const PENDING_TOTP_SESSION_TIMEOUT_MS = 120 * 1000;`).
});
res.json({ status: "success", message: "TOTP verified, SSH connection established" });
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

medium

Swallowing errors in an empty catch block can hide potential issues and make debugging difficult. It's better to at least log the error, even if it's just a warning.

    } catch (e) {
      fileLogger.warn("Failed to end client on TOTP timeout", { error: e instanceof Error ? e.message : String(e), sessionId });
    }
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) Swallowing errors in an empty `catch` block can hide potential issues and make debugging difficult. It's better to at least log the error, even if it's just a warning. ```typescript } catch (e) { fileLogger.warn("Failed to end client on TOTP timeout", { error: e instanceof Error ? e.message : String(e), sessionId }); } ```
});
session.client.on("error", (err) => {
if (responseSent) return;
responseSent = true;
fileLogger.error("TOTP verification failed", {
operation: "file_totp_verify",
sessionId,
userId,
error: err.message,
});
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
});
setTimeout(() => {
if (!responseSent) {
responseSent = true;
res.status(408).json({ error: "TOTP verification timeout" });
}
}, 60000);
});
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
const { sessionId } = req.body;
cleanupSession(sessionId);

View File

@@ -95,6 +95,40 @@ class SSHConnectionPool {
reject(err);
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

high

Calling finish([]) sends an empty array of responses. If the server expects a response for each prompt (even an empty string), this will likely cause authentication to fail. To ensure compatibility, you should provide one empty string response for each prompt.

            finish(prompts.map(() => ""));
![high](https://www.gstatic.com/codereviewagent/high-priority.svg) Calling `finish([])` sends an empty array of responses. If the server expects a response for each prompt (even an empty string), this will likely cause authentication to fail. To ensure compatibility, you should provide one empty string response for each prompt. ```suggestion finish(prompts.map(() => "")); ```
});
client.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const totpPrompt = prompts.find((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPrompt) {
statsLogger.warn(
`Server Stats cannot handle TOTP for host ${host.ip}. Connection will fail.`,
{
operation: "server_stats_totp_detected",
hostId: host.id,
},
);
client.end();
reject(new Error("TOTP authentication required but not supported in Server Stats"));
} else if (host.password) {
const responses = prompts.map(() => host.password || "");
finish(responses);
} else {
finish(prompts.map(() => ""));
}
},
);
try {
client.connect(buildSshConfig(host));
} catch (err) {
@@ -487,6 +521,7 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
host: host.ip,
port: host.port || 22,
username: host.username || "root",
tryKeyboard: true,
readyTimeout: 10_000,
algorithms: {
kex: [
@@ -651,7 +686,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
}
return requestQueue.queueRequest(host.id, async () => {
return withSshConnection(host, async (client) => {
try {
return await withSshConnection(host, async (client) => {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
@@ -812,6 +848,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
metricsCache.set(host.id, result);
return result;
});
} catch (error) {
if (error instanceof Error && error.message.includes("TOTP authentication required")) {
throw error;
}
throw error;
}
});
}
@@ -982,6 +1024,17 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() });
} catch (err) {
if (err instanceof Error && err.message.includes("TOTP authentication required")) {
return res.status(403).json({
error: "TOTP_REQUIRED",
message: "Server Stats unavailable for TOTP-enabled servers",
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedHuman: null, totalHuman: null },
lastChecked: new Date().toISOString(),
});
}
statsLogger.error("Failed to collect metrics", err);
if (err instanceof Error && err.message.includes("timeout")) {

View File

@@ -154,6 +154,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
ws.on("close", () => {
const userWs = userConnections.get(userId);
@@ -257,6 +258,33 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(JSON.stringify({ type: "pong" }));
break;
case "totp_response":
if (keyboardInteractiveFinish && data?.code) {
const totpCode = data.code;
sshLogger.info("TOTP code received from user", {
operation: "totp_response",
userId,
codeLength: totpCode.length,
});
keyboardInteractiveFinish([totpCode]);
keyboardInteractiveFinish = null;
} else {
sshLogger.warn("TOTP response received but no callback available", {
operation: "totp_response_error",
userId,
hasCallback: !!keyboardInteractiveFinish,
hasCode: !!data?.code,
});
ws.send(
JSON.stringify({
type: "error",
message: "TOTP authentication state lost. Please reconnect.",
}),
);
}
break;
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
@@ -557,10 +585,56 @@ wss.on("connection", async (ws: WebSocket, req) => {
cleanupSSH(connectionTimeout);
});
sshConn.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
sshLogger.info("Keyboard-interactive authentication requested", {
operation: "ssh_keyboard_interactive",
hostId: id,
promptsCount: prompts.length,
instructions: instructions || "none",
});
const totpPrompt = prompts.find((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPrompt) {
keyboardInteractiveFinish = finish;
ws.send(
JSON.stringify({
type: "totp_required",
prompt: totpPrompt.prompt,
}),
);
} else {
if (resolvedCredentials.password) {
const responses = prompts.map(() => resolvedCredentials.password || "");
finish(responses);
} else {
sshLogger.warn("Keyboard-interactive requires password but none available", {
operation: "ssh_keyboard_interactive_no_password",
hostId: id,
});
finish(prompts.map(() => ""));
}
}
},
);
const connectConfig: any = {
host: ip,
port,
username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,

View File

@@ -709,7 +709,11 @@
"connectionTimeout": "Connection timeout",
"terminalTitle": "Terminal - {{host}}",
"terminalWithPath": "Terminal - {{host}}:{{path}}",
"runTitle": "Running {{command}} - {{host}}"
"runTitle": "Running {{command}} - {{host}}",
"totpRequired": "Two-Factor Authentication Required",
"totpCodeLabel": "Verification Code",
"totpPlaceholder": "000000",
"totpVerify": "Verify"
},
"fileManager": {
"title": "File Manager",
@@ -993,7 +997,9 @@
"fileComparison": "File Comparison: {{file1}} vs {{file2}}",
"fileTooLarge": "File too large: {{error}}",
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Failed to load file: {{error}}"
"loadFileFailed": "Failed to load file: {{error}}",
"connectedSuccessfully": "Connected successfully",
"totpVerificationFailed": "TOTP verification failed"
},
"tunnels": {
"title": "SSH Tunnels",
@@ -1093,6 +1099,8 @@
"refreshing": "Refreshing...",
"serverOffline": "Server Offline",
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
"totpRequired": "TOTP Authentication Required",
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
"load": "Load",
"free": "Free",
"available": "Available"

View File

@@ -703,6 +703,10 @@
"terminalTitle": "终端 - {{host}}",
"terminalWithPath": "终端 - {{host}}:{{path}}",
"runTitle": "运行 {{command}} - {{host}}",
"totpRequired": "需要双因素认证",
"totpCodeLabel": "验证码",
"totpPlaceholder": "000000",
"totpVerify": "验证",
"connect": "连接主机",
"disconnect": "断开连接",
"clear": "清屏",
@@ -984,7 +988,9 @@
"fileComparison": "文件对比:{{file1}} 与 {{file2}}",
"fileTooLarge": "文件过大:{{error}}",
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
"loadFileFailed": "加载文件失败:{{error}}"
"loadFileFailed": "加载文件失败:{{error}}",
"connectedSuccessfully": "连接成功",
"totpVerificationFailed": "TOTP 验证失败"
},
"tunnels": {
"title": "SSH 隧道",
@@ -1072,6 +1078,8 @@
"refreshing": "正在刷新...",
"serverOffline": "服务器离线",
"cannotFetchMetrics": "无法从离线服务器获取指标",
"totpRequired": "需要 TOTP 认证",
"totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
"load": "负载"
},
"auth": {

View File

@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/components/TOTPDialog";
import {
Upload,
FolderPlus,
@@ -38,6 +39,7 @@ import {
renameSSHItem,
moveSSHItem,
connectSSH,
verifySSHTOTP,
getSSHStatus,
keepSSHAlive,
identifySSHSymlink,
@@ -98,6 +100,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [searchQuery, setSearchQuery] = useState("");
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [totpRequired, setTotpRequired] = useState(false);
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
const [isClosing, setIsClosing] = useState<boolean>(false);
@@ -288,6 +293,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
userId: currentHost.userId,
});
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sessionId);
setTotpPrompt(result.prompt || "Verification code:");
setIsLoading(false);
return;
}
setSshSessionId(sessionId);
try {
@@ -1241,6 +1254,47 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(null);
}
async function handleTotpSubmit(code: string) {
if (!totpSessionId || !code) return;
try {
setIsLoading(true);
const result = await verifySSHTOTP(totpSessionId, code);
if (result?.status === "success") {
setTotpRequired(false);
setTotpPrompt("");
setSshSessionId(totpSessionId);
setTotpSessionId(null);
try {
const response = await listSSHFiles(totpSessionId, currentPath);
const files = Array.isArray(response)
? response
: response?.files || [];
setFiles(files);
clearSelection();
initialLoadDoneRef.current = true;
toast.success(t("fileManager.connectedSuccessfully"));
} catch (dirError: any) {
console.error("Failed to load initial directory:", dirError);
}
}
} catch (error: any) {
console.error("TOTP verification failed:", error);
toast.error(t("fileManager.totpVerificationFailed"));
} finally {
setIsLoading(false);
}
}
function handleTotpCancel() {
setTotpRequired(false);
setTotpPrompt("");
setTotpSessionId(null);
if (onClose) onClose();
}
function generateUniqueName(
baseName: string,
type: "file" | "directory",
@@ -1809,6 +1863,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
/>
</div>
</div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
</div>
);
}

View File

@@ -119,11 +119,16 @@ export function Server({
setMetrics(data);
setShowStatsUI(true);
}
} catch (error) {
} catch (error: any) {
if (!cancelled) {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.failedToFetchMetrics"));
if (error?.code === "TOTP_REQUIRED" ||
(error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
toast.error(t("serverStats.totpUnavailable"));
} else {
toast.error(t("serverStats.failedToFetchMetrics"));
}
}
} finally {
if (!cancelled) {
@@ -210,17 +215,28 @@ export function Server({
setMetrics(data);
setShowStatsUI(true);
} catch (error: any) {
if (error?.response?.status === 503) {
if (error?.code === "TOTP_REQUIRED" ||
(error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
toast.error(t("serverStats.totpUnavailable"));
setMetrics(null);
setShowStatsUI(false);
} else if (error?.response?.status === 503 || error?.status === 503) {
setServerStatus("offline");
} else if (error?.response?.status === 504) {
setMetrics(null);
setShowStatsUI(false);
} else if (error?.response?.status === 504 || error?.status === 504) {
setServerStatus("offline");
} else if (error?.response?.status === 404) {
setMetrics(null);
setShowStatsUI(false);
} else if (error?.response?.status === 404 || error?.status === 404) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
}
setMetrics(null);
setShowStatsUI(false);
} finally {
setIsRefreshing(false);
}

View File

@@ -13,6 +13,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/components/TOTPDialog";
interface SSHTerminalProps {
hostConfig: any;
@@ -55,6 +56,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const isVisibleRef = useRef<boolean>(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
@@ -102,6 +105,25 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} catch (_) {}
}
function handleTotpSubmit(code: string) {
if (webSocketRef.current && code) {
webSocketRef.current.send(
JSON.stringify({
type: "totp_response",
data: { code },
}),
);
setTotpRequired(false);
setTotpPrompt("");
}
}
function handleTotpCancel() {
setTotpRequired(false);
setTotpPrompt("");
if (onClose) onClose();
}
function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = { cols, rows };
@@ -422,6 +444,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
if (onClose) {
onClose();
}
} else if (msg.type === "totp_required") {
setTotpRequired(true);
setTotpPrompt(msg.prompt || "Verification code:");
}
} catch (error) {
toast.error(t("terminal.messageParseError"));
@@ -729,6 +754,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
</div>
</div>
)}
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
</div>
);
});

View File

@@ -0,0 +1,81 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Shield } from "lucide-react";
import { useTranslation } from "react-i18next";
interface TOTPDialogProps {
isOpen: boolean;
prompt: string;
onSubmit: (code: string) => void;
onCancel: () => void;
}
export function TOTPDialog({
isOpen,
prompt,
onSubmit,
onCancel,
}: TOTPDialogProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 bg-black/50" />
<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" />
<h3 className="text-lg font-semibold">
{t("terminal.totpRequired")}
</h3>
</div>
<p className="text-muted-foreground text-sm mb-4">{prompt}</p>
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem(
"totpCode",
) as HTMLInputElement;
if (input && input.value.trim()) {
onSubmit(input.value.trim());
}
}}
gemini-code-assist[bot] commented 2025-10-05 07:19:12 +00:00 (Migrated from github.com)
Review

medium

The form submission logic directly accesses the form element from the event to get the input value. A more idiomatic React approach is to use a controlled component by managing the input's value with useState. This makes the component's state more predictable and easier to manage.

Example:

const [code, setCode] = React.useState('');

// in form
<Input value={code} onChange={(e) => setCode(e.target.value)} ... />

// in onSubmit
if (code.trim()) {
  onSubmit(code.trim());
}
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) The form submission logic directly accesses the form element from the event to get the input value. A more idiomatic React approach is to use a controlled component by managing the input's value with `useState`. This makes the component's state more predictable and easier to manage. Example: ```tsx const [code, setCode] = React.useState(''); // in form <Input value={code} onChange={(e) => setCode(e.target.value)} ... /> // in onSubmit if (code.trim()) { onSubmit(code.trim()); } ```
className="space-y-4"
>
<div>
<Label htmlFor="totpCode">
{t("terminal.totpCodeLabel")}
</Label>
<Input
id="totpCode"
name="totpCode"
type="text"
autoFocus
maxLength={6}
pattern="[0-9]*"
inputMode="numeric"
placeholder="000000"
className="text-center text-lg tracking-widest mt-1.5"
/>
</div>
<div className="flex gap-2">
<Button type="submit" className="flex-1">
{t("terminal.totpVerify")}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1"
>
{t("common.cancel")}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -526,8 +526,8 @@ function handleApiError(error: unknown, operation: string): never {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const message = error.response?.data?.error || error.message;
const code = error.response?.data?.code;
const message = error.response?.data?.message || error.response?.data?.error || error.message;
const code = error.response?.data?.code || error.response?.data?.error;
const url = error.config?.url;
const method = error.config?.method?.toUpperCase();
@@ -554,11 +554,15 @@ function handleApiError(error: unknown, operation: string): never {
throw new ApiError(errorMessage, 401, "AUTH_REQUIRED");
} else if (status === 403) {
authLogger.warn(`Access denied: ${method} ${url}`, errorContext);
throw new ApiError(
"Access denied. You do not have permission to perform this action.",
const apiError = new ApiError(
code === "TOTP_REQUIRED"
? message
: "Access denied. You do not have permission to perform this action.",
403,
"ACCESS_DENIED",
code || "ACCESS_DENIED",
);
(apiError as any).response = error.response;
throw apiError;
} else if (status === 404) {
apiLogger.warn(`Not found: ${method} ${url}`, errorContext);
throw new ApiError(
@@ -1057,6 +1061,21 @@ export async function disconnectSSH(sessionId: string): Promise<any> {
}
}
export async function verifySSHTOTP(
sessionId: string,
totpCode: string,
): Promise<any> {
try {
const response = await fileManagerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
return response.data;
} catch (error) {
handleApiError(error, "verify SSH TOTP");
}
}
export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {