Files
move-data-nas/web/src/pages/SSHKeys.tsx
T
darroyo 9cef7173cb Visual redesign: design system, refined ops console aesthetic
- Full component library (Button, Input, Label, Select, Modal, Table, Badge, Card, etc.)
- Tailwind design tokens: IBM Plex Sans + JetBrains Mono, teal accent, semantic status colors
- NavBar with logo, responsive hamburger menu, real logout
- All pages redesigned: Login, Dashboard (KPI cards), Machines, SyncPairs, JobHistory, JobDetail, SSHKeys, Settings
- Fixed: hover:bg-gray-750 dead class, window.location.href navigation bug
- Replaced alert()/confirm() with sonner toasts and accessible modals
- Added ErrorBoundary, skip link, accessible modal dialogs (Radix)
- Icons: lucide-react throughout, copy/download buttons
- 1.0.5 → 1.0.6
2026-07-07 23:12:34 -04:00

357 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import { api, SSHKey } from '../api/client';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Textarea } from '@/components/ui/Textarea';
import { Badge } from '@/components/ui/Badge';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from '@/components/ui/Modal';
import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { CopyButton } from '@/components/ui/CopyButton';
import { Spinner } from '@/components/ui/Spinner';
import {
Key,
Plus,
Upload,
Download,
Trash2,
Fingerprint,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type ModalType = 'generate' | 'import' | 'delete' | null;
export default function SSHKeys() {
const [keys, setKeys] = useState<SSHKey[]>([]);
const [modal, setModal] = useState<ModalType>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [genLabel, setGenLabel] = useState('');
const [importLabel, setImportLabel] = useState('');
const [importPubKey, setImportPubKey] = useState('');
const [loading, setLoading] = useState(false);
const [downloading, setDownloading] = useState<number | null>(null);
useEffect(() => {
load();
}, []);
async function load() {
try {
setKeys(await api<SSHKey[]>('/api/ssh-keys'));
} catch {}
}
async function generate() {
if (!genLabel.trim()) {
toast.error('Label is required');
return;
}
setLoading(true);
try {
await api('/api/ssh-keys', {
method: 'POST',
body: { label: genLabel.trim(), generate: true },
});
setModal(null);
setGenLabel('');
toast.success('SSH key pair generated');
load();
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function importKey() {
if (!importLabel.trim()) {
toast.error('Label is required');
return;
}
if (!importPubKey.trim()) {
toast.error('Public key is required');
return;
}
setLoading(true);
try {
await api('/api/ssh-keys', {
method: 'POST',
body: {
label: importLabel.trim(),
generate: false,
public_key: importPubKey.trim(),
},
});
setModal(null);
setImportLabel('');
setImportPubKey('');
toast.success('SSH key imported');
load();
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function handleDelete() {
if (deleteId === null) return;
try {
await api(`/api/ssh-keys/${deleteId}`, { method: 'DELETE' });
toast.success('SSH key deleted');
setDeleteId(null);
load();
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
async function downloadPrivate(id: number) {
try {
const res = await fetch(`/api/ssh-keys/${id}/private`, {
credentials: 'include',
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Failed' }));
toast.error((err as { error: string }).error);
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ssh-key-${id}.key`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setDownloading(id);
toast.success('Private key downloaded');
setTimeout(() => setDownloading(null), 3000);
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
return (
<div className="space-y-6">
<PageHeader
title="SSH Keys"
description="Manage SSH key pairs for authenticating with remote machines"
actions={
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setModal('import')}
>
<Upload className="h-4 w-4" />
Import Public Key
</Button>
<Button size="sm" onClick={() => setModal('generate')}>
<Plus className="h-4 w-4" />
Generate New
</Button>
</div>
}
/>
{keys.length === 0 ? (
<Card>
<CardBody className="p-0">
<EmptyState
icon={<Key className="h-5 w-5" />}
title="No SSH keys"
description="Generate a key pair or import a public key to authenticate with remote machines"
action={
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setModal('import')}
>
<Upload className="h-4 w-4" />
Import
</Button>
<Button size="sm" onClick={() => setModal('generate')}>
<Plus className="h-4 w-4" />
Generate
</Button>
</div>
}
/>
</CardBody>
</Card>
) : (
<div className="space-y-3">
{keys.map(k => (
<Card key={k.id}>
<CardBody>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-semibold text-fg truncate">
{k.label}
</h3>
{k.in_use && (
<Badge variant="success" label="In Use" />
)}
{!k.has_private_key && (
<Badge variant="neutral" label="Imported Only" />
)}
</div>
<div className="flex items-center gap-1.5 mb-3 text-xs text-fg-subtle">
<Fingerprint className="h-3.5 w-3.5" />
<span className="font-mono truncate">
{k.fingerprint}
</span>
</div>
<div className="bg-canvas-raised rounded-card p-3 font-mono text-xs text-emerald-400/80 break-all max-w-2xl">
{k.public_key}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<CopyButton
text={k.public_key}
displayText="Copy pub"
/>
{k.has_private_key && (
<Button
variant="secondary"
size="sm"
onClick={() => downloadPrivate(k.id)}
loading={downloading === k.id}
>
<Download className="h-3.5 w-3.5" />
{downloading === k.id ? 'Done' : 'Private'}
</Button>
)}
<Button
variant="danger"
size="icon-sm"
onClick={() => setDeleteId(k.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardBody>
</Card>
))}
</div>
)}
<Modal open={modal === 'generate'} onOpenChange={v => !v && setModal(null)}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>Generate SSH Key Pair</ModalTitle>
<ModalDescription>
Generate a new Ed25519 key pair. The private key will be
downloaded immediately and the public key stored on the server.
</ModalDescription>
</ModalHeader>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="gen-label" required>
Label
</Label>
<Input
id="gen-label"
placeholder="backup-nas"
value={genLabel}
onChange={e => setGenLabel(e.target.value)}
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={() => setModal(null)}>
Cancel
</Button>
<Button onClick={generate} loading={loading}>
<Key className="h-4 w-4" />
Generate
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={modal === 'import'} onOpenChange={v => !v && setModal(null)}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>Import Public Key</ModalTitle>
<ModalDescription>
Import an existing public key. Only the public key will be stored
you must have the corresponding private key on this server.
</ModalDescription>
</ModalHeader>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="import-label" required>
Label
</Label>
<Input
id="import-label"
placeholder="work-server"
value={importLabel}
onChange={e => setImportLabel(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="import-pubkey" required>
Public Key
</Label>
<Textarea
id="import-pubkey"
placeholder="ssh-ed25519 AAAA..."
value={importPubKey}
onChange={e => setImportPubKey(e.target.value)}
className="font-mono text-xs h-24"
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={() => setModal(null)}>
Cancel
</Button>
<Button onClick={importKey} loading={loading}>
<Upload className="h-4 w-4" />
Import
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Delete SSH Key</ModalTitle>
<ModalDescription>
Are you sure you want to delete this SSH key? Machines using it
will fall back to the server key.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button variant="danger-solid" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}