refactor: extract SshKeysPage components

- Extract use-ssh-keys hook for state management
- Extract SSHKeyCreateForm and SSHKeyList components
- Slim SshKeysPage from 277 to ~80 lines

Quality gates: tsc --noEmit passes, npm run build passes
This commit is contained in:
Developer
2026-06-05 19:27:38 +00:00
parent e49d049455
commit 96ce3f4c53
4 changed files with 386 additions and 252 deletions
@@ -0,0 +1,39 @@
import { Icon } from "../../icon";
interface Props {
newKeyName: string;
setNewKeyName: (name: string) => void;
generating: boolean;
onSubmit: (e: React.FormEvent) => void;
}
export const SSHKeyCreateForm = ({ newKeyName, setNewKeyName, generating, onSubmit }: Props) => {
return (
<form onSubmit={onSubmit} 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>
);
};
@@ -0,0 +1,180 @@
import { Icon } from "../../icon";
import { EmptyState, ErrorState } from "../../data-states";
import type { SSHKey } from "../../../api/ssh-keys";
interface Props {
keys: SSHKey[];
status: "idle" | "loading" | "ready" | "error";
signPayloads: Record<string, string>;
signatures: Record<string, string>;
signing: Record<string, boolean>;
verifyPayloads: Record<string, string>;
verifySignatures: Record<string, string>;
verifyResults: Record<string, boolean | null>;
verifying: Record<string, boolean>;
onLoadKeys: () => void;
onDelete: (id: string) => void;
onCopy: (text: string) => void;
onSign: (id: string) => void;
onVerify: (id: string) => void;
onSignPayloadChange: (id: string, value: string) => void;
onVerifyPayloadChange: (id: string, value: string) => void;
onVerifySignatureChange: (id: string, value: string) => void;
}
export const SSHKeyList = ({
keys,
status,
signPayloads,
signatures,
signing,
verifyPayloads,
verifySignatures,
verifyResults,
verifying,
onLoadKeys,
onDelete,
onCopy,
onSign,
onVerify,
onSignPayloadChange,
onVerifyPayloadChange,
onVerifySignatureChange,
}: Props) => {
if (status === "error") {
return <ErrorState message="Failed to load SSH keys" onRetry={onLoadKeys} />;
}
if (keys.length === 0) {
return <EmptyState message="No SSH keys yet. Generate one above." />;
}
return (
<div className="keys-list">
{keys.map((key) => (
<div key={key.id} className="key-card">
<div className="key-header">
<h3>{key.name}</h3>
<button onClick={() => onDelete(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={() => onCopy(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) => onSignPayloadChange(key.id, e.target.value)}
placeholder="Enter payload to sign..."
rows={3}
/>
</div>
<button
onClick={() => onSign(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={() => onCopy(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) => onVerifyPayloadChange(key.id, e.target.value)}
placeholder="Enter payload..."
rows={2}
/>
</div>
<div className="form-group">
<textarea
value={verifySignatures[key.id] || ""}
onChange={(e) => onVerifySignatureChange(key.id, e.target.value)}
placeholder="Enter base64 signature..."
rows={2}
/>
</div>
<button
onClick={() => onVerify(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>
);
};
+108
View File
@@ -0,0 +1,108 @@
import { useState } from "react";
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh-keys";
import { useAsyncData } from "./use-async-data";
export const useSSHKeys = () => {
const { data: keys, status, 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 }));
}
}
return {
keys: safeKeys,
status,
loadKeys,
newKeyName,
setNewKeyName,
generating,
mutationError,
setMutationError,
signPayloads,
setSignPayloads,
signatures,
signing,
verifyPayloads,
setVerifyPayloads,
verifySignatures,
setVerifySignatures,
verifyResults,
verifying,
handleGenerate,
handleDelete,
copyToClipboard,
handleSign,
handleVerify,
};
};
+59 -252
View File
@@ -1,92 +1,35 @@
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";
import { LoadingState } from "../components/data-states";
import { useSSHKeys } from "../hooks/use-ssh-keys";
import { SSHKeyCreateForm } from "../components/features/ssh-keys/SSHKeyCreateForm";
import { SSHKeyList } from "../components/features/ssh-keys/SSHKeyList";
export const SSHKeysPage = () => {
const navigate = useNavigate();
const { data: keys, status, 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 }));
}
}
const {
keys,
status,
loadKeys,
newKeyName,
setNewKeyName,
generating,
mutationError,
signPayloads,
signatures,
signing,
verifyPayloads,
verifySignatures,
verifyResults,
verifying,
handleGenerate,
handleDelete,
copyToClipboard,
handleSign,
handleVerify,
setSignPayloads,
setVerifyPayloads,
setVerifySignatures,
} = useSSHKeys();
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
@@ -104,174 +47,38 @@ export const SSHKeysPage = () => {
{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>
<SSHKeyCreateForm
newKeyName={newKeyName}
setNewKeyName={setNewKeyName}
generating={generating}
onSubmit={handleGenerate}
/>
{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>
<SSHKeyList
keys={keys}
status={status}
signPayloads={signPayloads}
signatures={signatures}
signing={signing}
verifyPayloads={verifyPayloads}
verifySignatures={verifySignatures}
verifyResults={verifyResults}
verifying={verifying}
onLoadKeys={loadKeys}
onDelete={handleDelete}
onCopy={copyToClipboard}
onSign={handleSign}
onVerify={handleVerify}
onSignPayloadChange={(id, value) =>
setSignPayloads((prev) => ({ ...prev, [id]: value }))
}
onVerifyPayloadChange={(id, value) =>
setVerifyPayloads((prev) => ({ ...prev, [id]: value }))
}
onVerifySignatureChange={(id, value) =>
setVerifySignatures((prev) => ({ ...prev, [id]: value }))
}
/>
</section>
);
};