Merge Luke and Zac
This commit is contained in:
562
src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx
Normal file
562
src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
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,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/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 { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { toast } from "sonner"
|
||||
import { createCredential, updateCredential, getCredentials } from '@/ui/main-axios'
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface Credential {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CredentialEditorProps {
|
||||
editingCredential?: Credential | null;
|
||||
onFormSubmit?: () => void;
|
||||
}
|
||||
|
||||
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 [authTab, setAuthTab] = useState<'password' | 'key'>('password');
|
||||
|
||||
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();
|
||||
|
||||
setFolders(uniqueFolders);
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
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.instanceof(File).optional().nullable(),
|
||||
keyPassword: z.string().optional(),
|
||||
keyType: z.enum([
|
||||
'rsa',
|
||||
'ecdsa',
|
||||
'ed25519'
|
||||
]).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: editingCredential?.name || "",
|
||||
description: editingCredential?.description || "",
|
||||
folder: editingCredential?.folder || "",
|
||||
tags: editingCredential?.tags || [],
|
||||
authType: editingCredential?.authType || "password",
|
||||
username: editingCredential?.username || "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "rsa",
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCredential) {
|
||||
const defaultAuthType = editingCredential.key ? 'key' : 'password';
|
||||
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
form.reset({
|
||||
name: editingCredential.name || "",
|
||||
description: editingCredential.description || "",
|
||||
folder: editingCredential.folder || "",
|
||||
tags: editingCredential.tags || [],
|
||||
authType: defaultAuthType as 'password' | 'key',
|
||||
username: editingCredential.username || "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: (editingCredential.keyType as any) || "rsa",
|
||||
});
|
||||
} else {
|
||||
setAuthTab('password');
|
||||
|
||||
form.reset({
|
||||
name: "",
|
||||
description: "",
|
||||
folder: "",
|
||||
tags: [],
|
||||
authType: "password",
|
||||
username: "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "rsa",
|
||||
});
|
||||
}
|
||||
}, [editingCredential, form]);
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
const formData = data as FormData;
|
||||
|
||||
if (!formData.name || formData.name.trim() === '') {
|
||||
formData.name = formData.username;
|
||||
}
|
||||
|
||||
if (editingCredential) {
|
||||
await updateCredential(editingCredential.id, formData);
|
||||
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name }));
|
||||
} else {
|
||||
await createCredential(formData);
|
||||
toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name }));
|
||||
}
|
||||
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit();
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToSaveCredential'));
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
const keyTypeOptions = [
|
||||
{ value: 'rsa', label: t('credentials.keyTypeRSA') },
|
||||
{ value: 'ecdsa', label: t('credentials.keyTypeECDSA') },
|
||||
{ value: 'ed25519', label: t('credentials.keyTypeEd25519') },
|
||||
];
|
||||
|
||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
keyTypeDropdownOpen &&
|
||||
keyTypeDropdownRef.current &&
|
||||
!keyTypeDropdownRef.current.contains(event.target as Node) &&
|
||||
keyTypeButtonRef.current &&
|
||||
!keyTypeButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
}, [keyTypeDropdownOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
|
||||
<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">
|
||||
<FormLabel className="mb-3 font-bold">{t('credentials.basicInformation')}</FormLabel>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<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>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">{t('credentials.organization')}</FormLabel>
|
||||
<div className="grid grid-cols-26 gap-4">
|
||||
<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-[#18181b] 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-[#222225] 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={() => {
|
||||
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"
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === " " && tagInput.trim() !== "") {
|
||||
e.preventDefault();
|
||||
if (!field.value.includes(tagInput.trim())) {
|
||||
field.onChange([...field.value, tagInput.trim()]);
|
||||
}
|
||||
setTagInput("");
|
||||
} else if (e.key === "Backspace" && tagInput === "" && field.value.length > 0) {
|
||||
field.onChange(field.value.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
placeholder={t('credentials.addTagsSpaceToAdd')}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="authentication">
|
||||
<FormLabel className="mb-3 font-bold">{t('credentials.authentication')}</FormLabel>
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) => {
|
||||
setAuthTab(value as 'password' | 'key');
|
||||
form.setValue('authType', value as 'password' | 'key');
|
||||
// Clear other auth fields when switching
|
||||
if (value === 'password') {
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
} else if (value === 'key') {
|
||||
form.setValue('password', '');
|
||||
}
|
||||
}}
|
||||
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>
|
||||
<Input type="password" placeholder={t('placeholders.password')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="key">
|
||||
<div className="grid grid-cols-15 gap-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4 overflow-hidden min-w-0">
|
||||
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative min-w-0">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
field.onChange(file || null);
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
|
||||
>
|
||||
<span className="block w-full truncate"
|
||||
title={field.value?.name || t('credentials.upload')}>
|
||||
{field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel>{t('credentials.keyPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('placeholders.keyPassword')}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>{t('credentials.keyType')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={keyTypeButtonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
|
||||
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
|
||||
>
|
||||
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('credentials.keyTypeRSA')}
|
||||
</Button>
|
||||
{keyTypeDropdownOpen && (
|
||||
<div
|
||||
ref={keyTypeDropdownRef}
|
||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] 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">
|
||||
{keyTypeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25"/>
|
||||
<Button
|
||||
className=""
|
||||
type="submit"
|
||||
variant="outline"
|
||||
style={{
|
||||
transform: 'translateY(8px)'
|
||||
}}
|
||||
>
|
||||
{editingCredential ? t('credentials.updateCredential') : t('credentials.addCredential')}
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user