Merge Luke and Zac

This commit is contained in:
Karmaa
2025-09-07 21:23:48 -05:00
committed by LukeGus
parent 60928ae191
commit 5f6792dc0d
38 changed files with 6648 additions and 3100 deletions

View File

@@ -0,0 +1,562 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import React, { useEffect, useRef, useState } from "react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { toast } from "sonner"
import { createCredential, updateCredential, getCredentials } from '@/ui/main-axios'
import { useTranslation } from "react-i18next"
interface Credential {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
interface CredentialEditorProps {
editingCredential?: Credential | null;
onFormSubmit?: () => void;
}
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const credentialsData = await getCredentials();
setCredentials(credentialsData);
const uniqueFolders = [...new Set(
credentialsData
.filter(credential => credential.folder && credential.folder.trim() !== '')
.map(credential => credential.folder)
)].sort();
setFolders(uniqueFolders);
} catch (error) {
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const formSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
folder: z.string().optional(),
tags: z.array(z.string().min(1)).default([]),
authType: z.enum(['password', 'key']),
username: z.string().min(1),
password: z.string().optional(),
key: z.instanceof(File).optional().nullable(),
keyPassword: z.string().optional(),
keyType: z.enum([
'rsa',
'ecdsa',
'ed25519'
]).optional(),
}).superRefine((data, ctx) => {
if (data.authType === 'password') {
if (!data.password || data.password.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('credentials.passwordRequired'),
path: ['password']
});
}
} else if (data.authType === 'key') {
if (!data.key && !editingCredential) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('credentials.sshKeyRequired'),
path: ['key']
});
}
}
});
type FormData = z.infer<typeof formSchema>;
const form = useForm<FormData>({
resolver: zodResolver(formSchema) as any,
defaultValues: {
name: editingCredential?.name || "",
description: editingCredential?.description || "",
folder: editingCredential?.folder || "",
tags: editingCredential?.tags || [],
authType: editingCredential?.authType || "password",
username: editingCredential?.username || "",
password: "",
key: null,
keyPassword: "",
keyType: "rsa",
}
});
useEffect(() => {
if (editingCredential) {
const defaultAuthType = editingCredential.key ? 'key' : 'password';
setAuthTab(defaultAuthType);
form.reset({
name: editingCredential.name || "",
description: editingCredential.description || "",
folder: editingCredential.folder || "",
tags: editingCredential.tags || [],
authType: defaultAuthType as 'password' | 'key',
username: editingCredential.username || "",
password: "",
key: null,
keyPassword: "",
keyType: (editingCredential.keyType as any) || "rsa",
});
} else {
setAuthTab('password');
form.reset({
name: "",
description: "",
folder: "",
tags: [],
authType: "password",
username: "",
password: "",
key: null,
keyPassword: "",
keyType: "rsa",
});
}
}, [editingCredential, form]);
const onSubmit = async (data: any) => {
try {
const formData = data as FormData;
if (!formData.name || formData.name.trim() === '') {
formData.name = formData.username;
}
if (editingCredential) {
await updateCredential(editingCredential.id, formData);
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name }));
} else {
await createCredential(formData);
toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name }));
}
if (onFormSubmit) {
onFormSubmit();
}
window.dispatchEvent(new CustomEvent('credentials:changed'));
} catch (error) {
toast.error(t('credentials.failedToSaveCredential'));
}
};
const [tagInput, setTagInput] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(null);
const folderValue = form.watch('folder');
const filteredFolders = React.useMemo(() => {
if (!folderValue) return folders;
return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase()));
}, [folderValue, folders]);
const handleFolderClick = (folder: string) => {
form.setValue('folder', folder);
setFolderDropdownOpen(false);
};
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
folderDropdownRef.current &&
!folderDropdownRef.current.contains(event.target as Node) &&
folderInputRef.current &&
!folderInputRef.current.contains(event.target as Node)
) {
setFolderDropdownOpen(false);
}
}
if (folderDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [folderDropdownOpen]);
const keyTypeOptions = [
{ value: 'rsa', label: t('credentials.keyTypeRSA') },
{ value: 'ecdsa', label: t('credentials.keyTypeECDSA') },
{ value: 'ed25519', label: t('credentials.keyTypeEd25519') },
];
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onClickOutside(event: MouseEvent) {
if (
keyTypeDropdownOpen &&
keyTypeDropdownRef.current &&
!keyTypeDropdownRef.current.contains(event.target as Node) &&
keyTypeButtonRef.current &&
!keyTypeButtonRef.current.contains(event.target as Node)
) {
setKeyTypeDropdownOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, [keyTypeDropdownOpen]);
return (
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full">
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">{t('credentials.general')}</TabsTrigger>
<TabsTrigger value="authentication">{t('credentials.authentication')}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">{t('credentials.basicInformation')}</FormLabel>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t('credentials.credentialName')}</FormLabel>
<FormControl>
<Input placeholder={t('placeholders.credentialName')} {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t('credentials.username')}</FormLabel>
<FormControl>
<Input placeholder={t('placeholders.username')} {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">{t('credentials.organization')}</FormLabel>
<div className="grid grid-cols-26 gap-4">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="col-span-10">
<FormLabel>{t('credentials.description')}</FormLabel>
<FormControl>
<Input placeholder={t('placeholders.description')} {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="folder"
render={({ field }) => (
<FormItem className="col-span-10 relative">
<FormLabel>{t('credentials.folder')}</FormLabel>
<FormControl>
<Input
ref={folderInputRef}
placeholder={t('placeholders.folder')}
className="min-h-[40px]"
autoComplete="off"
value={field.value}
onFocus={() => setFolderDropdownOpen(true)}
onChange={e => {
field.onChange(e);
setFolderDropdownOpen(true);
}}
/>
</FormControl>
{folderDropdownOpen && filteredFolders.length > 0 && (
<div
ref={folderDropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{filteredFolders.map((folder) => (
<Button
key={folder}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => handleFolderClick(folder)}
>
{folder}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>{t('credentials.tags')}</FormLabel>
<FormControl>
<div
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
{field.value.map((tag: string, idx: number) => (
<span key={tag + idx}
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
{tag}
<button
type="button"
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
onClick={() => {
const newTags = field.value.filter((_: string, i: number) => i !== idx);
field.onChange(newTags);
}}
>
×
</button>
</span>
))}
<input
type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6"
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => {
if (e.key === " " && tagInput.trim() !== "") {
e.preventDefault();
if (!field.value.includes(tagInput.trim())) {
field.onChange([...field.value, tagInput.trim()]);
}
setTagInput("");
} else if (e.key === "Backspace" && tagInput === "" && field.value.length > 0) {
field.onChange(field.value.slice(0, -1));
}
}}
placeholder={t('credentials.addTagsSpaceToAdd')}
/>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="authentication">
<FormLabel className="mb-3 font-bold">{t('credentials.authentication')}</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
setAuthTab(value as 'password' | 'key');
form.setValue('authType', value as 'password' | 'key');
// Clear other auth fields when switching
if (value === 'password') {
form.setValue('key', null);
form.setValue('keyPassword', '');
} else if (value === 'key') {
form.setValue('password', '');
}
}}
className="flex-1 flex flex-col h-full min-h-0"
>
<TabsList>
<TabsTrigger value="password">{t('credentials.password')}</TabsTrigger>
<TabsTrigger value="key">{t('credentials.key')}</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('credentials.password')}</FormLabel>
<FormControl>
<Input type="password" placeholder={t('placeholders.password')} {...field} />
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
<div className="grid grid-cols-15 gap-4">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="col-span-4 overflow-hidden min-w-0">
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
<FormControl>
<div className="relative min-w-0">
<input
id="key-upload"
type="file"
accept=".pem,.key,.txt,.ppk"
onChange={(e) => {
const file = e.target.files?.[0];
field.onChange(file || null);
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
>
<span className="block w-full truncate"
title={field.value?.name || t('credentials.upload')}>
{field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>{t('credentials.keyPassword')}</FormLabel>
<FormControl>
<Input
placeholder={t('placeholders.keyPassword')}
type="password"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>{t('credentials.keyType')}</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
>
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('credentials.keyTypeRSA')}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</ScrollArea>
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25"/>
<Button
className=""
type="submit"
variant="outline"
style={{
transform: 'translateY(8px)'
}}
>
{editingCredential ? t('credentials.updateCredential') : t('credentials.addCredential')}
</Button>
</footer>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,482 @@
import React, { useState, useEffect } 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 { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import {
Key,
User,
Calendar,
Hash,
Folder,
Edit3,
Copy,
Settings,
Shield,
Clock,
Server,
Eye,
EyeOff,
ExternalLink,
AlertTriangle,
CheckCircle,
FileText
} from 'lucide-react';
import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
interface Credential {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
interface CredentialWithSecrets extends Credential {
password?: string;
key?: string;
keyPassword?: string;
}
interface HostInfo {
id: number;
name?: string;
ip: string;
port: number;
createdAt: string;
}
interface CredentialViewerProps {
credential: Credential;
onClose: () => void;
onEdit: () => void;
}
const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose, onEdit }) => {
const { t } = useTranslation();
const [credentialDetails, setCredentialDetails] = useState<CredentialWithSecrets | null>(null);
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
const [loading, setLoading] = useState(true);
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({});
const [activeTab, setActiveTab] = useState<'overview' | 'security' | 'usage'>('overview');
useEffect(() => {
fetchCredentialDetails();
fetchHostsUsing();
}, [credential.id]);
const fetchCredentialDetails = async () => {
try {
const response = await getCredentialDetails(credential.id);
setCredentialDetails(response);
} catch (error) {
console.error('Failed to fetch credential details:', error);
toast.error(t('credentials.failedToFetchCredentialDetails'));
}
};
const fetchHostsUsing = async () => {
try {
const response = await getCredentialHosts(credential.id);
setHostsUsing(response);
} catch (error) {
console.error('Failed to fetch hosts using credential:', error);
toast.error(t('credentials.failedToFetchHostsUsing'));
} finally {
setLoading(false);
}
};
const toggleSensitiveVisibility = (field: string) => {
setShowSensitive(prev => ({
...prev,
[field]: !prev[field]
}));
};
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t('copiedToClipboard', { field: fieldName }));
} catch (error) {
toast.error(t('credentials.failedToCopy'));
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getAuthIcon = (authType: string) => {
return authType === 'password' ? (
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
) : (
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
);
};
const renderSensitiveField = (
value: string | undefined,
fieldName: string,
label: string,
isMultiline = false
) => {
if (!value) return null;
const isVisible = showSensitive[fieldName];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
{label}
</label>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSensitiveVisibility(fieldName)}
>
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(value, label)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<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-zinc-500 dark:text-zinc-400">
{'•'.repeat(isMultiline ? 50 : 20)}
</div>
)}
</div>
</div>
);
};
if (loading || !credentialDetails) {
return (
<Sheet open={true} onOpenChange={onClose}>
<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-zinc-600"></div>
</div>
</SheetContent>
</Sheet>
);
}
return (
<Sheet open={true} onOpenChange={onClose}>
<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 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">
<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="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-10">
{/* Tab Navigation */}
<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 h-10"
>
<FileText className="h-4 w-4 mr-2" />
{t('credentials.overview')}
</Button>
<Button
variant={activeTab === 'security' ? 'default' : 'ghost'}
size="sm"
onClick={() => setActiveTab('security')}
className="flex-1 h-10"
>
<Shield className="h-4 w-4 mr-2" />
{t('credentials.security')}
</Button>
<Button
variant={activeTab === 'usage' ? 'default' : 'ghost'}
size="sm"
onClick={() => setActiveTab('usage')}
className="flex-1 h-10"
>
<Server className="h-4 w-4 mr-2" />
{t('credentials.usage')}
</Button>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<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-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-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-4">
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<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-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-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}
</Badge>
))}
</div>
</div>
</div>
)}
<Separator />
<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-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-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<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>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('credentials.usageStatistics')}</CardTitle>
</CardHeader>
<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-zinc-600 dark:text-zinc-400">
{t('credentials.timesUsed')}
</div>
</div>
{credentialDetails.lastUsed && (
<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-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-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-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div>
<div className="font-medium">{hostsUsing.length}</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{activeTab === 'security' && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<span>{t('credentials.securityDetails')}</span>
</CardTitle>
<CardDescription>
{t('credentials.securityDetailsDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<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-zinc-800 dark:text-zinc-200">
{t('credentials.credentialSecured')}
</div>
<div className="text-sm text-zinc-700 dark:text-zinc-300">
{t('credentials.credentialSecuredDescription')}
</div>
</div>
</div>
{credentialDetails.authType === 'password' && (
<div>
<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-6">
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3>
<div className="grid gap-6 md:grid-cols-2">
<div>
<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">
{credentialDetails.keyType?.toUpperCase() || t('unknown').toUpperCase()}
</Badge>
</div>
</div>
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)}
{credentialDetails.keyPassword && renderSensitiveField(
credentialDetails.keyPassword,
'keyPassword',
t('credentials.keyPassphrase')
)}
</div>
)}
<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-zinc-800 dark:text-zinc-200 mb-2">
{t('credentials.securityReminder')}
</div>
<div className="text-zinc-700 dark:text-zinc-300">
{t('credentials.securityReminderText')}
</div>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === 'usage' && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<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-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>
) : (
<ScrollArea className="h-64">
<div className="space-y-3">
{hostsUsing.map((host) => (
<div
key={host.id}
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-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-zinc-500 dark:text-zinc-400">
{host.ip}:{host.port}
</div>
</div>
</div>
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
{formatDate(host.createdAt)}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)}
</div>
<SheetFooter>
<Button variant="outline" onClick={onClose}>
{t('common.close')}
</Button>
<Button onClick={onEdit}>
<Edit3 className="h-4 w-4 mr-2" />
{t('credentials.editCredential')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
};
export default CredentialViewer;

View File

@@ -0,0 +1,336 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
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 {
Search,
Key,
Folder,
Edit,
Trash2,
Shield,
Pin,
Tag,
Info
} from 'lucide-react';
import { getCredentials, deleteCredential } from '@/ui/main-axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {CredentialEditor} from './CredentialEditor';
import CredentialViewer from './CredentialViewer';
interface Credential {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
interface CredentialsManagerProps {
onEditCredential?: (credential: Credential) => void;
}
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [showViewer, setShowViewer] = useState(false);
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null);
useEffect(() => {
fetchCredentials();
}, []);
const fetchCredentials = async () => {
try {
setLoading(true);
const data = await getCredentials();
setCredentials(data);
setError(null);
} catch (err) {
setError(t('credentials.failedToFetchCredentials'));
} finally {
setLoading(false);
}
};
const handleEdit = (credential: Credential) => {
if (onEditCredential) {
onEditCredential(credential);
}
};
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'));
}
}
};
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);
});
}
return filtered.sort((a, b) => {
const aName = a.name || a.username;
const bName = b.name || b.username;
return aName.localeCompare(bName);
});
}, [credentials, searchQuery]);
const credentialsByFolder = useMemo(() => {
const grouped: { [key: string]: Credential[] } = {};
filteredAndSortedCredentials.forEach(credential => {
const folder = credential.folder || t('credentials.uncategorized');
if (!grouped[folder]) {
grouped[folder] = [];
}
grouped[folder].push(credential);
});
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 sortedGrouped: { [key: string]: Credential[] } = {};
sortedFolders.forEach(folder => {
sortedGrouped[folder] = grouped[folder];
});
return sortedGrouped;
}, [filteredAndSortedCredentials, t]);
if (loading) {
return (
<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">
<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>
<div className="flex items-center gap-2">
<Button onClick={fetchCredentials} variant="outline" size="sm">
{t('credentials.refresh')}
</Button>
</div>
</div>
<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>
<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
credential={viewingCredential}
onClose={() => setShowViewer(false)}
onEdit={() => {
setShowViewer(false);
handleEdit(viewingCredential);
}}
/>
)}
</div>
);
}