refactor(ssh): 将SSH密钥管理功能从账户页面移至系统配置页面

重构SSH密钥管理相关代码,将其从Account组件移动到SystemConfig组件
移除Account组件中不再使用的SSH相关代码和状态
保持原有功能不变,仅改变功能位置以更好组织代码结构
This commit is contained in:
lintsinghua 2025-12-26 20:51:00 +08:00
parent b030381ad2
commit 8644f6f113
2 changed files with 296 additions and 304 deletions

View File

@ -9,13 +9,16 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import {
Settings, Save, RotateCcw, Eye, EyeOff, CheckCircle2, AlertCircle,
Info, Zap, Globe, PlayCircle, Brain
Info, Zap, Globe, PlayCircle, Brain, Key, Copy, Trash2, Terminal, ServerCrash
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/shared/api/database";
import EmbeddingConfig from "@/components/agent/EmbeddingConfig";
import { generateSSHKey, getSSHKey, deleteSSHKey, testSSHKey, clearKnownHosts } from "@/shared/api/sshKeys";
// LLM Providers - 2025
const LLM_PROVIDERS = [
@ -54,7 +57,16 @@ export function SystemConfig() {
const [llmTestResult, setLlmTestResult] = useState<{ success: boolean; message: string; debug?: Record<string, unknown> } | null>(null);
const [showDebugInfo, setShowDebugInfo] = useState(true);
useEffect(() => { loadConfig(); }, []);
// SSH Key states
const [sshKey, setSSHKey] = useState<{ has_key: boolean; public_key?: string; fingerprint?: string }>({ has_key: false });
const [generatingKey, setGeneratingKey] = useState(false);
const [deletingKey, setDeletingKey] = useState(false);
const [clearingKnownHosts, setClearingKnownHosts] = useState(false);
const [testingKey, setTestingKey] = useState(false);
const [testRepoUrl, setTestRepoUrl] = useState("");
const [showDeleteKeyDialog, setShowDeleteKeyDialog] = useState(false);
useEffect(() => { loadConfig(); loadSSHKey(); }, []);
const loadConfig = async () => {
try {
@ -116,6 +128,99 @@ export function SystemConfig() {
}
};
// SSH Key functions
const loadSSHKey = async () => {
try {
const data = await getSSHKey();
setSSHKey(data);
} catch (error) {
console.error('Failed to load SSH key:', error);
}
};
const handleGenerateSSHKey = async () => {
try {
setGeneratingKey(true);
const data = await generateSSHKey();
setSSHKey({ has_key: true, public_key: data.public_key, fingerprint: data.fingerprint });
toast.success(data.message);
} catch (error: any) {
console.error('Failed to generate SSH key:', error);
toast.error(error.response?.data?.detail || "生成SSH密钥失败");
} finally {
setGeneratingKey(false);
}
};
const handleDeleteSSHKey = async () => {
try {
setDeletingKey(true);
await deleteSSHKey();
setSSHKey({ has_key: false });
toast.success("SSH密钥已删除");
setShowDeleteKeyDialog(false);
} catch (error: any) {
console.error('Failed to delete SSH key:', error);
toast.error(error.response?.data?.detail || "删除SSH密钥失败");
} finally {
setDeletingKey(false);
}
};
const handleTestSSHKey = async () => {
if (!testRepoUrl) {
toast.error("请输入仓库URL");
return;
}
try {
setTestingKey(true);
const result = await testSSHKey(testRepoUrl);
if (result.success) {
toast.success("SSH连接测试成功");
if (result.output) {
console.log("SSH测试输出:", result.output);
}
} else {
toast.error(result.message || "SSH连接测试失败", {
description: result.output ? `详情: ${result.output.substring(0, 100)}...` : undefined,
duration: 5000,
});
if (result.output) {
console.error("SSH测试失败:", result.output);
}
}
} catch (error: any) {
console.error('Failed to test SSH key:', error);
toast.error(error.response?.data?.detail || "测试SSH密钥失败");
} finally {
setTestingKey(false);
}
};
const handleClearKnownHosts = async () => {
try {
setClearingKnownHosts(true);
const result = await clearKnownHosts();
if (result.success) {
toast.success(result.message || "known_hosts已清理");
} else {
toast.error("清理known_hosts失败");
}
} catch (error: any) {
console.error('Failed to clear known_hosts:', error);
toast.error(error.response?.data?.detail || "清理known_hosts失败");
} finally {
setClearingKnownHosts(false);
}
};
const handleCopyPublicKey = () => {
if (sshKey.public_key) {
navigator.clipboard.writeText(sshKey.public_key);
toast.success("公钥已复制到剪贴板");
}
};
const saveConfig = async () => {
if (!config) return;
try {
@ -639,6 +744,160 @@ export function SystemConfig() {
<p className="text-muted-foreground"> Token</p>
</div>
</div>
{/* SSH Key Management */}
<div className="cyber-card p-6 space-y-4">
<div className="flex items-center gap-3 mb-2">
<Key className="w-5 h-5 text-emerald-400" />
<h3 className="text-lg font-bold uppercase tracking-wider text-foreground">SSH </h3>
</div>
<div className="flex items-start gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
<div className="flex-shrink-0 mt-0.5">
<div className="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<Key className="w-4 h-4 text-emerald-400" />
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground font-medium mb-1">
使 SSH 访 Git
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
SSH GitHub/GitLab使 SSH URL 访
</p>
</div>
</div>
{!sshKey.has_key ? (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-muted/50 mb-4">
<Key className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground mb-4"> SSH </p>
<Button
onClick={handleGenerateSSHKey}
disabled={generatingKey}
className="cyber-btn-primary h-10"
>
{generatingKey ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
<>
<Key className="w-4 h-4 mr-2" />
SSH
</>
)}
</Button>
</div>
) : (
<div className="space-y-4">
{/* Public Key Display */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold text-muted-foreground uppercase flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
SSH
</Label>
<Button
size="sm"
variant="ghost"
onClick={handleCopyPublicKey}
className="h-8 text-xs"
>
<Copy className="w-3 h-3 mr-1" />
</Button>
</div>
<Textarea
value={sshKey.public_key || ""}
readOnly
className="cyber-input font-mono text-xs h-24 resize-none"
/>
{/* 显示指纹 */}
{sshKey.fingerprint && (
<div className="p-3 bg-muted/50 rounded border border-border">
<Label className="text-xs font-bold text-muted-foreground uppercase mb-1 block">
(SHA256)
</Label>
<code className="text-xs text-emerald-400 font-mono break-all">
{sshKey.fingerprint}
</code>
</div>
)}
<p className="text-xs text-muted-foreground">
<a href="https://github.com/settings/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">GitHub</a> <a href="https://gitlab.com/-/profile/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">GitLab</a>
</p>
</div>
{/* Test SSH Connection */}
<div className="space-y-2 pt-4 border-t border-border">
<Label className="text-xs font-bold text-muted-foreground uppercase">
SSH
</Label>
<div className="flex gap-2">
<Input
placeholder="git@github.com:username/repo.git"
value={testRepoUrl}
onChange={(e) => setTestRepoUrl(e.target.value)}
className="cyber-input font-mono text-xs"
/>
<Button
onClick={handleTestSSHKey}
disabled={testingKey}
className="cyber-btn-outline whitespace-nowrap"
>
{testingKey ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
<>
<Terminal className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
{/* Delete Key and Clear Known Hosts */}
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<Button
variant="outline"
onClick={handleClearKnownHosts}
disabled={clearingKnownHosts}
className="cyber-btn-outline h-10"
>
{clearingKnownHosts ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
<>
<ServerCrash className="w-4 h-4 mr-2" />
known_hosts
</>
)}
</Button>
<Button
variant="destructive"
onClick={() => setShowDeleteKeyDialog(true)}
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30 h-10"
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
@ -650,6 +909,40 @@ export function SystemConfig() {
</Button>
</div>
)}
{/* Delete SSH Key Confirmation Dialog */}
<AlertDialog open={showDeleteKeyDialog} onOpenChange={setShowDeleteKeyDialog}>
<AlertDialogContent className="cyber-card border-rose-500/30 cyber-dialog">
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold uppercase text-foreground flex items-center gap-2">
<Trash2 className="w-5 h-5 text-rose-400" />
SSH
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground">
使 SSH 访 Git
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cyber-btn-outline" disabled={deletingKey}>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteSSHKey}
disabled={deletingKey}
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30"
>
{deletingKey ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
"确认删除"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -10,7 +10,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Textarea } from "@/components/ui/textarea";
import {
User,
Mail,
@ -22,17 +21,11 @@ import {
LogOut,
UserPlus,
GitBranch,
Terminal,
Key,
Copy,
Trash2,
CheckCircle2,
ServerCrash
Terminal
} from "lucide-react";
import { apiClient } from "@/shared/api/serverClient";
import { toast } from "sonner";
import type { Profile } from "@/shared/types";
import { generateSSHKey, getSSHKey, deleteSSHKey, testSSHKey, clearKnownHosts } from "@/shared/api/sshKeys";
export default function Account() {
const navigate = useNavigate();
@ -53,18 +46,8 @@ export default function Account() {
});
const [changingPassword, setChangingPassword] = useState(false);
// SSH Key states
const [sshKey, setSSHKey] = useState<{ has_key: boolean; public_key?: string; fingerprint?: string }>({ has_key: false });
const [generatingKey, setGeneratingKey] = useState(false);
const [deletingKey, setDeletingKey] = useState(false);
const [clearingKnownHosts, setClearingKnownHosts] = useState(false);
const [testingKey, setTestingKey] = useState(false);
const [testRepoUrl, setTestRepoUrl] = useState("");
const [showDeleteKeyDialog, setShowDeleteKeyDialog] = useState(false);
useEffect(() => {
loadProfile();
loadSSHKey();
}, []);
const loadProfile = async () => {
@ -86,101 +69,6 @@ export default function Account() {
}
};
const loadSSHKey = async () => {
try {
const data = await getSSHKey();
setSSHKey(data);
} catch (error) {
console.error('Failed to load SSH key:', error);
}
};
const handleGenerateSSHKey = async () => {
try {
setGeneratingKey(true);
const data = await generateSSHKey();
setSSHKey({ has_key: true, public_key: data.public_key, fingerprint: data.fingerprint });
toast.success(data.message);
} catch (error: any) {
console.error('Failed to generate SSH key:', error);
toast.error(error.response?.data?.detail || "生成SSH密钥失败");
} finally {
setGeneratingKey(false);
}
};
const handleDeleteSSHKey = async () => {
try {
setDeletingKey(true);
await deleteSSHKey();
setSSHKey({ has_key: false });
toast.success("SSH密钥已删除");
setShowDeleteKeyDialog(false);
} catch (error: any) {
console.error('Failed to delete SSH key:', error);
toast.error(error.response?.data?.detail || "删除SSH密钥失败");
} finally {
setDeletingKey(false);
}
};
const handleTestSSHKey = async () => {
if (!testRepoUrl) {
toast.error("请输入仓库URL");
return;
}
try {
setTestingKey(true);
const result = await testSSHKey(testRepoUrl);
if (result.success) {
toast.success("SSH连接测试成功");
// 在控制台输出详细信息
if (result.output) {
console.log("SSH测试输出:", result.output);
}
} else {
// 显示详细的错误信息
toast.error(result.message || "SSH连接测试失败", {
description: result.output ? `详情: ${result.output.substring(0, 100)}...` : undefined,
duration: 5000,
});
// 在控制台输出完整错误信息
if (result.output) {
console.error("SSH测试失败:", result.output);
}
}
} catch (error: any) {
console.error('Failed to test SSH key:', error);
toast.error(error.response?.data?.detail || "测试SSH密钥失败");
} finally {
setTestingKey(false);
}
};
const handleClearKnownHosts = async () => {
try {
setClearingKnownHosts(true);
const result = await clearKnownHosts();
if (result.success) {
toast.success(result.message || "known_hosts已清理");
} else {
toast.error("清理known_hosts失败");
}
} catch (error: any) {
console.error('Failed to clear known_hosts:', error);
toast.error(error.response?.data?.detail || "清理known_hosts失败");
} finally {
setClearingKnownHosts(false);
}
};
const handleCopyPublicKey = () => {
if (sshKey.public_key) {
navigator.clipboard.writeText(sshKey.public_key);
toast.success("公钥已复制到剪贴板");
}
};
const handleSave = async () => {
try {
setSaving(true);
@ -421,161 +309,6 @@ export default function Account() {
</div>
</div>
{/* SSH Key Management */}
<div className="lg:col-span-3 cyber-card p-0">
<div className="cyber-card-header">
<Key className="w-5 h-5 text-emerald-400" />
<h3 className="text-lg font-bold uppercase tracking-wider text-foreground">SSH </h3>
</div>
<div className="p-6 space-y-4">
<div className="flex items-start gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
<div className="flex-shrink-0 mt-0.5">
<div className="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<Key className="w-4 h-4 text-emerald-400" />
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground font-medium mb-1">
使 SSH 访 Git
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
SSH GitHub/GitLab使 SSH URL 访
</p>
</div>
</div>
{!sshKey.has_key ? (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-muted/50 mb-4">
<Key className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground mb-4"> SSH </p>
<Button
onClick={handleGenerateSSHKey}
disabled={generatingKey}
className="cyber-btn-primary h-10"
>
{generatingKey ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
<>
<Key className="w-4 h-4 mr-2" />
SSH
</>
)}
</Button>
</div>
) : (
<div className="space-y-4">
{/* Public Key Display */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold text-muted-foreground uppercase flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
SSH
</Label>
<Button
size="sm"
variant="ghost"
onClick={handleCopyPublicKey}
className="h-8 text-xs"
>
<Copy className="w-3 h-3 mr-1" />
</Button>
</div>
<Textarea
value={sshKey.public_key || ""}
readOnly
className="cyber-input font-mono text-xs h-24 resize-none"
/>
{/* 显示指纹 */}
{sshKey.fingerprint && (
<div className="p-3 bg-muted/50 rounded border border-border">
<Label className="text-xs font-bold text-muted-foreground uppercase mb-1 block">
(SHA256)
</Label>
<code className="text-xs text-emerald-400 font-mono break-all">
{sshKey.fingerprint}
</code>
</div>
)}
<p className="text-xs text-muted-foreground">
<a href="https://github.com/settings/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">GitHub</a> <a href="https://gitlab.com/-/profile/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">GitLab</a>
</p>
</div>
{/* Test SSH Connection */}
<div className="space-y-2 pt-4 border-t border-border">
<Label className="text-xs font-bold text-muted-foreground uppercase">
SSH
</Label>
<div className="flex gap-2">
<Input
placeholder="git@github.com:username/repo.git"
value={testRepoUrl}
onChange={(e) => setTestRepoUrl(e.target.value)}
className="cyber-input font-mono text-xs"
/>
<Button
onClick={handleTestSSHKey}
disabled={testingKey}
className="cyber-btn-outline whitespace-nowrap"
>
{testingKey ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
<>
<Terminal className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
{/* Delete Key and Clear Known Hosts */}
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<Button
variant="outline"
onClick={handleClearKnownHosts}
disabled={clearingKnownHosts}
className="cyber-btn-outline h-10"
>
{clearingKnownHosts ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
<>
<ServerCrash className="w-4 h-4 mr-2" />
known_hosts
</>
)}
</Button>
<Button
variant="destructive"
onClick={() => setShowDeleteKeyDialog(true)}
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30 h-10"
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
</div>
</div>
{/* Password Change */}
<div className="lg:col-span-3 cyber-card p-0">
<div className="cyber-card-header">
@ -655,40 +388,6 @@ export default function Account() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete SSH Key Confirmation Dialog */}
<AlertDialog open={showDeleteKeyDialog} onOpenChange={setShowDeleteKeyDialog}>
<AlertDialogContent className="cyber-card border-rose-500/30 cyber-dialog">
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold uppercase text-foreground flex items-center gap-2">
<Trash2 className="w-5 h-5 text-rose-400" />
SSH
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground">
使 SSH 访 Git
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cyber-btn-outline" disabled={deletingKey}>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteSSHKey}
disabled={deletingKey}
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30"
>
{deletingKey ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
"确认删除"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}