feat(backup): full DR bundle export + admin-configurable offsite destinations
Backend-agnostic disaster-recovery backup engine that runs on the current storage backend (no storage cutover required): - Full-bundle export: db.dump (pg_dump custom) + every storage blob + manifest.json with per-object SHA-256, streamed as a tar. Entry points: admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts. - Admin-configurable push destinations (backup_destinations table, migration 0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM at rest; API returns only *IsSet markers. - Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) + scripts/decrypt-backup.ts for restore. - Wired the previously-dead database-backup cron to runScheduledBackupPush (push to enabled destinations, prune to retention, alert super-admins on failure). Tests: 1608 unit/integration pass; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Download, Database, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
Download,
|
||||
Database,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
HardDriveDownload,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -94,6 +101,18 @@ export function BackupAdminPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFullBundle() {
|
||||
// Streams a tar (db.dump + every blob + manifest.json) straight to disk via
|
||||
// the GET endpoint (cookie auth). The server assembles the bundle before
|
||||
// the first byte arrives, so the browser download sits "pending" for a
|
||||
// moment on large datasets — flag that so it doesn't look stuck.
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
toast.info(
|
||||
'Preparing full backup — your download starts once the server finishes assembling it.',
|
||||
);
|
||||
triggerUrlDownload('/api/v1/admin/backup/export', `pn-crm-backup-${stamp}.tar`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -126,6 +145,32 @@ export function BackupAdminPanel() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<HardDriveDownload className="h-4 w-4" aria-hidden />
|
||||
Full disaster-recovery export
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Bundles the database dump <em>and every stored file</em> (documents, berth PDFs,
|
||||
brochures, GDPR exports) into one <code>.tar</code> and downloads it to this computer.
|
||||
Use this as an offsite, storage-backend-independent backup.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={downloadFullBundle}>
|
||||
<HardDriveDownload className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Download full backup
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
The archive contains <code>db.dump</code>, <code>blobs/<key></code> for every file,
|
||||
and a <code>manifest.json</code> with a SHA-256 per object for restore-side verification.
|
||||
Unlike the DB-only backup above, this does not depend on the active storage backend
|
||||
surviving. Restore steps live in <code>docs/backup-restore-runbook.md</code>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">History</CardTitle>
|
||||
|
||||
604
src/components/admin/backup-destinations-card.tsx
Normal file
604
src/components/admin/backup-destinations-card.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Server,
|
||||
Cloud,
|
||||
FolderTree,
|
||||
Trash2,
|
||||
Pencil,
|
||||
PlugZap,
|
||||
UploadCloud,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
type DestType = 'sftp' | 's3' | 'filesystem';
|
||||
type Schedule = 'off' | 'daily' | 'weekly';
|
||||
|
||||
interface Destination {
|
||||
id: string;
|
||||
name: string;
|
||||
type: DestType;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
retentionCount: number | null;
|
||||
encryptBundle: boolean;
|
||||
encryptionKeyIsSet: boolean;
|
||||
lastRunAt: string | null;
|
||||
lastStatus: string | null;
|
||||
lastError: string | null;
|
||||
lastBackupBytes: number | null;
|
||||
}
|
||||
|
||||
const TYPE_META: Record<DestType, { label: string; icon: typeof Server; hint: string }> = {
|
||||
sftp: { label: 'SFTP / SSH server', icon: Server, hint: 'Push to a server over SFTP.' },
|
||||
s3: { label: 'S3-compatible', icon: Cloud, hint: 'AWS S3, Backblaze B2, Wasabi, R2, MinIO.' },
|
||||
filesystem: {
|
||||
label: 'Mounted path / NAS',
|
||||
icon: FolderTree,
|
||||
hint: 'A directory this server can write to.',
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
ok: 'text-emerald-700',
|
||||
failed: 'text-rose-700',
|
||||
};
|
||||
|
||||
function formatBytes(n: number | null): string {
|
||||
if (n === null) return '—';
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function BackupDestinationsCard() {
|
||||
const [destinations, setDestinations] = useState<Destination[]>([]);
|
||||
const [schedule, setSchedule] = useState<Schedule>('off');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Destination | 'new' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [d, s] = await Promise.all([
|
||||
apiFetch<{ data: Destination[] }>('/api/v1/admin/backup/destinations'),
|
||||
apiFetch<{ data: { schedule: Schedule } }>('/api/v1/admin/backup/schedule'),
|
||||
]);
|
||||
setDestinations(d.data);
|
||||
setSchedule(s.data.schedule);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSchedule(value: Schedule) {
|
||||
setSchedule(value);
|
||||
try {
|
||||
await apiFetch('/api/v1/admin/backup/schedule', {
|
||||
method: 'PUT',
|
||||
body: { schedule: value },
|
||||
});
|
||||
toast.success(
|
||||
value === 'off' ? 'Automated backups turned off' : `Automated backups: ${value}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
void load();
|
||||
}
|
||||
}
|
||||
|
||||
async function test(id: string) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${id}/test`, { method: 'POST' });
|
||||
toast.success('Connection OK');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow(id: string) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
toast.info('Backing up — assembling the bundle, then pushing. This can take a minute.');
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${id}/run`, { method: 'POST' });
|
||||
toast.success('Backup pushed');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
await load();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('Delete this backup destination? This does not delete already-pushed backups.'))
|
||||
return;
|
||||
setBusyId(id);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${id}`, { method: 'DELETE' });
|
||||
toast.success('Destination deleted');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<UploadCloud className="h-4 w-4" aria-hidden />
|
||||
Automated backup destinations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Where scheduled backups are pushed. Each destination receives the same full bundle
|
||||
(database + every file) you can download above.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setEditing('new')}>
|
||||
<Plus className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Add destination
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="backup-schedule" className="text-sm">
|
||||
Schedule
|
||||
</Label>
|
||||
<Select value={schedule} onValueChange={(v) => void changeSchedule(v as Schedule)}>
|
||||
<SelectTrigger id="backup-schedule" className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
<SelectItem value="daily">Daily (02:00)</SelectItem>
|
||||
<SelectItem value="weekly">Weekly (Sun 02:00)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Pushes to every enabled destination below.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> Loading…
|
||||
</div>
|
||||
) : destinations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No destinations yet. Add one to enable automated offsite backups.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{destinations.map((d) => {
|
||||
const Icon = TYPE_META[d.type].icon;
|
||||
return (
|
||||
<li key={d.id} className="flex items-center justify-between gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium truncate">{d.name}</span>
|
||||
{d.encryptBundle && (
|
||||
<ShieldCheck
|
||||
className="h-3.5 w-3.5 text-emerald-600"
|
||||
aria-label="Encrypted"
|
||||
/>
|
||||
)}
|
||||
{!d.enabled && (
|
||||
<span className="text-xs rounded-full bg-muted px-1.5 py-0.5 text-muted-foreground">
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{TYPE_META[d.type].label}
|
||||
{d.lastStatus && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className={STATUS_TONE[d.lastStatus] ?? ''}>
|
||||
{d.lastStatus === 'ok' ? 'last OK' : 'last FAILED'}
|
||||
</span>
|
||||
{d.lastRunAt && ` ${new Date(d.lastRunAt).toLocaleString()}`}
|
||||
{d.lastStatus === 'ok' && ` (${formatBytes(d.lastBackupBytes)})`}
|
||||
</>
|
||||
)}
|
||||
{d.lastStatus === 'failed' && d.lastError && (
|
||||
<span className="block text-rose-700 truncate">{d.lastError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busyId === d.id}
|
||||
onClick={() => void test(d.id)}
|
||||
>
|
||||
<PlugZap className="me-1 h-3.5 w-3.5" aria-hidden />
|
||||
Test
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busyId === d.id}
|
||||
onClick={() => void runNow(d.id)}
|
||||
>
|
||||
{busyId === d.id ? (
|
||||
<Loader2 className="me-1 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<UploadCloud className="me-1 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
Back up now
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => setEditing(d)}>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busyId === d.id}
|
||||
onClick={() => void remove(d.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-rose-600" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{editing && (
|
||||
<DestinationDialog
|
||||
destination={editing === 'new' ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── add/edit dialog ─────────────────────────────────────────────────────────
|
||||
|
||||
interface DialogProps {
|
||||
destination: Destination | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function DestinationDialog({ destination, onClose, onSaved }: DialogProps) {
|
||||
const isEdit = Boolean(destination);
|
||||
const cfg = (destination?.config ?? {}) as Record<string, unknown>;
|
||||
const str = (k: string) => (typeof cfg[k] === 'string' ? (cfg[k] as string) : '');
|
||||
const num = (k: string) => (typeof cfg[k] === 'number' ? String(cfg[k]) : '');
|
||||
|
||||
const [name, setName] = useState(destination?.name ?? '');
|
||||
const [type, setType] = useState<DestType>(destination?.type ?? 'sftp');
|
||||
const [enabled, setEnabled] = useState(destination?.enabled ?? true);
|
||||
const [retention, setRetention] = useState(
|
||||
destination?.retentionCount != null ? String(destination.retentionCount) : '',
|
||||
);
|
||||
const [encryptBundle, setEncryptBundle] = useState(destination?.encryptBundle ?? false);
|
||||
const [encryptionKey, setEncryptionKey] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Config fields (controlled). Secrets start blank on edit (kept server-side).
|
||||
const [c, setC] = useState<Record<string, string>>({
|
||||
directory: str('directory'),
|
||||
host: str('host'),
|
||||
port: num('port'),
|
||||
username: str('username'),
|
||||
password: '',
|
||||
privateKey: '',
|
||||
passphrase: '',
|
||||
remoteDir: str('remoteDir'),
|
||||
hostFingerprint: str('hostFingerprint'),
|
||||
endpoint: str('endpoint'),
|
||||
region: str('region'),
|
||||
bucket: str('bucket'),
|
||||
accessKey: str('accessKey'),
|
||||
secretKey: '',
|
||||
prefix: str('prefix'),
|
||||
});
|
||||
const set = (k: string, v: string) => setC((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
function buildConfig(): Record<string, unknown> {
|
||||
if (type === 'filesystem') return { directory: c.directory };
|
||||
if (type === 'sftp') {
|
||||
return {
|
||||
host: c.host,
|
||||
...(c.port ? { port: Number(c.port) } : {}),
|
||||
username: c.username,
|
||||
...(c.password ? { password: c.password } : {}),
|
||||
...(c.privateKey ? { privateKey: c.privateKey } : {}),
|
||||
...(c.passphrase ? { passphrase: c.passphrase } : {}),
|
||||
remoteDir: c.remoteDir,
|
||||
...(c.hostFingerprint ? { hostFingerprint: c.hostFingerprint } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
endpoint: c.endpoint,
|
||||
...(c.region ? { region: c.region } : {}),
|
||||
bucket: c.bucket,
|
||||
accessKey: c.accessKey,
|
||||
...(c.secretKey ? { secretKey: c.secretKey } : {}),
|
||||
...(c.prefix ? { prefix: c.prefix } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
config: buildConfig(),
|
||||
retentionCount: retention ? Number(retention) : null,
|
||||
encryptBundle,
|
||||
...(encryptionKey ? { encryptionKey } : {}),
|
||||
};
|
||||
if (isEdit && destination) {
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${destination.id}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/admin/backup/destinations', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
toast.success(isEdit ? 'Destination updated' : 'Destination added');
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const secretPlaceholder = isEdit ? 'unchanged — leave blank to keep' : '';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit destination' : 'Add backup destination'}</DialogTitle>
|
||||
<DialogDescription>{TYPE_META[type].hint}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field label="Name">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Hetzner box"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Type">
|
||||
<Select value={type} onValueChange={(v) => setType(v as DestType)} disabled={isEdit}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sftp">SFTP / SSH server</SelectItem>
|
||||
<SelectItem value="s3">S3-compatible</SelectItem>
|
||||
<SelectItem value="filesystem">Mounted path / NAS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{type === 'filesystem' && (
|
||||
<Field label="Directory">
|
||||
<Input
|
||||
value={c.directory}
|
||||
onChange={(e) => set('directory', e.target.value)}
|
||||
placeholder="/mnt/backups"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === 'sftp' && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2">
|
||||
<Field label="Host">
|
||||
<Input
|
||||
value={c.host}
|
||||
onChange={(e) => set('host', e.target.value)}
|
||||
placeholder="backups.example.com"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Port">
|
||||
<Input
|
||||
value={c.port}
|
||||
onChange={(e) => set('port', e.target.value)}
|
||||
placeholder="22"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Username">
|
||||
<Input value={c.username} onChange={(e) => set('username', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
<Input
|
||||
type="password"
|
||||
value={c.password}
|
||||
onChange={(e) => set('password', e.target.value)}
|
||||
placeholder={secretPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Private key (optional, instead of password)">
|
||||
<textarea
|
||||
className="w-full rounded-md border px-2 py-1.5 text-xs font-mono"
|
||||
rows={3}
|
||||
value={c.privateKey}
|
||||
onChange={(e) => set('privateKey', e.target.value)}
|
||||
placeholder={secretPlaceholder || '-----BEGIN OPENSSH PRIVATE KEY-----'}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Remote directory">
|
||||
<Input
|
||||
value={c.remoteDir}
|
||||
onChange={(e) => set('remoteDir', e.target.value)}
|
||||
placeholder="/srv/pn-crm-backups"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Host key fingerprint (optional, sha256 — pins the server)">
|
||||
<Input
|
||||
value={c.hostFingerprint}
|
||||
onChange={(e) => set('hostFingerprint', e.target.value)}
|
||||
placeholder="aa:bb:cc… or hex"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 's3' && (
|
||||
<>
|
||||
<Field label="Endpoint">
|
||||
<Input
|
||||
value={c.endpoint}
|
||||
onChange={(e) => set('endpoint', e.target.value)}
|
||||
placeholder="https://s3.us-west.example.com"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Bucket">
|
||||
<Input value={c.bucket} onChange={(e) => set('bucket', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Region (optional)">
|
||||
<Input
|
||||
value={c.region}
|
||||
onChange={(e) => set('region', e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Access key">
|
||||
<Input value={c.accessKey} onChange={(e) => set('accessKey', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Secret key">
|
||||
<Input
|
||||
type="password"
|
||||
value={c.secretKey}
|
||||
onChange={(e) => set('secretKey', e.target.value)}
|
||||
placeholder={secretPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Prefix (optional)">
|
||||
<Input
|
||||
value={c.prefix}
|
||||
onChange={(e) => set('prefix', e.target.value)}
|
||||
placeholder="crm-backups/"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Keep last N (blank = all)">
|
||||
<Input
|
||||
value={retention}
|
||||
onChange={(e) => setRetention(e.target.value)}
|
||||
placeholder="7"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
Enabled (auto-pushed)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={encryptBundle} onCheckedChange={setEncryptBundle} />
|
||||
Encrypt the bundle before sending (AES-256)
|
||||
</label>
|
||||
{encryptBundle && (
|
||||
<Field label="Passphrase (needed to restore — store it safely)">
|
||||
<Input
|
||||
type="password"
|
||||
value={encryptionKey}
|
||||
onChange={(e) => setEncryptionKey(e.target.value)}
|
||||
placeholder={
|
||||
destination?.encryptionKeyIsSet ? 'unchanged — leave blank to keep' : ''
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={saving || !name}>
|
||||
{saving && <Loader2 className="me-1.5 h-4 w-4 animate-spin" aria-hidden />}
|
||||
{isEdit ? 'Save changes' : 'Add destination'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user