Files
Termix/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx
T

1138 lines
46 KiB
TypeScript
Raw Normal View History

2025-09-12 14:42:00 -05:00
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,
2025-10-01 15:40:10 -05:00
detectKeyType,
detectPublicKeyType,
generatePublicKeyFromPrivate,
generateKeyPair,
2025-09-12 14:42:00 -05:00
} from "@/ui/main-axios";
import { useTranslation } from "react-i18next";
2025-10-01 15:40:10 -05:00
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
2025-09-12 14:42:00 -05:00
import type {
Credential,
CredentialEditorProps,
CredentialData,
} from "../../../../types/index.js";
export function CredentialEditor({
editingCredential,
onFormSubmit,
}: CredentialEditorProps) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [fullCredentialDetails, setFullCredentialDetails] =
useState<Credential | null>(null);
const [authTab, setAuthTab] = useState<"password" | "key">("password");
2025-10-01 15:40:10 -05:00
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
string | null
>(null);
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] =
useState(false);
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
2025-09-12 14:42:00 -05:00
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 (error) {
} finally {
setLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
const fetchCredentialDetails = async () => {
if (editingCredential) {
try {
const fullDetails = await getCredentialDetails(editingCredential.id);
setFullCredentialDetails(fullDetails);
} catch (error) {
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(),
2025-10-01 15:40:10 -05:00
publicKey: z.string().optional(),
2025-09-12 14:42:00 -05:00
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<typeof formSchema>;
const form = useForm<FormData>({
resolver: zodResolver(formSchema) as any,
defaultValues: {
name: "",
description: "",
folder: "",
tags: [],
authType: "password",
username: "",
password: "",
key: null,
2025-10-01 15:40:10 -05:00
publicKey: "",
2025-09-12 14:42:00 -05:00
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,
2025-10-01 15:40:10 -05:00
publicKey: "",
2025-09-12 14:42:00 -05:00
keyPassword: "",
keyType: "auto" as const,
};
if (defaultAuthType === "password") {
formData.password = fullCredentialDetails.password || "";
} else if (defaultAuthType === "key") {
2025-10-01 15:40:10 -05:00
formData.key = fullCredentialDetails.key || "";
formData.publicKey = fullCredentialDetails.publicKey || "";
2025-09-12 14:42:00 -05:00
formData.keyPassword = fullCredentialDetails.keyPassword || "";
formData.keyType =
(fullCredentialDetails.keyType as any) || ("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,
2025-10-01 15:40:10 -05:00
publicKey: "",
2025-09-12 14:42:00 -05:00
keyPassword: "",
keyType: "auto",
});
setTagInput("");
}
}, [editingCredential?.id, fullCredentialDetails, form]);
2025-10-01 15:40:10 -05:00
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<string, string> = {
"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;
};
2025-09-12 14:42:00 -05:00
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;
2025-10-01 15:40:10 -05:00
submitData.publicKey = null;
2025-09-12 14:42:00 -05:00
submitData.keyPassword = null;
submitData.keyType = null;
if (data.authType === "password") {
submitData.password = data.password;
} else if (data.authType === "key") {
2025-10-01 15:40:10 -05:00
submitData.key = data.key;
submitData.publicKey = data.publicKey;
2025-09-12 14:42:00 -05:00
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) {
2025-10-01 15:40:10 -05:00
console.error("Credential save error:", error);
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error(t("credentials.failedToSaveCredential"));
}
2025-09-12 14:42:00 -05:00
}
};
const [tagInput, setTagInput] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(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 (
<div
className="flex-1 flex flex-col h-full min-h-0 w-full"
key={editingCredential?.id || "new"}
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col flex-1 min-h-0 h-full"
>
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">
{t("credentials.general")}
</TabsTrigger>
<TabsTrigger value="authentication">
{t("credentials.authentication")}
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
2025-10-01 15:40:10 -05:00
<FormLabel className="mb-2 font-bold">
2025-09-12 14:42:00 -05:00
{t("credentials.basicInformation")}
</FormLabel>
2025-10-01 15:40:10 -05:00
<div className="grid grid-cols-12 gap-3">
2025-09-12 14:42:00 -05:00
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("credentials.credentialName")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.credentialName")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("credentials.username")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.username")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
2025-10-01 15:40:10 -05:00
<FormLabel className="mb-2 mt-4 font-bold">
2025-09-12 14:42:00 -05:00
{t("credentials.organization")}
</FormLabel>
2025-10-01 15:40:10 -05:00
<div className="grid grid-cols-26 gap-3">
2025-09-12 14:42:00 -05:00
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="col-span-10">
<FormLabel>{t("credentials.description")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.description")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="folder"
render={({ field }) => (
<FormItem className="col-span-10 relative">
<FormLabel>{t("credentials.folder")}</FormLabel>
<FormControl>
<Input
ref={folderInputRef}
placeholder={t("placeholders.folder")}
className="min-h-[40px]"
autoComplete="off"
value={field.value}
onFocus={() => setFolderDropdownOpen(true)}
onChange={(e) => {
field.onChange(e);
setFolderDropdownOpen(true);
}}
/>
</FormControl>
{folderDropdownOpen && filteredFolders.length > 0 && (
<div
ref={folderDropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{filteredFolders.map((folder) => (
<Button
key={folder}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => handleFolderClick(folder)}
>
{folder}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>{t("credentials.tags")}</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-dark-bg-input focus-within:ring-2 ring-ring min-h-[40px]">
{(field.value || []).map(
(tag: string, idx: number) => (
<span
key={`${tag}-${idx}`}
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs"
>
{tag}
<button
type="button"
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const newTags = (
field.value || []
).filter(
(_: string, i: number) => i !== idx,
);
field.onChange(newTags);
}}
>
×
</button>
</span>
),
)}
<input
type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm"
value={tagInput}
onChange={(e) => 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")}
/>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="authentication">
2025-10-01 15:40:10 -05:00
<FormLabel className="mb-2 font-bold">
2025-09-12 14:42:00 -05:00
{t("credentials.authentication")}
</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
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");
if (newAuthType === "password") {
} else if (newAuthType === "key") {
}
}}
className="flex-1 flex flex-col h-full min-h-0"
>
<TabsList>
<TabsTrigger value="password">
{t("credentials.password")}
</TabsTrigger>
<TabsTrigger value="key">
{t("credentials.key")}
</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("credentials.password")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
2025-10-01 15:40:10 -05:00
<div className="mt-2">
<div className="mb-3 p-3 bg-muted/20 border border-muted rounded-md">
<FormLabel className="mb-2 font-bold block">
{t("credentials.generateKeyPair")}
</FormLabel>
<div className="mb-2">
<div className="text-sm text-muted-foreground">
{t("credentials.generateKeyPairDescription")}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-ed25519",
undefined,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "Ed25519" },
),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate Ed25519 key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
{t("credentials.generateEd25519")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ecdsa-sha2-nistp256",
undefined,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "ECDSA" },
),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate ECDSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
{t("credentials.generateECDSA")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-rsa",
2048,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "RSA" },
),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate RSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
{t("credentials.generateRSA")}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 items-start">
2025-09-12 14:42:00 -05:00
<Controller
control={form.control}
name="key"
render={({ field }) => (
2025-10-01 15:40:10 -05:00
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
2025-09-12 14:42:00 -05:00
{t("credentials.sshPrivateKey")}
</FormLabel>
2025-10-01 15:40:10 -05:00
<div className="mb-1">
<div className="relative inline-block w-full">
2025-09-12 14:42:00 -05:00
<input
id="key-upload"
type="file"
2025-10-01 15:40:10 -05:00
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
2025-09-12 14:42:00 -05:00
const file = e.target.files?.[0];
2025-10-01 15:40:10 -05:00
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,
);
}
}
2025-09-12 14:42:00 -05:00
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
2025-10-01 15:40:10 -05:00
className="w-full justify-start text-left"
2025-09-12 14:42:00 -05:00
>
2025-10-01 15:40:10 -05:00
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
2025-09-12 14:42:00 -05:00
</span>
</Button>
</div>
2025-10-01 15:40:10 -05:00
</div>
2025-09-12 14:42:00 -05:00
<FormControl>
2025-10-01 15:40:10 -05:00
<CodeMirror
2025-09-12 14:42:00 -05:00
value={
typeof field.value === "string"
? field.value
: ""
}
2025-10-01 15:40:10 -05:00
onChange={(value) => {
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",
},
}),
]}
/>
</FormControl>
{detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedKeyType === "invalid" ||
detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
{t("credentials.sshPublicKey")}
</FormLabel>
<div className="mb-1 flex gap-2">
<div className="relative inline-block flex-1">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
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"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPublicKeyFile")}
</span>
</Button>
</div>
<Button
type="button"
variant="outline"
className="flex-shrink-0"
onClick={async () => {
const privateKey = form.watch("key");
if (
!privateKey ||
typeof privateKey !== "string" ||
!privateKey.trim()
) {
toast.error(
t(
"credentials.privateKeyRequiredForGeneration",
),
);
return;
}
try {
const keyPassword =
form.watch("keyPassword");
const result =
await generatePublicKeyFromPrivate(
privateKey,
keyPassword,
);
if (result.success && result.publicKey) {
field.onChange(result.publicKey);
debouncedPublicKeyDetection(
result.publicKey,
);
toast.success(
t(
"credentials.publicKeyGeneratedSuccessfully",
),
);
} else {
toast.error(
result.error ||
t(
"credentials.failedToGeneratePublicKey",
),
);
}
} catch (error) {
console.error(
"Failed to generate public key:",
error,
);
toast.error(
t(
"credentials.failedToGeneratePublicKey",
),
);
}
}}
>
{t("credentials.generatePublicKey")}
</Button>
</div>
<FormControl>
<CodeMirror
value={field.value || ""}
onChange={(value) => {
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",
},
}),
]}
/>
</FormControl>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedPublicKeyType === "invalid" ||
detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(
detectedPublicKeyType,
)}
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-8 gap-3 mt-3">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
2025-09-12 14:42:00 -05:00
/>
</FormControl>
</FormItem>
)}
/>
2025-10-01 15:40:10 -05:00
</div>
</div>
2025-09-12 14:42:00 -05:00
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</ScrollArea>
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" />
<Button className="translate-y-2" type="submit" variant="outline">
{editingCredential
? t("credentials.updateCredential")
: t("credentials.addCredential")}
</Button>
</footer>
</form>
</Form>
</div>
);
}