Files
headquarter/apps/web/src/pages/ssh-keys.tsx
T
Alex Blank ab79080f0b refactor: consolidate loading/error states and extract instance actions hook
Frontend:
- Create reusable DataStates components (LoadingState, ErrorState, EmptyState)
- Refactor 12 pages to use shared state components instead of inline JSX
- Extract useInstanceActions hook to eliminate session action duplication
- Update dashboard and sessions pages to use shared hook

OpenSpec:
- Archive completed mobile-app-usability change (44/44 tasks)
- Archive completed add-config-profiles change (15/15 tasks)

Quality: TypeScript check passes, production build succeeds
2026-05-25 22:50:18 +02:00

278 lines
9.6 KiB
TypeScript

import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
export const SSHKeysPage = () => {
const navigate = useNavigate();
const { data: keys, status, error, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
const [newKeyName, setNewKeyName] = useState("");
const [generating, setGenerating] = useState(false);
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
const [signatures, setSignatures] = useState<Record<string, string>>({});
const [signing, setSigning] = useState<Record<string, boolean>>({});
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
const [mutationError, setMutationError] = useState<string | null>(null);
const safeKeys = keys ?? [];
async function handleGenerate(e: React.FormEvent) {
e.preventDefault();
if (!newKeyName.trim()) return;
try {
setGenerating(true);
await createSSHKey({ name: newKeyName.trim() });
setNewKeyName("");
await loadKeys();
} catch {
setMutationError("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 {
setMutationError("Failed to delete SSH key");
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
async function handleSign(keyId: string) {
const payload = signPayloads[keyId];
if (!payload?.trim()) return;
try {
setSigning((prev) => ({ ...prev, [keyId]: true }));
const result = await signPayload(keyId, { payload: payload.trim() });
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
setMutationError(null);
} catch {
setMutationError("Failed to sign payload");
} finally {
setSigning((prev) => ({ ...prev, [keyId]: false }));
}
}
async function handleVerify(keyId: string) {
const payload = verifyPayloads[keyId];
const signature = verifySignatures[keyId];
if (!payload?.trim() || !signature?.trim()) return;
try {
setVerifying((prev) => ({ ...prev, [keyId]: true }));
const result = await verifySignature(keyId, {
payload: payload.trim(),
signature: signature.trim(),
});
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
setMutationError(null);
} catch {
setMutationError("Failed to verify signature");
} finally {
setVerifying((prev) => ({ ...prev, [keyId]: false }));
}
}
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>SSH Keys</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
</div>
{mutationError && <div className="error">{mutationError}</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>
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
<div className="keys-list">
{safeKeys.length === 0 ? (
<EmptyState message="No SSH keys yet. Generate one above." />
) : (
safeKeys.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 className="key-signing">
<h4>Sign Payload</h4>
<div className="form-group">
<textarea
value={signPayloads[key.id] || ""}
onChange={(e) =>
setSignPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
}
placeholder="Enter payload to sign..."
rows={3}
/>
</div>
<button
onClick={() => handleSign(key.id)}
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
className="primary-button"
>
{signing[key.id] ? (
<>
<Icon name="loading" size="sm" />
Signing...
</>
) : (
<>
<Icon name="edit" size="sm" />
Sign
</>
)}
</button>
{signatures[key.id] && (
<div className="signature-result">
<label>Signature (base64):</label>
<code>{signatures[key.id]}</code>
<button
onClick={() => copyToClipboard(signatures[key.id])}
className="secondary-button"
>
<Icon name="copy" size="sm" />
Copy Signature
</button>
</div>
)}
</div>
<div className="key-verification">
<h4>Verify Signature</h4>
<div className="form-group">
<textarea
value={verifyPayloads[key.id] || ""}
onChange={(e) =>
setVerifyPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
}
placeholder="Enter payload..."
rows={2}
/>
</div>
<div className="form-group">
<textarea
value={verifySignatures[key.id] || ""}
onChange={(e) =>
setVerifySignatures((prev) => ({ ...prev, [key.id]: e.target.value }))
}
placeholder="Enter base64 signature..."
rows={2}
/>
</div>
<button
onClick={() => handleVerify(key.id)}
disabled={
verifying[key.id] ||
!verifyPayloads[key.id]?.trim() ||
!verifySignatures[key.id]?.trim()
}
className="primary-button"
>
{verifying[key.id] ? (
<>
<Icon name="loading" size="sm" />
Verifying...
</>
) : (
<>
<Icon name="success" size="sm" />
Verify
</>
)}
</button>
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
{verifyResults[key.id] ? (
<>
<Icon name="success" size="sm" />
Signature is valid
</>
) : (
<>
<Icon name="error" size="sm" />
Signature is invalid
</>
)}
</div>
)}
</div>
</div>
))
)}
</div>
</section>
);
};