ab79080f0b
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
278 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
};
|