301 lines
14 KiB
TypeScript
301 lines
14 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
RefreshCcw,
|
|
ChevronDown,
|
|
User,
|
|
Globe,
|
|
Link,
|
|
Clipboard,
|
|
ShieldAlert,
|
|
Loader2
|
|
} from 'lucide-react';
|
|
import { Credential, UserGroup, BrowserGroup } from '../types';
|
|
import { formatDate } from '../utils';
|
|
|
|
interface CredentialsTabProps {
|
|
hostname: string;
|
|
}
|
|
|
|
export default function CredentialsTab({ hostname }: CredentialsTabProps) {
|
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
|
const [loadingCredentials, setLoadingCredentials] = useState(false);
|
|
const [expandedUsers, setExpandedUsers] = useState<string[]>([]);
|
|
const [expandedBrowsers, setExpandedBrowsers] = useState<string[]>([]);
|
|
const [expandedCredentials, setExpandedCredentials] = useState<string[]>([]);
|
|
const [revealedPasswords, setRevealedPasswords] = useState<string[]>([]);
|
|
|
|
const fetchCredentials = async () => {
|
|
try {
|
|
setLoadingCredentials(true);
|
|
const response = await fetch(`/hosts/${hostname}/credentials`);
|
|
if (!response.ok) throw new Error('获取凭据数据失败');
|
|
const data = await response.json();
|
|
setCredentials(data);
|
|
|
|
if (data.length > 0) {
|
|
const firstUser = data[0].username;
|
|
if (!expandedUsers.includes(firstUser)) {
|
|
setExpandedUsers([firstUser]);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('获取凭据数据失败:', error);
|
|
} finally {
|
|
setLoadingCredentials(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchCredentials();
|
|
}, [hostname]);
|
|
|
|
const credentialsByUser = useMemo<UserGroup[]>(() => {
|
|
const userMap = new Map<string, Credential[]>();
|
|
|
|
credentials.forEach(cred => {
|
|
if (!userMap.has(cred.username)) {
|
|
userMap.set(cred.username, []);
|
|
}
|
|
userMap.get(cred.username)!.push(cred);
|
|
});
|
|
|
|
const result: UserGroup[] = [];
|
|
|
|
userMap.forEach((userCreds, username) => {
|
|
const latestSyncTime = userCreds.reduce((latest, cred) => {
|
|
const credTime = new Date(cred.lastSyncTime);
|
|
return credTime > latest ? credTime : latest;
|
|
}, new Date(0)).toISOString();
|
|
|
|
const browserMap = new Map<string, Credential[]>();
|
|
|
|
userCreds.forEach(cred => {
|
|
if (!browserMap.has(cred.browser)) {
|
|
browserMap.set(cred.browser, []);
|
|
}
|
|
browserMap.get(cred.browser)!.push(cred);
|
|
});
|
|
|
|
const browsers: BrowserGroup[] = [];
|
|
browserMap.forEach((browserCreds, browserName) => {
|
|
browsers.push({
|
|
name: browserName,
|
|
credentials: browserCreds
|
|
});
|
|
});
|
|
|
|
result.push({
|
|
username,
|
|
browsers,
|
|
total: userCreds.length,
|
|
lastSyncTime: latestSyncTime
|
|
});
|
|
});
|
|
|
|
return result;
|
|
}, [credentials]);
|
|
|
|
const toggleUserExpanded = (username: string) => {
|
|
if (expandedUsers.includes(username)) {
|
|
setExpandedUsers(expandedUsers.filter(u => u !== username));
|
|
} else {
|
|
setExpandedUsers([...expandedUsers, username]);
|
|
}
|
|
};
|
|
|
|
const toggleBrowserExpanded = (browserKey: string) => {
|
|
if (expandedBrowsers.includes(browserKey)) {
|
|
setExpandedBrowsers(expandedBrowsers.filter(b => b !== browserKey));
|
|
} else {
|
|
setExpandedBrowsers([...expandedBrowsers, browserKey]);
|
|
}
|
|
};
|
|
|
|
const toggleCredentialExpanded = (credId: string) => {
|
|
if (expandedCredentials.includes(credId)) {
|
|
setExpandedCredentials(expandedCredentials.filter(c => c !== credId));
|
|
} else {
|
|
setExpandedCredentials([...expandedCredentials, credId]);
|
|
}
|
|
};
|
|
|
|
const revealPassword = (passwordKey: string) => {
|
|
if (!revealedPasswords.includes(passwordKey)) {
|
|
setRevealedPasswords([...revealedPasswords, passwordKey]);
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = async (text: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
} catch (err) {
|
|
console.error('复制失败:', err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-xl font-medium text-gray-900 dark:text-white">凭据信息</h2>
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={fetchCredentials}
|
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded focus:outline-none flex items-center"
|
|
>
|
|
<RefreshCcw className={`h-4 w-4 mr-1 ${loadingCredentials ? 'animate-spin' : ''}`} />
|
|
刷新
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{loadingCredentials ? (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-pulse flex flex-col items-center">
|
|
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
|
|
<p className="mt-2 text-gray-500 dark:text-gray-400">加载凭据信息...</p>
|
|
</div>
|
|
</div>
|
|
) : credentialsByUser.length === 0 ? (
|
|
<div className="py-12 flex flex-col items-center justify-center">
|
|
<ShieldAlert className="h-12 w-12 text-gray-400 mb-3" />
|
|
<p className="text-gray-500 dark:text-gray-400 mb-1">没有找到任何凭据信息</p>
|
|
<p className="text-sm text-gray-400 dark:text-gray-500">可能是该主机尚未上报凭据数据</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{credentialsByUser.map((userGroup, index) => (
|
|
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden">
|
|
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div
|
|
className="flex items-center cursor-pointer"
|
|
onClick={() => toggleUserExpanded(userGroup.username)}
|
|
>
|
|
<User className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
|
<h3 className="font-medium text-gray-900 dark:text-white">{userGroup.username}</h3>
|
|
<div className="ml-3 text-sm text-gray-500 dark:text-gray-400">
|
|
({userGroup.browsers.length} 个浏览器, {userGroup.total} 个凭据)
|
|
</div>
|
|
<div className="ml-2 text-xs text-gray-400 dark:text-gray-500">
|
|
{userGroup.lastSyncTime ? `最后同步: ${formatDate(userGroup.lastSyncTime, 'short')}` : '未同步'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{expandedUsers.includes(userGroup.username) && (
|
|
<div className="divide-y divide-gray-100 dark:divide-gray-600">
|
|
{userGroup.browsers.map((browser) => (
|
|
<div
|
|
key={`${userGroup.username}-${browser.name}`}
|
|
className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
>
|
|
<div
|
|
className="flex items-center mb-2 cursor-pointer"
|
|
onClick={() => toggleBrowserExpanded(`${userGroup.username}-${browser.name}`)}
|
|
>
|
|
<div className="flex items-center">
|
|
<Globe className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
|
|
<span className="font-medium text-gray-800 dark:text-gray-200">{browser.name}</span>
|
|
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
|
({browser.credentials.length} 个站点)
|
|
</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={`h-4 w-4 text-gray-500 dark:text-gray-400 ml-2 transition-transform duration-200 ${expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) ? 'rotate-180' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
|
|
{expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) && (
|
|
<div className="pl-6 space-y-3 mt-2">
|
|
{browser.credentials.map((cred) => {
|
|
const credentialId = cred._id || cred.id || `${userGroup.username}-${browser.name}-${cred.url}-${cred.login}`;
|
|
const isExpanded = expandedCredentials.includes(credentialId);
|
|
return (
|
|
<div
|
|
key={credentialId}
|
|
className="border border-gray-200 dark:border-gray-600 rounded-md overflow-hidden"
|
|
>
|
|
<div
|
|
className="bg-gray-50 dark:bg-gray-700 px-3 py-2 flex items-center justify-between cursor-pointer"
|
|
onClick={() => toggleCredentialExpanded(credentialId)}
|
|
>
|
|
<div className="flex items-center">
|
|
<Link className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
|
|
<div className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-md">
|
|
{cred.url}
|
|
</div>
|
|
</div>
|
|
<ChevronDown
|
|
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
|
/>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="bg-white dark:bg-gray-800 px-3 py-2">
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
<div className="flex items-center">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">用户名:</span>
|
|
<span className="text-sm font-medium ml-2 text-gray-900 dark:text-white">{cred.login}</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center mb-1">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">密码历史:</span>
|
|
<span className="text-xs bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded px-1">
|
|
{cred.passwords.length} 条记录
|
|
</span>
|
|
</div>
|
|
|
|
<div className="pl-6 space-y-2 mt-1">
|
|
{cred.passwords.map((pwd, pwdIndex) => {
|
|
const pwdKey = `${credentialId}-${pwdIndex}`;
|
|
const revealed = revealedPasswords.includes(pwdKey);
|
|
return (
|
|
<div
|
|
key={pwdIndex}
|
|
className="flex items-center group relative"
|
|
>
|
|
<span className="text-xs text-gray-400 dark:text-gray-500 w-24 flex-shrink-0">
|
|
{formatDate(pwd.timestamp, 'short')}
|
|
</span>
|
|
<div className="flex-1 flex items-center">
|
|
<span
|
|
className="text-sm font-mono bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white px-2 py-0.5 rounded flex-1 cursor-pointer"
|
|
onClick={() => revealed ? null : revealPassword(pwdKey)}
|
|
>
|
|
{revealed ? pwd.value : '••••••••'}
|
|
</span>
|
|
<button
|
|
onClick={() => copyToClipboard(pwd.value)}
|
|
className="ml-2 opacity-0 group-hover:opacity-100 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
>
|
|
<Clipboard className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|