From e5939dadbeb82253c24c1259904ec0015a8ddca9 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 7 Sep 2025 04:28:25 +0800 Subject: [PATCH] Comprehensive credentials management and SSH host system fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend improvements: - Fix SSH host update authentication system (use effectiveAuthType) - Support both authType and authMethod field mapping - Prevent empty password/key from overwriting existing credentials - Add proper credentialId support for credential-based authentication Frontend enhancements: - Add dedicated "Add Credential" page similar to Add Host - Refactor credentials management with consistent Host Manager styling - Fix credential display bug (hide keyType for password credentials) - Enhance CredentialSelector with improved API response handling i18n internationalization: - Fix Admin Settings i18n issues with comprehensive translation support - Add missing credentials.* translation keys for both English and Chinese - Fix hardcoded key type labels (RSA, ECDSA, Ed25519) with proper translations - Add missing placeholders and form labels UI/UX improvements: - Update HostManager with 4-tab structure including credential management - Improve visual consistency across credential components - Better error handling and user feedback - Enhanced form validation and submission logic 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- public/locales/en/translation.json | 34 +- public/locales/zh/translation.json | 32 + src/backend/database/routes/ssh.ts | 36 +- src/components/CredentialSelector.tsx | 16 +- src/ui/Desktop/Admin/AdminSettings.tsx | 87 +- .../Apps/Credentials/CredentialEditor.tsx | 1029 ++++++++--------- .../Apps/Credentials/CredentialsManager.tsx | 681 ++++------- .../Desktop/Apps/Host Manager/HostManager.tsx | 31 +- src/ui/main-axios.ts | 2 + 9 files changed, 918 insertions(+), 1030 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 9418bd75..a94b87d6 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -10,7 +10,8 @@ "deleteCredential": "Delete Credential", "updateCredential": "Update Credential", "credentialName": "Credential Name", - "credentialDescription": "Description", + "credentialDescription": "Description", + "username": "Username", "searchCredentials": "Search credentials...", "selectFolder": "Select Folder", "selectAuthType": "Select Auth Type", @@ -33,6 +34,32 @@ "failedToSaveCredential": "Failed to save credential", "failedToFetchCredentialDetails": "Failed to fetch credential details", "failedToFetchHostsUsing": "Failed to fetch hosts using this credential", + "loadingCredentials": "Loading credentials...", + "retry": "Retry", + "noCredentials": "No Credentials", + "noCredentialsMessage": "Start by creating your first SSH credential", + "sshCredentials": "SSH Credentials", + "credentialsCount": "{{count}} credentials", + "refresh": "Refresh", + "passwordRequired": "Password is required", + "sshKeyRequired": "SSH key is required", + "credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully", + "general": "General", + "description": "Description", + "folder": "Folder", + "tags": "Tags", + "addTagsSpaceToAdd": "Add tags (press space to add)", + "password": "Password", + "key": "Key", + "sshPrivateKey": "SSH Private Key", + "upload": "Upload", + "updateKey": "Update Key", + "keyPassword": "Key Password (optional)", + "keyType": "Key Type", + "keyTypeRSA": "RSA", + "keyTypeECDSA": "ECDSA", + "keyTypeEd25519": "Ed25519", + "updateCredential": "Update Credential", "basicInfo": "Basic Info", "authentication": "Authentication", "organization": "Organization", @@ -236,6 +263,7 @@ }, "admin": { "title": "Admin Settings", + "oidc": "OIDC", "users": "Users", "userManagement": "User Management", "makeAdmin": "Make Admin", @@ -770,6 +798,9 @@ "folder": "folder", "password": "password", "keyPassword": "key password", + "credentialName": "My SSH Server", + "description": "SSH credential description", + "searchCredentials": "Search credentials by name, username, or tags...", "sshConfig": "endpoint ssh configuration", "homePath": "/home", "clientId": "your-client-id", @@ -780,6 +811,7 @@ "userIdField": "sub", "usernameField": "name", "scopes": "openid email profile", + "userinfoUrl": "https://your-provider.com/application/o/userinfo/", "enterUsername": "Enter username to make admin", "searchHosts": "Search hosts by name, username, IP, folder, tags...", "enterPassword": "Enter your password", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index ec5e0eea..678d383d 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -11,6 +11,7 @@ "updateCredential": "更新凭据", "credentialName": "凭据名称", "credentialDescription": "描述", + "username": "用户名", "searchCredentials": "搜索凭据...", "selectFolder": "选择文件夹", "selectAuthType": "选择认证类型", @@ -33,6 +34,32 @@ "failedToSaveCredential": "保存凭据失败", "failedToFetchCredentialDetails": "获取凭据详情失败", "failedToFetchHostsUsing": "获取使用此凭据的主机失败", + "loadingCredentials": "正在加载凭据...", + "retry": "重试", + "noCredentials": "暂无凭据", + "noCredentialsMessage": "开始创建您的第一个SSH凭据", + "sshCredentials": "SSH凭据", + "credentialsCount": "{{count}} 个凭据", + "refresh": "刷新", + "passwordRequired": "密码为必填项", + "sshKeyRequired": "SSH密钥为必填项", + "credentialAddedSuccessfully": "凭据「{{name}}」添加成功", + "general": "常规", + "description": "描述", + "folder": "文件夹", + "tags": "标签", + "addTagsSpaceToAdd": "添加标签(按空格键添加)", + "password": "密码", + "key": "密钥", + "sshPrivateKey": "SSH私钥", + "upload": "上传", + "updateKey": "更新密钥", + "keyPassword": "密钥密码(可选)", + "keyType": "密钥类型", + "keyTypeRSA": "RSA", + "keyTypeECDSA": "ECDSA", + "keyTypeEd25519": "Ed25519", + "updateCredential": "更新凭据", "basicInfo": "基本信息", "authentication": "认证方式", "organization": "组织管理", @@ -236,6 +263,7 @@ }, "admin": { "title": "管理员设置", + "oidc": "OIDC", "users": "用户", "userManagement": "用户管理", "makeAdmin": "设为管理员", @@ -807,6 +835,9 @@ "hostname": "主机名", "folder": "文件夹", "password": "密码", + "credentialName": "我的SSH服务器", + "description": "SSH凭据描述", + "searchCredentials": "按名称、用户名或标签搜索凭据...", "keyPassword": "密钥密码", "sshConfig": "端点 SSH 配置", "homePath": "/home", @@ -818,6 +849,7 @@ "userIdField": "sub", "usernameField": "name", "scopes": "openid email profile", + "userinfoUrl": "https://your-provider.com/application/o/userinfo/", "enterUsername": "输入用户名以设为管理员", "searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...", "enterPassword": "输入您的密码", diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 49cf82c4..a49b9956 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -144,6 +144,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque username, password, authMethod, + authType, + credentialId, key, keyPassword, keyType, @@ -160,6 +162,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque return res.status(400).json({error: 'Invalid SSH data'}); } + const effectiveAuthType = authType || authMethod; const sshDataObj: any = { userId: userId, name, @@ -168,7 +171,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque ip, port, username, - authType: authMethod, + authType: effectiveAuthType, + credentialId: credentialId || null, pin: !!pin ? 1 : 0, enableTerminal: !!enableTerminal ? 1 : 0, enableTunnel: !!enableTunnel ? 1 : 0, @@ -177,12 +181,12 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque defaultPath: defaultPath || null, }; - if (authMethod === 'password') { + if (effectiveAuthType === 'password') { sshDataObj.password = password; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; - } else if (authMethod === 'key') { + } else if (effectiveAuthType === 'key') { sshDataObj.key = key; sshDataObj.keyPassword = keyPassword; sshDataObj.keyType = keyType; @@ -232,6 +236,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re username, password, authMethod, + authType, + credentialId, key, keyPassword, keyType, @@ -249,6 +255,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re return res.status(400).json({error: 'Invalid SSH data'}); } + const effectiveAuthType = authType || authMethod; const sshDataObj: any = { name, folder, @@ -256,7 +263,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re ip, port, username, - authType: authMethod, + authType: effectiveAuthType, + credentialId: credentialId || null, pin: !!pin ? 1 : 0, enableTerminal: !!enableTerminal ? 1 : 0, enableTunnel: !!enableTunnel ? 1 : 0, @@ -265,15 +273,23 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re defaultPath: defaultPath || null, }; - if (authMethod === 'password') { - sshDataObj.password = password; + if (effectiveAuthType === 'password') { + if (password) { + sshDataObj.password = password; + } sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; - } else if (authMethod === 'key') { - sshDataObj.key = key; - sshDataObj.keyPassword = keyPassword; - sshDataObj.keyType = keyType; + } else if (effectiveAuthType === 'key') { + if (key) { + sshDataObj.key = key; + } + if (keyPassword !== undefined) { + sshDataObj.keyPassword = keyPassword; + } + if (keyType) { + sshDataObj.keyType = keyType; + } sshDataObj.password = null; } diff --git a/src/components/CredentialSelector.tsx b/src/components/CredentialSelector.tsx index 7eab693a..d41361fe 100644 --- a/src/components/CredentialSelector.tsx +++ b/src/components/CredentialSelector.tsx @@ -34,7 +34,9 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP try { setLoading(true); const data = await getCredentials(); - setCredentials(data.credentials || []); + // Handle both possible response formats: direct array or nested object + const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []); + setCredentials(credentialsArray); } catch (error) { console.error('Failed to fetch credentials:', error); setCredentials([]); @@ -102,7 +104,7 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP ref={buttonRef} type="button" variant="outline" - className="w-full justify-between text-left rounded-md px-3 py-2 bg-[#18181b] border border-input text-foreground" + className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200" onClick={() => setDropdownOpen(!dropdownOpen)} > {loading ? ( @@ -127,9 +129,9 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP {dropdownOpen && (
-
+
{t('common.clear')} @@ -166,8 +168,8 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP type="button" variant="ghost" size="sm" - className={`w-full justify-start text-left rounded-md px-2 py-2 hover:bg-white/15 focus:bg-white/20 focus:outline-none ${ - credential.id === value ? 'bg-white/20' : '' + className={`w-full justify-start text-left rounded-lg px-2 py-2 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${ + credential.id === value ? 'bg-muted' : '' }`} onClick={() => handleCredentialSelect(credential)} > diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index 29208d09..8602d693 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -208,7 +208,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
-

Admin Settings

+

{t('admin.title')}

@@ -221,11 +221,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. - OIDC + {t('admin.oidc')} - Users + {t('admin.users')} @@ -246,9 +246,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
-

External Authentication (OIDC)

-

Configure external identity provider for - OIDC/OAuth2 authentication.

+

{t('admin.externalAuthentication')}

+

{t('admin.configureExternalProvider')}

{oidcError && ( @@ -259,50 +258,50 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
- + handleOIDCConfigChange('client_id', e.target.value)} - placeholder="your-client-id" required/> + placeholder={t('placeholders.clientId')} required/>
- + handleOIDCConfigChange('client_secret', e.target.value)} - placeholder="your-client-secret" required/> + placeholder={t('placeholders.clientSecret')} required/>
- + handleOIDCConfigChange('authorization_url', e.target.value)} - placeholder="https://your-provider.com/application/o/authorize/" + placeholder={t('placeholders.authUrl')} required/>
- + handleOIDCConfigChange('issuer_url', e.target.value)} - placeholder="https://your-provider.com/application/o/termix/" required/> + placeholder={t('placeholders.redirectUrl')} required/>
- + handleOIDCConfigChange('token_url', e.target.value)} - placeholder="https://your-provider.com/application/o/token/" required/> + placeholder={t('placeholders.tokenUrl')} required/>
- + handleOIDCConfigChange('identifier_path', e.target.value)} - placeholder="sub" required/> + placeholder={t('placeholders.userIdField')} required/>
- + handleOIDCConfigChange('name_path', e.target.value)} - placeholder="name" required/> + placeholder={t('placeholders.usernameField')} required/>
- + handleOIDCConfigChange('scopes', e.target.value)} placeholder={t('placeholders.scopes')} required/> @@ -311,17 +310,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. handleOIDCConfigChange('userinfo_url', e.target.value)} - placeholder="https://your-provider.com/application/o/userinfo/"/> -
-
- - handleOIDCConfigChange('userinfo_url', e.target.value)} - placeholder="https://your-provider.com/application/o/userinfo/"/> + placeholder={t('placeholders.userinfoUrl')}/>
+ disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')} + size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}
{usersLoading ? ( -
Loading users...
+
{t('admin.loadingUsers')}
) : (
- Username - Type - Actions + {t('admin.username')} + {t('admin.type')} + {t('admin.actions')} @@ -364,11 +357,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. {user.username} {user.is_admin && ( Admin + className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')} )} {user.is_oidc ? "External" : "Local"} + className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')} + disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')} {makeAdminError && ( @@ -413,14 +406,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
-

Current Admins

+

{t('admin.currentAdmins')}

- Username - Type - Actions + {t('admin.username')} + {t('admin.type')} + {t('admin.actions')} @@ -432,13 +425,13 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')} {admin.is_oidc ? "External" : "Local"} + className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')} diff --git a/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx index 74436ecd..06ebd274 100644 --- a/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx +++ b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx @@ -1,34 +1,26 @@ -import React, { useState, useEffect } from 'react'; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Switch } from "@/components/ui/switch"; -import { Separator } from "@/components/ui/separator"; -import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"; -import { - X, - Plus, - Eye, - EyeOff, - Upload, - Download, - Key, - Shield, - AlertTriangle, - Check, - Tag, - Folder, - User, - Lock -} from 'lucide-react'; -import { createCredential, updateCredential, getCredentialFolders } from '@/ui/main-axios'; -import { toast } from 'sonner'; -import { useTranslation } from 'react-i18next'; +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; @@ -45,533 +37,526 @@ interface Credential { updatedAt: string; } -interface CredentialInput { - name: string; - description?: string; - folder?: string; - tags: string[]; - authType: 'password' | 'key'; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; -} - interface CredentialEditorProps { - credential?: Credential | null; - onSave: () => void; - onCancel: () => void; + editingCredential?: Credential | null; + onFormSubmit?: () => void; } -const CredentialEditor: React.FC = ({ credential, onSave, onCancel }) => { +export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) { const { t } = useTranslation(); - const [formData, setFormData] = useState({ - name: '', - description: '', - folder: '', - tags: [], - authType: 'password', - username: '', - password: '', - key: '', - keyPassword: '', - keyType: 'rsa' - }); - const [saving, setSaving] = useState(false); - const [showPassword, setShowPassword] = useState(false); - const [showKeyPassword, setShowKeyPassword] = useState(false); - const [newTag, setNewTag] = useState(''); - const [existingFolders, setExistingFolders] = useState([]); - const [keyFile, setKeyFile] = useState(null); - const [errors, setErrors] = useState>({}); + const [credentials, setCredentials] = useState([]); + const [folders, setFolders] = useState([]); + const [loading, setLoading] = useState(true); + + const [authTab, setAuthTab] = useState<'password' | 'key'>('password'); useEffect(() => { - if (credential) { - setFormData({ - name: credential.name, - description: credential.description || '', - folder: credential.folder || '', - tags: [...credential.tags], - authType: credential.authType, - username: credential.username, - password: '', - key: '', - keyPassword: '', - keyType: credential.keyType || 'rsa' + 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; + + const form = useForm({ + 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", }); } - fetchExistingFolders(); - }, [credential]); + }, [editingCredential, form]); - const fetchExistingFolders = async () => { + const onSubmit = async (data: any) => { try { - const response = await getCredentialFolders(); - setExistingFolders(response); - } catch (error) { - console.error('Failed to fetch folders:', error); - } - }; + const formData = data as FormData; - const validateForm = (): boolean => { - const newErrors: Record = {}; - - if (!formData.name.trim()) { - newErrors.name = t('credentials.nameIsRequired'); - } - - if (!formData.username.trim()) { - newErrors.username = t('credentials.usernameIsRequired'); - } - - if (formData.authType === 'password' && !formData.password && !credential) { - newErrors.password = t('credentials.passwordIsRequired'); - } - - if (formData.authType === 'key' && !formData.key && !credential) { - newErrors.key = t('credentials.sshKeyIsRequired'); - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validateForm()) { - return; - } - - setSaving(true); - try { - const payload = { ...formData }; - - // Don't send empty passwords/keys when editing unless they were changed - if (credential) { - if (!payload.password) delete payload.password; - if (!payload.key) delete payload.key; - if (!payload.keyPassword) delete payload.keyPassword; + if (!formData.name || formData.name.trim() === '') { + formData.name = formData.username; } - if (credential && credential.id) { - await updateCredential(credential.id, payload); - toast.success(t('credentials.credentialUpdatedSuccessfully')); + if (editingCredential) { + await updateCredential(editingCredential.id, formData); + toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name })); } else { - await createCredential(payload); - toast.success(t('credentials.credentialCreatedSuccessfully')); + await createCredential(formData); + toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name })); } - onSave(); - } catch (error: any) { - console.error('Failed to save credential:', error); - toast.error(error.response?.data?.error || t('credentials.failedToSaveCredential')); - } finally { - setSaving(false); + if (onFormSubmit) { + onFormSubmit(); + } + + window.dispatchEvent(new CustomEvent('credentials:changed')); + } catch (error) { + toast.error(t('credentials.failedToSaveCredential')); } }; - const handleAddTag = () => { - if (newTag.trim() && !formData.tags.includes(newTag.trim())) { - setFormData(prev => ({ - ...prev, - tags: [...prev.tags, newTag.trim()] - })); - setNewTag(''); + 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); + } } - }; - const handleRemoveTag = (tagToRemove: string) => { - setFormData(prev => ({ - ...prev, - tags: prev.tags.filter(tag => tag !== tagToRemove) - })); - }; - - const handleKeyFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - setKeyFile(file); - const reader = new FileReader(); - reader.onload = (event) => { - const content = event.target?.result as string; - setFormData(prev => ({ ...prev, key: content })); - }; - reader.readAsText(file); + if (folderDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); } - }; - const generateSSHKeyPair = () => { - toast.info(t('credentials.sshKeyGenerationNotImplemented')); - }; + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [folderDropdownOpen]); - const testConnection = () => { - toast.info(t('credentials.connectionTestingNotImplemented')); - }; + 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(null); + const keyTypeDropdownRef = useRef(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 ( - - - - - - - {credential ? t('credentials.editCredential') : t('credentials.createCredential')} - - - - {credential - ? t('credentials.editCredentialDescription') - : t('credentials.createCredentialDescription') - } - - - - - - - {t('credentials.basicInfo')} - {t('credentials.authentication')} - {t('credentials.organization')} - - - - - - {t('credentials.basicInformation')} - - {t('credentials.basicInformationDescription')} - - - -
- - setFormData(prev => ({ ...prev, name: e.target.value }))} - placeholder={t('credentials.enterCredentialName')} - className={errors.name ? 'border-red-500' : ''} - /> - {errors.name && ( -

- - {errors.name} -

+
+ + + + + + {t('credentials.general')} + {t('credentials.authentication')} + + + {t('credentials.basicInformation')} +
+ ( + + {t('credentials.credentialName')} + + + + )} -
+ /> -
- -