feat: implement SSH key management

- Add backend API endpoints for SSH key CRUD (POST, GET, DELETE)
- Implement Ed25519 key generation with Fernet-encrypted private keys
- Add frontend SSH keys page with generate, list, and delete functionality
- Include copy-to-clipboard for public keys
- Add responsive CSS styles for key cards
- Register ssh_keys router in main.py
- Add basic auth tests for SSH key endpoints

Quality gates: ruff ✓, mypy ✓, typecheck ✓, lint ✓
This commit is contained in:
Fusion
2026-05-18 14:44:21 +02:00
parent be81aa1c8b
commit a441ea2fac
24 changed files with 500 additions and 369 deletions
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
export const SSHKeysPage = () => {
const [keys, setKeys] = useState<SSHKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newKeyName, setNewKeyName] = useState("");
const [generating, setGenerating] = useState(false);
useEffect(() => {
loadKeys();
}, []);
async function loadKeys() {
try {
setLoading(true);
const data = await listSSHKeys();
setKeys(data);
setError(null);
} catch {
setError("Failed to load SSH keys");
} finally {
setLoading(false);
}
}
async function handleGenerate(e: React.FormEvent) {
e.preventDefault();
if (!newKeyName.trim()) return;
try {
setGenerating(true);
await createSSHKey({ name: newKeyName.trim() });
setNewKeyName("");
await loadKeys();
} catch {
setError("Failed to generate SSH key");
} finally {
setGenerating(false);
}
}
async function handleDelete(keyId: string) {
if (!confirm("Are you sure you want to delete this SSH key?")) return;
try {
await deleteSSHKey(keyId);
await loadKeys();
} catch {
setError("Failed to delete SSH key");
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
if (loading) return <div>Loading...</div>;
return (
<section className="stack">
<h1>SSH Keys</h1>
{error && <div className="error">{error}</div>}
<form onSubmit={handleGenerate} className="stack">
<div className="form-group">
<label htmlFor="key-name">Key Name</label>
<input
id="key-name"
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="e.g., GitHub Work"
required
/>
</div>
<button type="submit" className="primary-button" disabled={generating}>
{generating ? "Generating..." : "Generate SSH Key"}
</button>
</form>
<div className="keys-list">
{keys.length === 0 ? (
<p className="muted">No SSH keys yet. Generate one above.</p>
) : (
keys.map((key) => (
<div key={key.id} className="key-card">
<div className="key-header">
<h3>{key.name}</h3>
<button
onClick={() => handleDelete(key.id)}
className="danger-button"
>
Delete
</button>
</div>
<div className="key-meta">
<span className="muted">
Created: {new Date(key.created_at).toLocaleDateString()}
</span>
</div>
<div className="key-public">
<code>{key.public_key.substring(0, 50)}...</code>
<button
onClick={() => copyToClipboard(key.public_key)}
className="secondary-button"
>
Copy Full Key
</button>
</div>
</div>
))
)}
</div>
</section>
);
};