import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { Button } from "@/components/ui/button.tsx"; import { Form } from "@/components/ui/form.tsx"; import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { Separator } from "@/components/ui/separator.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components/ui/tabs.tsx"; import { Alert, AlertDescription } from "@/components/ui/alert.tsx"; import React, { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { createCredential, updateCredential, getCredentials, getCredentialDetails, detectKeyType, detectPublicKeyType, } from "@/ui/main-axios.ts"; import { useTranslation } from "react-i18next"; import { oneDark } from "@codemirror/theme-one-dark"; import { githubLight } from "@uiw/codemirror-theme-github"; import { useTheme } from "@/components/theme-provider.tsx"; import type { Credential, CredentialEditorProps, CredentialData, } from "../../../../../types"; import { CredentialGeneralTab } from "./tabs/CredentialGeneralTab"; import { CredentialAuthenticationTab } from "./tabs/CredentialAuthenticationTab"; export function CredentialEditor({ editingCredential, onFormSubmit, }: CredentialEditorProps) { const { t } = useTranslation(); const { theme: appTheme } = useTheme(); const isDarkMode = appTheme === "dark" || (appTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); const editorTheme = isDarkMode ? oneDark : githubLight; 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 [activeTab, setActiveTab] = useState("general"); const [formError, setFormError] = useState(null); const [detectedPublicKeyType, setDetectedPublicKeyType] = useState< string | null >(null); const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] = useState(false); const publicKeyDetectionTimeoutRef = useRef(null); useEffect(() => { setFormError(null); }, [activeTab]); 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 { } 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": t("credentials.keyTypeRSA"), "ssh-ed25519": t("credentials.keyTypeEd25519"), "ecdsa-sha2-nistp256": t("credentials.keyTypeEcdsaP256"), "ecdsa-sha2-nistp384": t("credentials.keyTypeEcdsaP384"), "ecdsa-sha2-nistp521": t("credentials.keyTypeEcdsaP521"), "ssh-dss": t("credentials.keyTypeDsa"), "rsa-sha2-256": t("credentials.keyTypeRsaSha256"), "rsa-sha2-512": t("credentials.keyTypeRsaSha512"), invalid: t("credentials.invalidKey"), error: t("credentials.detectionError"), unknown: t("credentials.unknown"), }; return keyTypeMap[keyType] || keyType; }; const onSubmit = async (data: FormData) => { try { setFormError(null); 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 handleFormError = () => { const errors = form.formState.errors; if ( errors.name || errors.username || errors.description || errors.folder || errors.tags ) { setActiveTab("general"); } else if ( errors.password || errors.key || errors.publicKey || errors.keyPassword || errors.keyType ) { setActiveTab("authentication"); } }; 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 (
{formError && ( {formError} )} {t("credentials.general")} {t("credentials.authentication")}
); }