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
This commit is contained in:
+289
-96
@@ -1,69 +1,130 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
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 [showGen, setShowGen] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
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 [downloading, setDownloading] = useState<number | null>(null);
|
||||
const [copied, setCopied] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloading, setDownloading] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
try { setKeys(await api<SSHKey[]>('/api/ssh-keys')); } catch {}
|
||||
try {
|
||||
setKeys(await api<SSHKey[]>('/api/ssh-keys'));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!genLabel.trim()) { alert('Label is required'); return; }
|
||||
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 },
|
||||
});
|
||||
setShowGen(false);
|
||||
setModal(null);
|
||||
setGenLabel('');
|
||||
toast.success('SSH key pair generated');
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
finally { setLoading(false); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function importKey() {
|
||||
if (!importLabel.trim()) { alert('Label is required'); return; }
|
||||
if (!importPubKey.trim()) { alert('Public key is required'); return; }
|
||||
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() },
|
||||
body: {
|
||||
label: importLabel.trim(),
|
||||
generate: false,
|
||||
public_key: importPubKey.trim(),
|
||||
},
|
||||
});
|
||||
setShowImport(false);
|
||||
setModal(null);
|
||||
setImportLabel('');
|
||||
setImportPubKey('');
|
||||
toast.success('SSH key imported');
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
finally { setLoading(false); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this SSH key? Machines using it will fall back to the server key.')) return;
|
||||
async function handleDelete() {
|
||||
if (deleteId === null) return;
|
||||
try {
|
||||
await api(`/api/ssh-keys/${id}`, { method: 'DELETE' });
|
||||
await api(`/api/ssh-keys/${deleteId}`, { method: 'DELETE' });
|
||||
toast.success('SSH key deleted');
|
||||
setDeleteId(null);
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
} 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' });
|
||||
const res = await fetch(`/api/ssh-keys/${id}/private`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Failed' }));
|
||||
alert((err as { error: string }).error);
|
||||
toast.error((err as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
@@ -76,88 +137,220 @@ export default function SSHKeys() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
setDownloading(id);
|
||||
toast.success('Private key downloaded');
|
||||
setTimeout(() => setDownloading(null), 3000);
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
}
|
||||
|
||||
async function copyPubKey(key: SSHKey) {
|
||||
await navigator.clipboard.writeText(key.public_key);
|
||||
setCopied(key.id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">SSH Keys</h1>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowGen(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
|
||||
Generate New
|
||||
</button>
|
||||
<button onClick={() => setShowImport(true)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded text-sm">
|
||||
Import Public Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showGen || showImport) && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 p-6 rounded-lg w-[500px] space-y-3">
|
||||
<h2 className="text-lg font-bold">{showGen ? 'Generate SSH Key Pair' : 'Import Public Key'}</h2>
|
||||
<input placeholder="Label (e.g. backup-nas)" value={genLabel} onChange={e => setGenLabel(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
{showImport && (
|
||||
<textarea placeholder="ssh-ed25519 AAAA..." value={importPubKey} onChange={e => setImportPubKey(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded px-3 py-2 text-white font-mono text-xs h-32" />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={showGen ? generate : importKey} disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1 disabled:opacity-50">
|
||||
{loading ? 'Working...' : showGen ? 'Generate' : 'Import'}
|
||||
</button>
|
||||
<button onClick={() => { setShowGen(false); setShowImport(false); }}
|
||||
className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{keys.map(k => (
|
||||
<div key={k.id} className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-white">{k.label}</span>
|
||||
{k.in_use && <span className="text-xs bg-green-900 text-green-400 px-2 py-0.5 rounded">In Use</span>}
|
||||
{!k.has_private_key && <span className="text-xs bg-gray-700 text-gray-400 px-2 py-0.5 rounded">Imported Only</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-2">Fingerprint: {k.fingerprint}</div>
|
||||
<div className="bg-gray-900 p-2 rounded font-mono text-xs text-green-400 break-all max-w-2xl">
|
||||
{k.public_key}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button onClick={() => copyPubKey(k)}
|
||||
className="text-gray-400 hover:text-white text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
|
||||
{copied === k.id ? 'Copied!' : 'Copy Public'}
|
||||
</button>
|
||||
{k.has_private_key && (
|
||||
<button onClick={() => downloadPrivate(k.id)}
|
||||
className="text-yellow-400 hover:text-yellow-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
|
||||
{downloading === k.id ? 'Downloaded!' : 'Download Private Key'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => remove(k.id)}
|
||||
className="text-red-400 hover:text-red-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
|
||||
Delete
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
))}
|
||||
{keys.length === 0 && <div className="text-gray-500 text-center py-12">No SSH keys. Generate one or import a public key above.</div>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user