From 199a9f6e5252be2b638fe390ee75515aa849ca96 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 6 Sep 2025 17:44:41 +0800 Subject: [PATCH] Integrate credentials manager with host authentication system - Add credential selection tab to Host Manager editor - Create CredentialSelector component for credential selection - Update form validation to support credential-based authentication - Extend SSH host interfaces with credentialId field - Add internationalization support for credential authentication - Fix OIDC config endpoint to return 200 with null data instead of 404 - Improve credentials UI spacing and color consistency - Remove hardcoded colors and use Zinc theme throughout --- .claude/settings.local.json | 7 +- electron/main-simple.cjs | 3 +- public/locales/en/translation.json | 5 + public/locales/zh/translation.json | 5 + src/backend/database/routes/users.ts | 2 +- src/components/CredentialSelector.tsx | 200 ++++++++++++++++++ src/components/ui/sheet.tsx | 4 +- src/ui/Desktop/Admin/AdminSettings.tsx | 8 +- .../Apps/Credentials/CredentialEditor.tsx | 121 +++++------ .../Apps/Credentials/CredentialViewer.tsx | 158 +++++++------- .../Apps/Credentials/CredentialsManager.tsx | 1 - .../Desktop/Apps/Host Manager/HostManager.tsx | 8 + .../Host Manager/HostManagerHostEditor.tsx | 54 ++++- src/ui/main-axios.ts | 11 +- 14 files changed, 429 insertions(+), 158 deletions(-) create mode 100644 src/components/CredentialSelector.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0d6bb3a0..5c9cb19c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,12 @@ "Bash(npm run build:*)", "Bash(npm install)", "Bash(npm run electron:build:*)", - "Bash(npm uninstall:*)" + "Bash(npm uninstall:*)", + "Bash(git remote set-url:*)", + "Bash(npm run dev:backend:*)", + "Bash(taskkill:*)", + "Bash(node:*)", + "WebFetch(domain:ui.shadcn.com)" ], "deny": [], "ask": [] diff --git a/electron/main-simple.cjs b/electron/main-simple.cjs index 314b066d..063367cf 100644 --- a/electron/main-simple.cjs +++ b/electron/main-simple.cjs @@ -21,7 +21,8 @@ function startBackendServer() { backendProcess = spawn('node', [backendPath], { stdio: ['ignore', 'pipe', 'pipe'], - detached: false + detached: false, + cwd: path.join(__dirname, '..') // Set working directory to app root }); backendProcess.stdout.on('data', (data) => { diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index cf347b2f..9418bd75 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -131,6 +131,7 @@ "loading": "Loading", "required": "Required", "optional": "Optional", + "clear": "Clear", "toggleSidebar": "Toggle Sidebar", "sidebar": "Sidebar", "home": "Home", @@ -376,6 +377,10 @@ "authentication": "Authentication", "password": "Password", "key": "Key", + "credential": "Credential", + "selectCredential": "Select Credential", + "selectCredentialPlaceholder": "Choose a credential...", + "credentialRequired": "Credential is required when using credential authentication", "sshPrivateKey": "SSH Private Key", "keyPassword": "Key Password", "keyType": "Key Type", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 1a4052fd..ec5e0eea 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -131,6 +131,7 @@ "loading": "加载中", "required": "必填", "optional": "可选", + "clear": "清除", "toggleSidebar": "切换侧边栏", "sidebar": "侧边栏", "home": "首页", @@ -397,6 +398,10 @@ "authentication": "认证方式", "password": "密码", "key": "密钥", + "credential": "凭证", + "selectCredential": "选择凭证", + "selectCredentialPlaceholder": "选择一个凭证...", + "credentialRequired": "使用凭证认证时需要选择凭证", "sshPrivateKey": "SSH 私钥", "keyPassword": "密钥密码", "keyType": "密钥类型", diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 0ac5f157..2c2e9100 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -274,7 +274,7 @@ router.get('/oidc-config', async (req, res) => { try { const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get(); if (!row) { - return res.status(404).json({error: 'OIDC not configured'}); + return res.json(null); } res.json(JSON.parse((row as any).value)); } catch (err) { diff --git a/src/components/CredentialSelector.tsx b/src/components/CredentialSelector.tsx new file mode 100644 index 00000000..7eab693a --- /dev/null +++ b/src/components/CredentialSelector.tsx @@ -0,0 +1,200 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { FormControl, FormItem, FormLabel } from "@/components/ui/form"; +import { getCredentials } from '@/ui/main-axios'; +import { useTranslation } from "react-i18next"; + +interface Credential { + id: number; + name: string; + description?: string; + username: string; + authType: 'password' | 'key'; + folder?: string; +} + +interface CredentialSelectorProps { + value?: number | null; + onValueChange: (credentialId: number | null) => void; +} + +export function CredentialSelector({ value, onValueChange }: CredentialSelectorProps) { + const { t } = useTranslation(); + const [credentials, setCredentials] = useState([]); + const [loading, setLoading] = useState(true); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + useEffect(() => { + const fetchCredentials = async () => { + try { + setLoading(true); + const data = await getCredentials(); + setCredentials(data.credentials || []); + } catch (error) { + console.error('Failed to fetch credentials:', error); + setCredentials([]); + } finally { + setLoading(false); + } + }; + + fetchCredentials(); + }, []); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setDropdownOpen(false); + } + } + + if (dropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [dropdownOpen]); + + const selectedCredential = credentials.find(c => c.id === value); + + const filteredCredentials = credentials.filter(credential => { + if (!searchQuery) return true; + const searchLower = searchQuery.toLowerCase(); + return ( + credential.name.toLowerCase().includes(searchLower) || + credential.username.toLowerCase().includes(searchLower) || + (credential.folder && credential.folder.toLowerCase().includes(searchLower)) + ); + }); + + const handleCredentialSelect = (credential: Credential) => { + onValueChange(credential.id); + setDropdownOpen(false); + setSearchQuery(''); + }; + + const handleClear = () => { + onValueChange(null); + setDropdownOpen(false); + setSearchQuery(''); + }; + + return ( + + {t('hosts.selectCredential')} + +
+ + + {dropdownOpen && ( +
+
+ setSearchQuery(e.target.value)} + className="h-8" + /> +
+ +
+ {loading ? ( +
+ {t('common.loading')} +
+ ) : filteredCredentials.length === 0 ? ( +
+ {searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')} +
+ ) : ( +
+ {value && ( + + )} + {filteredCredentials.map((credential) => ( + + ))} +
+ )} +
+ +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 1a6ea1e2..b031fbed 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -58,9 +58,9 @@ function SheetContent({ className={cn( "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none", side === "right" && - "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", + "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l", side === "left" && - "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", + "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r", side === "top" && "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", side === "bottom" && diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index d3e0fa95..29208d09 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -79,7 +79,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. .then(res => { if (res) setOidcConfig(res); }) - .catch(() => { + .catch(error => { + // Silently ignore OIDC config fetch errors - this is expected when OIDC is not configured + console.debug('OIDC config not available:', error.message); }); fetchUsers(); }, []); @@ -370,7 +372,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. @@ -434,7 +436,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. diff --git a/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx index a8db154e..74436ecd 100644 --- a/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx +++ b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx @@ -211,15 +211,15 @@ const CredentialEditor: React.FC = ({ credential, onSave, return ( - - - - - + + + + + {credential ? t('credentials.editCredential') : t('credentials.createCredential')} - + {credential ? t('credentials.editCredentialDescription') : t('credentials.createCredentialDescription') @@ -227,26 +227,26 @@ const CredentialEditor: React.FC = ({ credential, onSave, -
+ - + {t('credentials.basicInfo')} {t('credentials.authentication')} {t('credentials.organization')} - - - - {t('credentials.basicInformation')} - + + + + {t('credentials.basicInformation')} + {t('credentials.basicInformationDescription')} - -
-