6f41fa7cbe
- Install @phosphor-icons/react package - Create centralized Icon component with size/weight/color variants - Create icon registry with 34 icons across 5 categories - Replace all raw Unicode symbols with proper icon components - Add icons to navigation, buttons, status indicators, git operations - Add icon CSS with consistent sizing and spacing - Fix type definitions for Phosphor icon compatibility Quality gates: typecheck ✓, lint ✓, build ✓ (375KB bundle)
133 lines
3.6 KiB
TypeScript
133 lines
3.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
|
import { Icon } from "../components/icon";
|
|
|
|
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 ? (
|
|
<>
|
|
<Icon name="loading" size="sm" />
|
|
Generating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Icon name="add" size="sm" />
|
|
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"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
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"
|
|
>
|
|
<Icon name="copy" size="sm" />
|
|
Copy Full Key
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|