Files
NetMesh/components/KeyboardInteractiveModal.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

318 lines
11 KiB
TypeScript

/**
* Keyboard Interactive Authentication Modal
* Global modal for handling SSH keyboard-interactive authentication (2FA/MFA)
* This modal displays prompts from the SSH server and collects user responses.
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export interface KeyboardInteractivePrompt {
prompt: string;
echo: boolean;
}
export interface KeyboardInteractiveRequest {
requestId: string;
sessionId?: string;
hostId?: string;
scope?: "terminal" | "external";
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
hostname?: string;
savedPassword?: string | null;
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
allowSavePassword?: boolean;
}
type KeyboardInteractiveServerPromptInput = Pick<
KeyboardInteractiveRequest,
"name" | "instructions" | "prompts" | "hostname"
>;
/** Formats the server-supplied keyboard-interactive prompt block for display. */
export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
const lines: string[] = [];
const name = request.name?.trim();
const hostname = request.hostname?.trim();
const instructions = request.instructions?.trim();
const hasServerText = !!instructions || !!(name && name !== hostname);
if (!hasServerText) return "";
if (name && name !== hostname) {
lines.push(name.endsWith(":") ? name : `${name}:`);
}
if (instructions) {
for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
lines.push(`| ${line}`);
}
}
for (const prompt of request.prompts || []) {
const promptText = prompt.prompt?.trim();
if (promptText) {
lines.push(`| ${promptText}`);
}
}
return lines.join("\n");
}
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
if (prompt.echo) return false;
const lower = prompt.prompt.toLowerCase();
if (!lower.includes("password") && !lower.includes("passwd")) return false;
// Keep aligned with electron/bridges/sshAuthHelper.cjs OTP_PROMPT_PATTERN so
// the modal never prefills the host login password into a second-factor field
// (#2150). Backend also omits savedPassword for those challenges; this is
// defense in depth if a caller still passes it.
if (
lower.includes("one-time") ||
lower.includes("otp") ||
lower.includes("verification") ||
lower.includes("token") ||
lower.includes("code") ||
lower.includes("passcode") ||
lower.includes("2fa") ||
lower.includes("mfa") ||
lower.includes("two-factor") ||
lower.includes("two factor") ||
lower.includes("multi-factor") ||
lower.includes("multi factor") ||
lower.includes("second factor") ||
lower.includes("secondary password") ||
lower.includes("secondary authentication") ||
lower.includes("second password") ||
lower.includes("additional password") ||
lower.includes("re-enter password") ||
lower.includes("reenter password") ||
lower.includes("confirm password") ||
lower.includes("edr") ||
lower.includes("duo")
) {
return false;
}
return true;
};
interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
onCancel: (requestId: string) => boolean | Promise<boolean>;
}
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
request,
onSubmit,
onCancel,
}) => {
const { t } = useI18n();
const [responses, setResponses] = useState<string[]>([]);
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePassword, setSavePassword] = useState(false);
// Index of the first password prompt (if any)
const passwordPromptIndex = useMemo(() => {
if (!request) return -1;
return request.prompts.findIndex(p => isAPasswordPrompt(p));
}, [request]);
// Reset state when request changes
useEffect(() => {
if (request) {
const initial = request.prompts.map(() => "");
// Auto-fill saved password into the password prompt
if (request.savedPassword && passwordPromptIndex >= 0) {
initial[passwordPromptIndex] = request.savedPassword;
}
setResponses(initial);
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
setSavePassword(false);
}
}, [request, passwordPromptIndex]);
const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
const updated = [...prev];
updated[index] = value;
return updated;
});
}, []);
const toggleShowPassword = useCallback((index: number) => {
setShowPasswords((prev) => {
const updated = [...prev];
updated[index] = !updated[index];
return updated;
});
}, []);
const canSavePassword = request?.allowSavePassword !== false;
const handleSubmit = useCallback(async () => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
const passwordToSave =
canSavePassword && savePassword && passwordPromptIndex >= 0
? responses[passwordPromptIndex]
: undefined;
try {
const submitted = await onSubmit(request.requestId, responses, passwordToSave);
if (!submitted) setIsSubmitting(false);
} catch {
setIsSubmitting(false);
}
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex, canSavePassword]);
const handleCancel = useCallback(async () => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
try {
const cancelled = await onCancel(request.requestId);
if (!cancelled) setIsSubmitting(false);
} catch {
setIsSubmitting(false);
}
}, [request, onCancel, isSubmitting]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting) {
e.preventDefault();
void handleSubmit();
}
},
[handleSubmit, isSubmitting]
);
if (!request) return null;
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
const title = t("keyboard.interactive.title");
const description =
request.hostname
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
: t("keyboard.interactive.desc");
return (
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
<DialogContent
className="sm:max-w-[425px]"
hideCloseButton
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-1">
{description}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
{serverPromptText && (
<pre className="max-h-36 overflow-auto rounded-md border border-border/60 bg-muted/45 px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]">
{serverPromptText}
</pre>
)}
{request.prompts.map((prompt, index) => {
const isPassword = !prompt.echo;
const showPassword = showPasswords[index];
const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
// Clean up prompt text (remove trailing colon and whitespace)
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
return (
<div key={index} className="space-y-2">
{showPromptLabel && (
<Label htmlFor={`ki-prompt-${index}`}>
{promptLabel || t("keyboard.interactive.response")}
</Label>
)}
<div className="relative">
<Input
id={`ki-prompt-${index}`}
type={isPassword && !showPassword ? "password" : "text"}
value={responses[index] || ""}
onChange={(e) => handleResponseChange(index, e.target.value)}
onKeyDown={handleKeyDown}
placeholder=""
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
disabled={isSubmitting}
/>
{isPassword && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
onClick={() => toggleShowPassword(index)}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
{/* Save password checkbox - first-factor password prompts only */}
{canSavePassword && index === passwordPromptIndex && (
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={savePassword}
onChange={(e) => setSavePassword(e.target.checked)}
disabled={isSubmitting}
className="accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t("keyboard.interactive.savePassword")}
</span>
</label>
)}
</div>
);
})}
</div>
<div className="flex items-center justify-between pt-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("keyboard.interactive.verifying")}
</>
) : (
t("keyboard.interactive.submit")
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default KeyboardInteractiveModal;