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
This commit is contained in:
@@ -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": []
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "密钥类型",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(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 (
|
||||
<FormItem>
|
||||
<FormLabel>{t('hosts.selectCredential')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
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"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
{loading ? (
|
||||
t('common.loading')
|
||||
) : selectedCredential ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<span className="font-medium">{selectedCredential.name}</span>
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
({selectedCredential.username} • {selectedCredential.authType})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
t('hosts.selectCredentialPlaceholder')
|
||||
)}
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-80 overflow-hidden"
|
||||
>
|
||||
<div className="p-2 border-b border-input">
|
||||
<Input
|
||||
placeholder={t('credentials.searchCredentials')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
{loading ? (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : filteredCredentials.length === 0 ? (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||
{searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-2 text-red-400 hover:bg-red-500/20"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t('common.clear')}
|
||||
</Button>
|
||||
)}
|
||||
{filteredCredentials.map((credential) => (
|
||||
<Button
|
||||
key={credential.id}
|
||||
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' : ''
|
||||
}`}
|
||||
onClick={() => handleCredentialSelect(credential)}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{credential.name}</span>
|
||||
{credential.folder && (
|
||||
<span className="text-xs bg-muted px-1 rounded">
|
||||
{credential.folder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{credential.username} • {credential.authType}
|
||||
{credential.description && ` • ${credential.description}`}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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.
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => handleDeleteUser(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-900/20 dark:hover:bg-red-900/30"
|
||||
disabled={user.is_admin}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
</Button>
|
||||
@@ -434,7 +436,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => handleRemoveAdminStatus(admin.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-900/20 dark:hover:bg-orange-900/30">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Remove Admin
|
||||
</Button>
|
||||
|
||||
@@ -211,15 +211,15 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onCancel}>
|
||||
<SheetContent className="w-[800px] max-w-[90vw] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center space-x-2">
|
||||
<Key className="h-5 w-5 text-blue-600" />
|
||||
<span>
|
||||
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
|
||||
<SheetHeader className="space-y-4 pb-8">
|
||||
<SheetTitle className="flex items-center space-x-3">
|
||||
<Key className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
|
||||
<span className="text-xl font-semibold">
|
||||
{credential ? t('credentials.editCredential') : t('credentials.createCredential')}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
<SheetDescription className="text-base text-zinc-600 dark:text-zinc-400">
|
||||
{credential
|
||||
? t('credentials.editCredentialDescription')
|
||||
: t('credentials.createCredentialDescription')
|
||||
@@ -227,26 +227,26 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-10 px-2">
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-[#18181b] border-2 border-[#303032]">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
|
||||
<TabsTrigger value="basic">{t('credentials.basicInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="auth">{t('credentials.authentication')}</TabsTrigger>
|
||||
<TabsTrigger value="organization">{t('credentials.organization')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('credentials.basicInformation')}</CardTitle>
|
||||
<CardDescription>
|
||||
<TabsContent value="basic" className="space-y-8 mt-8">
|
||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||
<CardHeader className="pb-8">
|
||||
<CardTitle className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle>
|
||||
<CardDescription className="text-zinc-600 dark:text-zinc-400">
|
||||
{t('credentials.basicInformationDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="flex items-center space-x-1">
|
||||
<User className="h-4 w-4" />
|
||||
<CardContent className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="name" className="flex items-center space-x-2 text-sm font-medium">
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<span>{t('credentials.credentialName')}</span>
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
@@ -265,7 +265,7 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="description">{t('credentials.credentialDescription')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
@@ -276,7 +276,7 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="username" className="flex items-center space-x-1">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{t('common.username')}</span>
|
||||
@@ -300,56 +300,56 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="auth" className="space-y-4">
|
||||
<TabsContent value="auth" className="space-y-6 mt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-green-600" />
|
||||
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<span>{t('credentials.authenticationMethod')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('credentials.authenticationMethodDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<Label>{t('credentials.authenticationType')}</Label>
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex space-x-6">
|
||||
<div
|
||||
className={`flex-1 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
className={`flex-1 p-6 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
formData.authType === 'password'
|
||||
? 'border-blue-500 bg-blue-900/20 dark:bg-blue-900/20'
|
||||
: 'border-gray-600 hover:border-gray-500 dark:border-gray-600 dark:hover:border-gray-500'
|
||||
? 'border-zinc-500 bg-zinc-900/20 dark:bg-zinc-900/20'
|
||||
: 'border-zinc-600 hover:border-zinc-500 dark:border-zinc-600 dark:hover:border-zinc-500'
|
||||
}`}
|
||||
onClick={() => setFormData(prev => ({ ...prev, authType: 'password' }))}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Lock className="h-5 w-5 text-orange-500" />
|
||||
<div className="flex items-center space-x-4">
|
||||
<Lock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="font-medium">{t('common.password')}</div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.passwordAuthDescription')}</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.passwordAuthDescription')}</div>
|
||||
</div>
|
||||
{formData.authType === 'password' && (
|
||||
<Check className="h-5 w-5 text-blue-500" />
|
||||
<Check className="h-5 w-5 text-zinc-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
className={`flex-1 p-6 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
formData.authType === 'key'
|
||||
? 'border-green-500 bg-green-900/20 dark:bg-green-900/20'
|
||||
: 'border-gray-600 hover:border-gray-500 dark:border-gray-600 dark:hover:border-gray-500'
|
||||
? 'border-zinc-500 bg-zinc-900/20 dark:bg-zinc-900/20'
|
||||
: 'border-zinc-600 hover:border-zinc-500 dark:border-zinc-600 dark:hover:border-zinc-500'
|
||||
}`}
|
||||
onClick={() => setFormData(prev => ({ ...prev, authType: 'key' }))}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Key className="h-5 w-5 text-green-500" />
|
||||
<div className="flex items-center space-x-4">
|
||||
<Key className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="font-medium">{t('credentials.sshKey')}</div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.sshKeyAuthDescription')}</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.sshKeyAuthDescription')}</div>
|
||||
</div>
|
||||
{formData.authType === 'key' && (
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<Check className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,7 +359,7 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
<Separator />
|
||||
|
||||
{formData.authType === 'password' && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="password" className="flex items-center space-x-1">
|
||||
<Lock className="h-4 w-4" />
|
||||
<span>{t('common.password')}</span>
|
||||
@@ -394,8 +394,8 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
)}
|
||||
|
||||
{formData.authType === 'key' && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center space-x-1">
|
||||
<Key className="h-4 w-4" />
|
||||
<span>{t('credentials.sshKeyType')}</span>
|
||||
@@ -412,7 +412,7 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="key" className="flex items-center space-x-1">
|
||||
<Key className="h-4 w-4" />
|
||||
<span>{t('credentials.privateKey')}</span>
|
||||
@@ -432,7 +432,7 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
<span>{errors.key}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex space-x-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => document.getElementById('key-file')?.click()}>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
{t('credentials.uploadKeyFile')}
|
||||
@@ -451,7 +451,7 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="keyPassword">{t('credentials.keyPassphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -472,7 +472,7 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
{showKeyPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{t('credentials.keyPassphraseOptional')}</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">{t('credentials.keyPassphraseOptional')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -480,29 +480,29 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="organization" className="space-y-4">
|
||||
<TabsContent value="organization" className="space-y-6 mt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Folder className="h-5 w-5 text-amber-500" />
|
||||
<Folder className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||
<span>{t('credentials.organization')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('credentials.organizationDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="folder" className="flex items-center space-x-1">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{t('common.folder')}</span>
|
||||
</Label>
|
||||
<Select value={formData.folder} onValueChange={(value) => setFormData(prev => ({ ...prev, folder: value }))}>
|
||||
<Select value={formData.folder || "__none__"} onValueChange={(value) => setFormData(prev => ({ ...prev, folder: value === "__none__" ? "" : value }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('credentials.selectOrCreateFolder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('credentials.noFolder')}</SelectItem>
|
||||
<SelectItem value="__none__">{t('credentials.noFolder')}</SelectItem>
|
||||
{existingFolders.map(folder => (
|
||||
<SelectItem key={folder} value={folder}>{folder}</SelectItem>
|
||||
))}
|
||||
@@ -515,12 +515,12 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<Label className="flex items-center space-x-1">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span>{t('hosts.tags')}</span>
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
{formData.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="pr-1">
|
||||
{tag}
|
||||
@@ -536,7 +536,7 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex space-x-3">
|
||||
<Input
|
||||
placeholder={t('credentials.addTag')}
|
||||
value={newTag}
|
||||
@@ -558,17 +558,12 @@ const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave,
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SheetFooter className="flex justify-between">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="outline" onClick={testConnection}>
|
||||
{t('credentials.testConnection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
<SheetFooter className="flex justify-end items-center pt-8 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex space-x-4">
|
||||
<Button type="button" variant="outline" size="lg" onClick={onCancel} className="border-zinc-300 dark:border-zinc-600">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Button type="submit" size="lg" disabled={saving}>
|
||||
{saving ? t('credentials.saving') : credential ? t('credentials.updateCredential') : t('credentials.createCredential')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -120,9 +120,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
|
||||
const getAuthIcon = (authType: string) => {
|
||||
return authType === 'password' ? (
|
||||
<Key className="h-5 w-5 text-orange-500" />
|
||||
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-green-500" />
|
||||
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -139,7 +139,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -159,13 +159,13 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-md bg-gray-800 dark:bg-gray-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
|
||||
<div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
|
||||
{isVisible ? (
|
||||
<pre className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}>
|
||||
{value}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{'•'.repeat(isMultiline ? 50 : 20)}
|
||||
</div>
|
||||
)}
|
||||
@@ -177,9 +177,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
if (loading || !credentialDetails) {
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[800px] max-w-[90vw]">
|
||||
<SheetContent className="w-[600px] max-w-[50vw]">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -188,35 +188,39 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[800px] max-w-[90vw] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center space-x-3">
|
||||
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
|
||||
<SheetHeader className="space-y-6 pb-8">
|
||||
<SheetTitle className="flex items-center space-x-4">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
{getAuthIcon(credentialDetails.authType)}
|
||||
<div>
|
||||
<div>{credentialDetails.name}</div>
|
||||
<div className="text-sm font-normal text-gray-600 dark:text-gray-400">
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xl font-semibold">{credentialDetails.name}</div>
|
||||
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
{credentialDetails.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 ml-auto">
|
||||
<Badge variant={credentialDetails.authType === 'password' ? 'secondary' : 'outline'}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||
{credentialDetails.authType}
|
||||
</Badge>
|
||||
{credentialDetails.keyType && (
|
||||
<Badge variant="outline">{credentialDetails.keyType}</Badge>
|
||||
<Badge variant="secondary" className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||
{credentialDetails.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-10">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex space-x-1 p-1 bg-[#18181b] border-2 border-[#303032] rounded-lg">
|
||||
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||
<Button
|
||||
variant={activeTab === 'overview' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className="flex-1"
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{t('credentials.overview')}
|
||||
@@ -225,7 +229,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
variant={activeTab === 'security' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('security')}
|
||||
className="flex-1"
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
{t('credentials.security')}
|
||||
@@ -234,7 +238,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
variant={activeTab === 'usage' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('usage')}
|
||||
className="flex-1"
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{t('credentials.usage')}
|
||||
@@ -243,36 +247,38 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('credentials.basicInformation')}</CardTitle>
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||
<CardHeader className="pb-8">
|
||||
<CardTitle className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<User className="h-4 w-4 text-gray-500" />
|
||||
<CardContent className="space-y-8">
|
||||
<div className="flex items-center space-x-5">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('common.username')}</div>
|
||||
<div className="font-medium">{credentialDetails.username}</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.folder && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Folder className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center space-x-4">
|
||||
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('common.folder')}</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.folder')}</div>
|
||||
<div className="font-medium">{credentialDetails.folder}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.tags.length > 0 && (
|
||||
<div className="flex items-start space-x-3">
|
||||
<Hash className="h-4 w-4 text-gray-500 mt-1" />
|
||||
<div className="flex items-start space-x-4">
|
||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-gray-500 mb-2">{t('hosts.tags')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">{t('hosts.tags')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{credentialDetails.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
@@ -285,18 +291,18 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.created')}</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.created')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.lastModified')}</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastModified')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,30 +313,30 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('credentials.usageStatistics')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center p-4 bg-blue-900/20 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
<CardContent className="space-y-6">
|
||||
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
|
||||
{credentialDetails.usageCount}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{t('credentials.timesUsed')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.lastUsed && (
|
||||
<div className="flex items-center space-x-3 p-3 bg-green-900/20 dark:bg-green-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.lastUsed')}</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.lastUsed)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-3 p-3 bg-purple-900/20 dark:bg-purple-900/20 rounded-lg">
|
||||
<Server className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.connectedHosts')}</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div>
|
||||
<div className="font-medium">{hostsUsing.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,7 +349,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-green-600" />
|
||||
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<span>{t('credentials.securityDetails')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -351,13 +357,13 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center space-x-3 p-4 bg-green-900/20 dark:bg-green-900/20 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-green-600" />
|
||||
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="font-medium text-green-800 dark:text-green-200">
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{t('credentials.credentialSecured')}
|
||||
</div>
|
||||
<div className="text-sm text-green-700 dark:text-green-300">
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{t('credentials.credentialSecuredDescription')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -365,18 +371,18 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
|
||||
{credentialDetails.authType === 'password' && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">{t('credentials.passwordAuthentication')}</h3>
|
||||
<h3 className="font-semibold mb-4">{t('credentials.passwordAuthentication')}</h3>
|
||||
{renderSensitiveField(credentialDetails.password, 'password', t('common.password'))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.authType === 'key' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">{t('credentials.keyAuthentication')}</h3>
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
{t('credentials.keyType')}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
@@ -395,13 +401,13 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 bg-amber-900/20 dark:bg-amber-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 mt-0.5" />
|
||||
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-amber-800 dark:text-amber-200 mb-1">
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
|
||||
{t('credentials.securityReminder')}
|
||||
</div>
|
||||
<div className="text-amber-700 dark:text-amber-300">
|
||||
<div className="text-zinc-700 dark:text-zinc-300">
|
||||
{t('credentials.securityReminderText')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,15 +420,15 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Server className="h-5 w-5 text-blue-600" />
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<span>{t('credentials.hostsUsingCredential')}</span>
|
||||
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hostsUsing.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Server className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
||||
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
|
||||
<p>{t('credentials.noHostsUsingCredential')}</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -431,22 +437,22 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
{hostsUsing.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-800 dark:hover:bg-gray-700"
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded">
|
||||
<Server className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
|
||||
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{host.name || `${host.ip}:${host.port}`}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{host.ip}:{host.port}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500">
|
||||
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{formatDate(host.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -495,7 +495,6 @@ const CredentialsManager: React.FC = () => {
|
||||
<Key className="h-16 w-16 mx-auto text-zinc-300 dark:text-zinc-600" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium">{t('credentials.noCredentialsYet')}</p>
|
||||
<p className="text-sm text-zinc-400">开始创建你的第一个SSH凭据</p>
|
||||
</div>
|
||||
<Button size="lg" onClick={handleCreateCredential}>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import {HostManagerHostViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerH
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {HostManagerHostEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx";
|
||||
import CredentialsManager from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
@@ -81,6 +82,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? t('hosts.editHost') : t('hosts.addHost')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="credentials">{t('credentials.credentialsManager')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
@@ -95,6 +97,12 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0 overflow-auto">
|
||||
<CredentialsManager />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import {toast} from "sonner";
|
||||
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {CredentialSelector} from "@/components/CredentialSelector.tsx";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -44,6 +45,7 @@ interface SSHHost {
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
credentialId?: number;
|
||||
}
|
||||
|
||||
interface SSHManagerHostEditorProps {
|
||||
@@ -58,7 +60,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
|
||||
const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -98,7 +100,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
folder: z.string().optional(),
|
||||
tags: z.array(z.string().min(1)).default([]),
|
||||
pin: z.boolean().default(false),
|
||||
authType: z.enum(['password', 'key']),
|
||||
authType: z.enum(['password', 'key', 'credential']),
|
||||
credentialId: z.number().optional().nullable(),
|
||||
password: z.string().optional(),
|
||||
key: z.instanceof(File).optional().nullable(),
|
||||
keyPassword: z.string().optional(),
|
||||
@@ -149,6 +152,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
path: ['keyType']
|
||||
});
|
||||
}
|
||||
} else if (data.authType === 'credential') {
|
||||
if (!data.credentialId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('hosts.credentialRequired'),
|
||||
path: ['credentialId']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
data.tunnelConnections.forEach((connection, index) => {
|
||||
@@ -174,7 +185,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
folder: editingHost?.folder || "",
|
||||
tags: editingHost?.tags || [],
|
||||
pin: editingHost?.pin || false,
|
||||
authType: (editingHost?.authType as 'password' | 'key') || "password",
|
||||
authType: (editingHost?.authType as 'password' | 'key' | 'credential') || "password",
|
||||
credentialId: editingHost?.credentialId || null,
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
@@ -189,7 +201,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
|
||||
useEffect(() => {
|
||||
if (editingHost) {
|
||||
const defaultAuthType = editingHost.key ? 'key' : 'password';
|
||||
const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password');
|
||||
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
@@ -201,7 +213,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
folder: editingHost.folder || "",
|
||||
tags: editingHost.tags || [],
|
||||
pin: editingHost.pin || false,
|
||||
authType: defaultAuthType,
|
||||
authType: defaultAuthType as 'password' | 'key' | 'credential',
|
||||
credentialId: editingHost.credentialId || null,
|
||||
password: editingHost.password || "",
|
||||
key: editingHost.key ? new File([editingHost.key], "key.pem") : null,
|
||||
keyPassword: editingHost.keyPassword || "",
|
||||
@@ -224,6 +237,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
tags: [],
|
||||
pin: false,
|
||||
authType: "password",
|
||||
credentialId: null,
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
@@ -574,14 +588,28 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) => {
|
||||
setAuthTab(value as 'password' | 'key');
|
||||
form.setValue('authType', value as 'password' | 'key');
|
||||
setAuthTab(value as 'password' | 'key' | 'credential');
|
||||
form.setValue('authType', value as 'password' | 'key' | 'credential');
|
||||
// Clear other auth fields when switching
|
||||
if (value === 'password') {
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
form.setValue('credentialId', null);
|
||||
} else if (value === 'key') {
|
||||
form.setValue('password', '');
|
||||
form.setValue('credentialId', null);
|
||||
} else if (value === 'credential') {
|
||||
form.setValue('password', '');
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="password">{t('hosts.password')}</TabsTrigger>
|
||||
<TabsTrigger value="key">{t('hosts.key')}</TabsTrigger>
|
||||
<TabsTrigger value="credential">{t('hosts.credential')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="password">
|
||||
<FormField
|
||||
@@ -696,6 +724,18 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="credential">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="credentialId"
|
||||
render={({ field }) => (
|
||||
<CredentialSelector
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
<TabsContent value="terminal">
|
||||
|
||||
@@ -12,11 +12,12 @@ interface SSHHostData {
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
pin?: boolean;
|
||||
authType: 'password' | 'key';
|
||||
authType: 'password' | 'key' | 'credential';
|
||||
password?: string;
|
||||
key?: File | null;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number | null;
|
||||
enableTerminal?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
@@ -38,6 +39,7 @@ interface SSHHost {
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
@@ -342,6 +344,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
key: hostData.authType === 'key' ? hostData.key : null,
|
||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||
credentialId: hostData.authType === 'credential' ? hostData.credentialId : null,
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
@@ -393,6 +396,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
||||
key: hostData.authType === 'key' ? hostData.key : null,
|
||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||
credentialId: hostData.authType === 'credential' ? hostData.credentialId : null,
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
@@ -817,8 +821,9 @@ export async function getOIDCConfig(): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get('/users/oidc-config');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch OIDC config');
|
||||
} catch (error: any) {
|
||||
console.warn('Failed to fetch OIDC config:', error.response?.data?.error || error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user