/** * 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; name: string; instructions: string; prompts: KeyboardInteractivePrompt[]; hostname?: string; savedPassword?: string | null; } const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => { if (prompt.echo) return false; const lower = prompt.prompt.toLowerCase(); if (!lower.includes("password")) return false; // Exclude OTP / one-time password / verification code prompts if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false; return true; }; interface KeyboardInteractiveModalProps { request: KeyboardInteractiveRequest | null; onSubmit: (requestId: string, responses: string[], savePassword?: string) => void; onCancel: (requestId: string) => void; } export const KeyboardInteractiveModal: React.FC = ({ request, onSubmit, onCancel, }) => { const { t } = useI18n(); const [responses, setResponses] = useState([]); const [showPasswords, setShowPasswords] = useState([]); 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 handleSubmit = useCallback(() => { if (!request || isSubmitting) return; setIsSubmitting(true); const passwordToSave = savePassword && passwordPromptIndex >= 0 ? responses[passwordPromptIndex] : undefined; onSubmit(request.requestId, responses, passwordToSave); }, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]); const handleCancel = useCallback(() => { if (!request) return; onCancel(request.requestId); }, [request, onCancel]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !isSubmitting) { e.preventDefault(); handleSubmit(); } }, [handleSubmit, isSubmitting] ); if (!request) return null; const title = request.name?.trim() || t("keyboard.interactive.title"); const description = request.instructions?.trim() || (request.hostname ? t("keyboard.interactive.descWithHost", { hostname: request.hostname }) : t("keyboard.interactive.desc")); return ( !open && handleCancel()}>
{title} {description}
{request.prompts.map((prompt, index) => { const isPassword = !prompt.echo; const showPassword = showPasswords[index]; // Clean up prompt text (remove trailing colon and whitespace) const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim(); return (
handleResponseChange(index, e.target.value)} onKeyDown={handleKeyDown} placeholder="" className={isPassword ? "pr-10" : undefined} autoFocus={index === 0} disabled={isSubmitting} /> {isPassword && ( )}
{/* Save password checkbox - shown only for the first password prompt */} {index === passwordPromptIndex && ( )}
); })}
); }; export default KeyboardInteractiveModal;