import { zodResolver } from "@hookform/resolvers/zod"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { PasswordInput } from "@/components/ui/password-input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import React, { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { createCredential, updateCredential, getCredentials, getCredentialDetails, detectKeyType, detectPublicKeyType, generatePublicKeyFromPrivate, generateKeyPair, } from "@/ui/main-axios"; import { useTranslation } from "react-i18next"; import CodeMirror from "@uiw/react-codemirror"; import { oneDark } from "@codemirror/theme-one-dark"; import { EditorView } from "@codemirror/view"; import type { Credential, CredentialEditorProps, CredentialData, } from "../../../../types/index.js"; export function CredentialEditor({ editingCredential, onFormSubmit, }: CredentialEditorProps) { const { t } = useTranslation(); const [, setCredentials] = useState([]); const [folders, setFolders] = useState([]); const [, setLoading] = useState(true); const [fullCredentialDetails, setFullCredentialDetails] = useState(null); const [authTab, setAuthTab] = useState<"password" | "key">("password"); const [detectedKeyType, setDetectedKeyType] = useState(null); const [keyDetectionLoading, setKeyDetectionLoading] = useState(false); const keyDetectionTimeoutRef = useRef(null); const [detectedPublicKeyType, setDetectedPublicKeyType] = useState< string | null >(null); const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] = useState(false); const publicKeyDetectionTimeoutRef = useRef(null); useEffect(() => { const fetchData = async () => { try { setLoading(true); const credentialsData = await getCredentials(); setCredentials(credentialsData); const uniqueFolders = [ ...new Set( credentialsData .filter( (credential) => credential.folder && credential.folder.trim() !== "", ) .map((credential) => credential.folder!), ), ].sort() as string[]; setFolders(uniqueFolders); } catch { // Failed to load credentials } finally { setLoading(false); } }; fetchData(); }, []); useEffect(() => { const fetchCredentialDetails = async () => { if (editingCredential) { try { const fullDetails = await getCredentialDetails(editingCredential.id); setFullCredentialDetails(fullDetails); } catch { toast.error(t("credentials.failedToFetchCredentialDetails")); } } else { setFullCredentialDetails(null); } }; fetchCredentialDetails(); }, [editingCredential, t]); const formSchema = z .object({ name: z.string().min(1), description: z.string().optional(), folder: z.string().optional(), tags: z.array(z.string().min(1)).default([]), authType: z.enum(["password", "key"]), username: z.string().min(1), password: z.string().optional(), key: z.any().optional().nullable(), publicKey: z.string().optional(), keyPassword: z.string().optional(), keyType: z .enum([ "auto", "ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ssh-dss", "ssh-rsa-sha2-256", "ssh-rsa-sha2-512", ]) .optional(), }) .superRefine((data, ctx) => { if (data.authType === "password") { if (!data.password || data.password.trim() === "") { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("credentials.passwordRequired"), path: ["password"], }); } } else if (data.authType === "key") { if (!data.key && !editingCredential) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("credentials.sshKeyRequired"), path: ["key"], }); } } }); type FormData = z.infer; const form = useForm({ resolver: zodResolver(formSchema) as unknown as Parameters< typeof useForm >[0]["resolver"], defaultValues: { name: "", description: "", folder: "", tags: [], authType: "password", username: "", password: "", key: null, publicKey: "", keyPassword: "", keyType: "auto", }, }); useEffect(() => { if (editingCredential && fullCredentialDetails) { const defaultAuthType = fullCredentialDetails.authType; setAuthTab(defaultAuthType); setTimeout(() => { const formData = { name: fullCredentialDetails.name || "", description: fullCredentialDetails.description || "", folder: fullCredentialDetails.folder || "", tags: fullCredentialDetails.tags || [], authType: defaultAuthType as "password" | "key", username: fullCredentialDetails.username || "", password: "", key: null, publicKey: "", keyPassword: "", keyType: "auto" as const, }; if (defaultAuthType === "password") { formData.password = fullCredentialDetails.password || ""; } else if (defaultAuthType === "key") { formData.key = fullCredentialDetails.key || ""; formData.publicKey = fullCredentialDetails.publicKey || ""; formData.keyPassword = fullCredentialDetails.keyPassword || ""; formData.keyType = (fullCredentialDetails.keyType as string) || ("auto" as const); } form.reset(formData); setTagInput(""); }, 100); } else if (!editingCredential) { setAuthTab("password"); form.reset({ name: "", description: "", folder: "", tags: [], authType: "password", username: "", password: "", key: null, publicKey: "", keyPassword: "", keyType: "auto", }); setTagInput(""); } }, [editingCredential?.id, fullCredentialDetails, form]); useEffect(() => { return () => { if (keyDetectionTimeoutRef.current) { clearTimeout(keyDetectionTimeoutRef.current); } if (publicKeyDetectionTimeoutRef.current) { clearTimeout(publicKeyDetectionTimeoutRef.current); } }; }, []); const handleKeyTypeDetection = async ( keyValue: string, keyPassword?: string, ) => { if (!keyValue || keyValue.trim() === "") { setDetectedKeyType(null); return; } setKeyDetectionLoading(true); try { const result = await detectKeyType(keyValue, keyPassword); if (result.success) { setDetectedKeyType(result.keyType); } else { setDetectedKeyType("invalid"); } } catch (error) { setDetectedKeyType("error"); console.error("Key type detection error:", error); } finally { setKeyDetectionLoading(false); } }; const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => { if (keyDetectionTimeoutRef.current) { clearTimeout(keyDetectionTimeoutRef.current); } keyDetectionTimeoutRef.current = setTimeout(() => { handleKeyTypeDetection(keyValue, keyPassword); }, 1000); }; const handlePublicKeyTypeDetection = async (publicKeyValue: string) => { if (!publicKeyValue || publicKeyValue.trim() === "") { setDetectedPublicKeyType(null); return; } setPublicKeyDetectionLoading(true); try { const result = await detectPublicKeyType(publicKeyValue); if (result.success) { setDetectedPublicKeyType(result.keyType); } else { setDetectedPublicKeyType("invalid"); console.warn("Public key detection failed:", result.error); } } catch (error) { setDetectedPublicKeyType("error"); console.error("Public key type detection error:", error); } finally { setPublicKeyDetectionLoading(false); } }; const debouncedPublicKeyDetection = (publicKeyValue: string) => { if (publicKeyDetectionTimeoutRef.current) { clearTimeout(publicKeyDetectionTimeoutRef.current); } publicKeyDetectionTimeoutRef.current = setTimeout(() => { handlePublicKeyTypeDetection(publicKeyValue); }, 1000); }; const getFriendlyKeyTypeName = (keyType: string): string => { const keyTypeMap: Record = { "ssh-rsa": "RSA (SSH)", "ssh-ed25519": "Ed25519 (SSH)", "ecdsa-sha2-nistp256": "ECDSA P-256 (SSH)", "ecdsa-sha2-nistp384": "ECDSA P-384 (SSH)", "ecdsa-sha2-nistp521": "ECDSA P-521 (SSH)", "ssh-dss": "DSA (SSH)", "rsa-sha2-256": "RSA-SHA2-256", "rsa-sha2-512": "RSA-SHA2-512", invalid: t("credentials.invalidKey"), error: t("credentials.detectionError"), unknown: t("credentials.unknown"), }; return keyTypeMap[keyType] || keyType; }; const onSubmit = async (data: FormData) => { try { if (!data.name || data.name.trim() === "") { data.name = data.username; } const submitData: CredentialData = { name: data.name, description: data.description, folder: data.folder, tags: data.tags, authType: data.authType, username: data.username, keyType: data.keyType, }; submitData.password = null; submitData.key = null; submitData.publicKey = null; submitData.keyPassword = null; submitData.keyType = null; if (data.authType === "password") { submitData.password = data.password; } else if (data.authType === "key") { submitData.key = data.key; submitData.publicKey = data.publicKey; submitData.keyPassword = data.keyPassword; submitData.keyType = data.keyType; } if (editingCredential) { await updateCredential(editingCredential.id, submitData); toast.success( t("credentials.credentialUpdatedSuccessfully", { name: data.name }), ); } else { await createCredential(submitData); toast.success( t("credentials.credentialAddedSuccessfully", { name: data.name }), ); } if (onFormSubmit) { onFormSubmit(); } window.dispatchEvent(new CustomEvent("credentials:changed")); form.reset(); } catch (error) { console.error("Credential save error:", error); if (error instanceof Error) { toast.error(error.message); } else { toast.error(t("credentials.failedToSaveCredential")); } } }; const [tagInput, setTagInput] = useState(""); const [folderDropdownOpen, setFolderDropdownOpen] = useState(false); const folderInputRef = useRef(null); const folderDropdownRef = useRef(null); const folderValue = form.watch("folder"); const filteredFolders = React.useMemo(() => { if (!folderValue) return folders; return folders.filter((f) => f.toLowerCase().includes(folderValue.toLowerCase()), ); }, [folderValue, folders]); const handleFolderClick = (folder: string) => { form.setValue("folder", folder); setFolderDropdownOpen(false); }; useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( folderDropdownRef.current && !folderDropdownRef.current.contains(event.target as Node) && folderInputRef.current && !folderInputRef.current.contains(event.target as Node) ) { setFolderDropdownOpen(false); } } if (folderDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } else { document.removeEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [folderDropdownOpen]); return (
{t("credentials.general")} {t("credentials.authentication")} {t("credentials.basicInformation")}
( {t("credentials.credentialName")} )} /> ( {t("credentials.username")} )} />
{t("credentials.organization")}
( {t("credentials.description")} )} /> ( {t("credentials.folder")} setFolderDropdownOpen(true)} onChange={(e) => { field.onChange(e); setFolderDropdownOpen(true); }} /> {folderDropdownOpen && filteredFolders.length > 0 && (
{filteredFolders.map((folder) => ( ))}
)}
)} /> ( {t("credentials.tags")}
{(field.value || []).map( (tag: string, idx: number) => ( {tag} ), )} setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === " " && tagInput.trim() !== "") { e.preventDefault(); const currentTags = field.value || []; if (!currentTags.includes(tagInput.trim())) { field.onChange([ ...currentTags, tagInput.trim(), ]); } setTagInput(""); } else if ( e.key === "Enter" && tagInput.trim() !== "" ) { e.preventDefault(); const currentTags = field.value || []; if (!currentTags.includes(tagInput.trim())) { field.onChange([ ...currentTags, tagInput.trim(), ]); } setTagInput(""); } else if ( e.key === "Backspace" && tagInput === "" && (field.value || []).length > 0 ) { const currentTags = field.value || []; field.onChange(currentTags.slice(0, -1)); } }} placeholder={t("credentials.addTagsSpaceToAdd")} />
)} />
{t("credentials.authentication")} { const newAuthType = value as "password" | "key"; setAuthTab(newAuthType); form.setValue("authType", newAuthType); form.setValue("password", ""); form.setValue("key", null); form.setValue("keyPassword", ""); form.setValue("keyType", "auto"); }} className="flex-1 flex flex-col h-full min-h-0" > {t("credentials.password")} {t("credentials.key")} ( {t("credentials.password")} )} />
{t("credentials.generateKeyPair")}
{t("credentials.generateKeyPairDescription")}
( {t("credentials.sshPrivateKey")}
{ const file = e.target.files?.[0]; if (file) { try { const fileContent = await file.text(); field.onChange(fileContent); debouncedKeyDetection( fileContent, form.watch("keyPassword"), ); } catch (error) { console.error( "Failed to read uploaded file:", error, ); } } }} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
{ field.onChange(value); debouncedKeyDetection( value, form.watch("keyPassword"), ); }} placeholder={t( "placeholders.pastePrivateKey", )} theme={oneDark} className="border border-input rounded-md" minHeight="120px" basicSetup={{ lineNumbers: true, foldGutter: false, dropCursor: false, allowMultipleSelections: false, highlightSelectionMatches: false, searchKeymap: false, scrollPastEnd: false, }} extensions={[ EditorView.theme({ ".cm-scroller": { overflow: "auto", }, }), ]} /> {detectedKeyType && (
{t("credentials.detectedKeyType")}:{" "} {getFriendlyKeyTypeName(detectedKeyType)} {keyDetectionLoading && ( ({t("credentials.detectingKeyType")}) )}
)}
)} /> ( {t("credentials.sshPublicKey")}
{ const file = e.target.files?.[0]; if (file) { try { const fileContent = await file.text(); field.onChange(fileContent); debouncedPublicKeyDetection( fileContent, ); } catch (error) { console.error( "Failed to read uploaded public key file:", error, ); } } }} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
{ field.onChange(value); debouncedPublicKeyDetection(value); }} placeholder={t("placeholders.pastePublicKey")} theme={oneDark} className="border border-input rounded-md" minHeight="120px" basicSetup={{ lineNumbers: true, foldGutter: false, dropCursor: false, allowMultipleSelections: false, highlightSelectionMatches: false, searchKeymap: false, scrollPastEnd: false, }} extensions={[ EditorView.theme({ ".cm-scroller": { overflow: "auto", }, }), ]} /> {detectedPublicKeyType && field.value && (
{t("credentials.detectedKeyType")}:{" "} {getFriendlyKeyTypeName( detectedPublicKeyType, )} {publicKeyDetectionLoading && ( ({t("credentials.detectingKeyType")}) )}
)}
)} />
( {t("credentials.keyPassword")} )} />
); }