Comprehensive credentials management and SSH host system fixes
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,25 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Key,
|
||||
User,
|
||||
Calendar,
|
||||
Hash,
|
||||
Folder,
|
||||
Edit3,
|
||||
Edit,
|
||||
Trash2,
|
||||
Copy,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Shield,
|
||||
Clock,
|
||||
Server
|
||||
Pin,
|
||||
Tag,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { getCredentials, getCredentialFolders, deleteCredential } from '@/ui/main-axios';
|
||||
import { getCredentials, deleteCredential } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CredentialEditor from './CredentialEditor';
|
||||
import {CredentialEditor} from './CredentialEditor';
|
||||
import CredentialViewer from './CredentialViewer';
|
||||
|
||||
interface Credential {
|
||||
@@ -45,137 +37,95 @@ interface Credential {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface GroupedCredentials {
|
||||
[folder: string]: Credential[];
|
||||
interface CredentialsManagerProps {
|
||||
onEditCredential?: (credential: Credential) => void;
|
||||
}
|
||||
|
||||
const CredentialsManager: React.FC = () => {
|
||||
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [filteredCredentials, setFilteredCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedFolder, setSelectedFolder] = useState<string>('all');
|
||||
const [selectedAuthType, setSelectedAuthType] = useState<string>('all');
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [showViewer, setShowViewer] = useState(false);
|
||||
const [editingCredential, setEditingCredential] = useState<Credential | null>(null);
|
||||
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [viewMode, setViewMode] = useState<'list' | 'folder'>('list');
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterCredentials();
|
||||
}, [credentials, searchQuery, selectedFolder, selectedAuthType]);
|
||||
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
const response = await getCredentials();
|
||||
setCredentials(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credentials:', error);
|
||||
toast.error(t('credentials.failedToFetchCredentials'));
|
||||
setLoading(true);
|
||||
const data = await getCredentials();
|
||||
setCredentials(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(t('credentials.failedToFetchCredentials'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterCredentials = () => {
|
||||
let filtered = credentials;
|
||||
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(cred =>
|
||||
cred.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cred.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cred.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cred.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleEdit = (credential: Credential) => {
|
||||
if (onEditCredential) {
|
||||
onEditCredential(credential);
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedFolder !== 'all') {
|
||||
if (selectedFolder === 'none') {
|
||||
filtered = filtered.filter(cred => !cred.folder);
|
||||
} else {
|
||||
filtered = filtered.filter(cred => cred.folder === selectedFolder);
|
||||
|
||||
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||
if (window.confirm(t('credentials.confirmDeleteCredential', { name: credentialName }))) {
|
||||
try {
|
||||
await deleteCredential(credentialId);
|
||||
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('credentials.failedToDeleteCredential'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedAuthType !== 'all') {
|
||||
filtered = filtered.filter(cred => cred.authType === selectedAuthType);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const filteredAndSortedCredentials = useMemo(() => {
|
||||
let filtered = credentials;
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = credentials.filter(credential => {
|
||||
const searchableText = [
|
||||
credential.name || '',
|
||||
credential.username,
|
||||
credential.description || '',
|
||||
...(credential.tags || []),
|
||||
credential.authType,
|
||||
credential.keyType || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
setFilteredCredentials(filtered);
|
||||
};
|
||||
return filtered.sort((a, b) => {
|
||||
const aName = a.name || a.username;
|
||||
const bName = b.name || b.username;
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}, [credentials, searchQuery]);
|
||||
|
||||
const handleCreateCredential = () => {
|
||||
setEditingCredential(null);
|
||||
setShowEditor(true);
|
||||
};
|
||||
const credentialsByFolder = useMemo(() => {
|
||||
const grouped: { [key: string]: Credential[] } = {};
|
||||
|
||||
const handleEditCredential = (credential: Credential) => {
|
||||
setEditingCredential(credential);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleViewCredential = (credential: Credential) => {
|
||||
setViewingCredential(credential);
|
||||
setShowViewer(true);
|
||||
};
|
||||
|
||||
const handleDeleteCredential = async (credential: Credential) => {
|
||||
if (!confirm(t('credentials.confirmDeleteCredential', { name: credential.name }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCredential(credential.id);
|
||||
|
||||
toast.success(t('credentials.credentialDeletedSuccessfully'));
|
||||
fetchCredentials();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete credential:', error);
|
||||
toast.error(error.response?.data?.error || t('credentials.failedToDeleteCredential'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateCredential = (credential: Credential) => {
|
||||
const duplicated: Credential = {
|
||||
...credential,
|
||||
id: 0, // Will be assigned by server
|
||||
name: `${credential.name} (Copy)`,
|
||||
usageCount: 0,
|
||||
lastUsed: undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setEditingCredential(duplicated);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleCredentialSaved = () => {
|
||||
setShowEditor(false);
|
||||
setEditingCredential(null);
|
||||
fetchCredentials();
|
||||
};
|
||||
|
||||
const toggleFolder = (folder: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (newExpanded.has(folder)) {
|
||||
newExpanded.delete(folder);
|
||||
} else {
|
||||
newExpanded.add(folder);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
const groupCredentialsByFolder = (credentials: Credential[]): GroupedCredentials => {
|
||||
const grouped: GroupedCredentials = {};
|
||||
|
||||
credentials.forEach(credential => {
|
||||
filteredAndSortedCredentials.forEach(credential => {
|
||||
const folder = credential.folder || t('credentials.uncategorized');
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
@@ -183,342 +133,193 @@ const CredentialsManager: React.FC = () => {
|
||||
grouped[folder].push(credential);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
};
|
||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === t('credentials.uncategorized')) return -1;
|
||||
if (b === t('credentials.uncategorized')) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const getUniqueValues = (field: keyof Credential): string[] => {
|
||||
const values = credentials
|
||||
.map(cred => cred[field])
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
return Array.from(new Set(values));
|
||||
};
|
||||
const sortedGrouped: { [key: string]: Credential[] } = {};
|
||||
sortedFolders.forEach(folder => {
|
||||
sortedGrouped[folder] = grouped[folder];
|
||||
});
|
||||
|
||||
const renderCredentialCard = (credential: Credential) => (
|
||||
<Card key={credential.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors border-zinc-200 dark:border-zinc-700">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
{credential.authType === 'password' ? (
|
||||
<Key className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">{credential.name}</CardTitle>
|
||||
{credential.description && (
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{credential.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewCredential(credential)}
|
||||
title={t('credentials.viewCredential')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditCredential(credential)}
|
||||
title={t('credentials.editCredential')}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDuplicateCredential(credential)}
|
||||
title={t('credentials.duplicateCredential')}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteCredential(credential)}
|
||||
title={t('credentials.deleteCredential')}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center space-x-3">
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<span className="text-zinc-700 dark:text-zinc-300 font-medium">{credential.username}</span>
|
||||
<Badge variant="outline" className="text-xs border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||
{credential.authType}
|
||||
</Badge>
|
||||
{credential.keyType && (
|
||||
<Badge variant="secondary" className="text-xs bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||
{credential.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{credential.tags.length > 0 && (
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-1">
|
||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||
{credential.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center space-x-4 text-zinc-500 dark:text-zinc-400">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{credential.usageCount}</span>
|
||||
</div>
|
||||
{credential.lastUsed && (
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{new Date(credential.lastUsed).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 text-zinc-500 dark:text-zinc-400">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{new Date(credential.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderListView = () => (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredCredentials.map(renderCredentialCard)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFolderView = () => {
|
||||
const grouped = groupCredentialsByFolder(filteredCredentials);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([folder, folderCredentials]) => (
|
||||
<div key={folder} className="space-y-2">
|
||||
<div
|
||||
className="flex items-center space-x-3 cursor-pointer p-3 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
onClick={() => toggleFolder(folder)}
|
||||
>
|
||||
{expandedFolders.has(folder) ? (
|
||||
<ChevronDown className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
<span className="font-medium text-zinc-800 dark:text-zinc-200">{folder === t('credentials.uncategorized') ? t('credentials.uncategorized') : folder}</span>
|
||||
<Badge variant="secondary" className="text-xs bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||
{folderCredentials.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{expandedFolders.has(folder) && (
|
||||
<div className="ml-8 grid gap-6 md:grid-cols-2 xl:grid-cols-3 pt-2">
|
||||
{folderCredentials.map(renderCredentialCard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return sortedGrouped;
|
||||
}, [filteredAndSortedCredentials, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">{t('credentials.loadingCredentials')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={fetchCredentials} variant="outline">
|
||||
{t('credentials.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Key className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('credentials.noCredentials')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('credentials.noCredentialsMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-8 pb-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">{t('credentials.credentialsManager')}</h1>
|
||||
<p className="text-zinc-600 dark:text-zinc-400 text-lg">
|
||||
{t('credentials.manageYourSSHCredentials')}
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('credentials.credentialsCount', { count: filteredAndSortedCredentials.length })}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateCredential} size="lg">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
{t('credentials.addCredential')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="px-8 pb-6">
|
||||
<Card>
|
||||
<CardContent className="pt-8">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('credentials.searchCredentials')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={selectedFolder} onValueChange={setSelectedFolder}>
|
||||
<SelectTrigger className="w-full md:w-48">
|
||||
<SelectValue placeholder={t('credentials.selectFolder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('credentials.allFolders')}</SelectItem>
|
||||
<SelectItem value="none">{t('credentials.uncategorized')}</SelectItem>
|
||||
{getUniqueValues('folder').map(folder => (
|
||||
<SelectItem key={folder} value={folder}>{folder}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedAuthType} onValueChange={setSelectedAuthType}>
|
||||
<SelectTrigger className="w-full md:w-48">
|
||||
<SelectValue placeholder={t('credentials.selectAuthType')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('credentials.allAuthTypes')}</SelectItem>
|
||||
<SelectItem value="password">{t('common.password')}</SelectItem>
|
||||
<SelectItem value="key">{t('credentials.sshKey')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex border rounded-md">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
{t('credentials.listView')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'folder' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('folder')}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{t('credentials.folderView')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-4">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="px-8 pb-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">{credentials.length}</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.totalCredentials')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">
|
||||
{credentials.filter(c => c.authType === 'key').length}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.keyBased')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">
|
||||
{credentials.filter(c => c.authType === 'password').length}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.passwordBased')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">
|
||||
{getUniqueValues('folder').length}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.folders')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={fetchCredentials} variant="outline" size="sm">
|
||||
{t('credentials.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials List */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden px-8 pb-8">
|
||||
<Card className="flex-1 flex flex-col min-h-0">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl">
|
||||
{t('nav.credentials')} ({filteredCredentials.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 px-6">
|
||||
<ScrollArea className="flex-1">
|
||||
{filteredCredentials.length === 0 ? (
|
||||
<div className="text-center py-16 text-zinc-500 dark:text-zinc-400">
|
||||
{searchQuery || selectedFolder !== 'all' || selectedAuthType !== 'all' ? (
|
||||
<div className="space-y-4">
|
||||
<Search className="h-16 w-16 mx-auto text-zinc-300 dark:text-zinc-600" />
|
||||
<p className="text-lg">{t('credentials.noCredentialsMatchFilters')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
</div>
|
||||
<Button size="lg" onClick={handleCreateCredential}>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
{t('credentials.createFirstCredential')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
viewMode === 'list' ? renderListView() : renderFolderView()
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder={t('placeholders.searchCredentials')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showEditor && (
|
||||
<CredentialEditor
|
||||
credential={editingCredential}
|
||||
onSave={handleCredentialSaved}
|
||||
onCancel={() => setShowEditor(false)}
|
||||
/>
|
||||
)}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => (
|
||||
<div key={folder} className="border rounded-md">
|
||||
<Accordion type="multiple" defaultValue={Object.keys(credentialsByFolder)}>
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger
|
||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4"/>
|
||||
<span className="font-medium">{folder}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderCredentials.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderCredentials.map((credential) => (
|
||||
<div
|
||||
key={credential.id}
|
||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||
onClick={() => handleEdit(credential)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{credential.name || `${credential.username}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(credential);
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(credential.id, credential.name || credential.username);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{credential.tags && credential.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{credential.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{credential.tags.length > 6 && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
+{credential.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{credential.authType === 'password' ? (
|
||||
<Key className="h-2 w-2 mr-0.5"/>
|
||||
) : (
|
||||
<Shield className="h-2 w-2 mr-0.5"/>
|
||||
)}
|
||||
{credential.authType}
|
||||
</Badge>
|
||||
{credential.authType === 'key' && credential.keyType && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{credential.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{showViewer && viewingCredential && (
|
||||
<CredentialViewer
|
||||
@@ -526,12 +327,10 @@ const CredentialsManager: React.FC = () => {
|
||||
onClose={() => setShowViewer(false)}
|
||||
onEdit={() => {
|
||||
setShowViewer(false);
|
||||
handleEditCredential(viewingCredential);
|
||||
handleEdit(viewingCredential);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialsManager;
|
||||
}
|
||||
Reference in New Issue
Block a user