Files
Netcatty/components/cloud-sync/CloudSyncLocalBackupsPanel.tsx

445 lines
22 KiB
TypeScript

/**
* CloudSyncSettings - End-to-End Encrypted Cloud Sync UI
*
* Handles:
* - Master key setup (gatekeeper screen)
* - Provider connections (GitHub, Google, OneDrive)
* - Sync status and conflict resolution
*/
import React, { useState, useEffect } from 'react';
import {
AlertTriangle,
Download,
FolderOpen,
Loader2,
RefreshCw,
} from 'lucide-react';
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
import {
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
withRestoreBarrier,
} from '../../application/localVaultBackups';
import { useI18n } from '../../application/i18n/I18nProvider';
import { type SyncPayload } from '../../domain/sync';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import { toast } from '../ui/toast';
// ============================================================================
interface LocalBackupsPanelProps {
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
/**
* When true, the panel hides the Restore button entirely — e.g. while the
* master key has not been configured yet, a restore would land credentials
* on disk in plaintext (I3). Listing is still allowed so users can see that
* their history exists.
*/
restoreDisabledReason?: 'no-master-key' | null;
}
export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
onApplyPayload,
restoreDisabledReason = null,
}) => {
const { t, resolvedLocale } = useI18n();
const {
backups,
isLoading,
maxBackups,
encryptionAvailable,
refreshBackups,
readBackup,
setMaxBackups,
openBackupDirectory,
} = useLocalVaultBackups();
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
// Backup chosen in the list but not yet confirmed. A two-step flow keeps
// users from wiping their vault with a single accidental click (I2).
const [pendingRestoreBackup, setPendingRestoreBackup] = useState<
(typeof backups)[number] | null
>(null);
useEffect(() => {
setMaxBackupsInput(String(maxBackups));
}, [maxBackups]);
const formatTimestamp = (timestamp: number) =>
new Date(timestamp).toLocaleString(resolvedLocale || undefined);
const getReasonLabel = (reason: 'app_version_change' | 'before_restore') =>
reason === 'app_version_change'
? t('cloudSync.localBackups.reason.appVersionChange')
: t('cloudSync.localBackups.reason.beforeRestore');
const handleSaveMaxBackups = async () => {
// Validate BEFORE calling setMaxBackups, which hands off to the
// renderer's `sanitizeLocalVaultBackupMaxCount` clamp. Two failure
// modes must be surfaced rather than silently clamped, because
// both produce a misleading "saved" toast:
//
// 1. Empty / non-numeric input — `Number("")` coerces to 0 and
// sanitize clamps to the default (20). A user who meant to
// clear the field then re-type would see their retention
// silently reset to 20 with a success message.
//
// 2. Out-of-range input (e.g. 500) — sanitize clamps to 100 and
// still reports success, but the visible error string says
// "between 1 and 100", so the user has no idea their value
// was changed. Reject explicitly instead.
//
// The 1..MAX range check mirrors the main-process `sanitizeMaxCount`
// in vaultBackupBridge.cjs so renderer and bridge agree.
const parsed = Number(maxBackupsInput);
const inRange =
Number.isFinite(parsed) &&
parsed >= MIN_LOCAL_VAULT_BACKUP_MAX_COUNT &&
parsed <= MAX_LOCAL_VAULT_BACKUP_MAX_COUNT;
if (!inRange || maxBackupsInput.trim() === '') {
toast.error(
t('cloudSync.localBackups.maxInvalid'),
t('sync.toast.errorTitle'),
);
return;
}
setIsSavingMaxBackups(true);
try {
const next = await setMaxBackups(parsed);
setMaxBackupsInput(String(next));
toast.success(t('cloudSync.localBackups.maxSaved', { count: String(next) }));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.toast.errorTitle'),
);
} finally {
setIsSavingMaxBackups(false);
}
};
const handleOpenBackupDirectory = async () => {
try {
await openBackupDirectory();
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.toast.errorTitle'),
);
}
};
const performRestore = async (backupId: string) => {
setRestoringBackupId(backupId);
try {
// Hold the cross-window restore barrier around both the load
// and the apply so another window's auto-sync cannot push a
// pre-restore snapshot concurrently. See `withRestoreBarrier`
// in application/localVaultBackups.ts for the read-side in
// useAutoSync.
//
// In-memory React state refresh is implicit: `onApplyPayload`
// (supplied by the hosting screen) routes through
// `applySyncPayload` → `importDataFromString` → store writes
// → the hook-store listeners in `useVaultState` /
// `useCustomThemes` / etc. We do NOT explicitly re-pull host
// lists here because a future refactor that decouples those
// stores from the apply path would silently break the UI
// refresh in a way that's only visible after a manual
// restart. Any change to that chain must either preserve
// store-listener notification OR add an explicit
// `rehydrateAllFromStorage` call here — do not assume
// restore is "just" a payload swap.
await withRestoreBarrier(async () => {
const detail = await readBackup(backupId);
if (!detail) {
throw new Error(t('cloudSync.localBackups.restoreMissing'));
}
await Promise.resolve(onApplyPayload(detail.payload));
});
await refreshBackups();
toast.success(t('cloudSync.localBackups.restoreSuccess'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.localBackups.restoreFailedTitle'),
);
} finally {
setRestoringBackupId(null);
}
};
const restoreAllowed = restoreDisabledReason === null;
// While encryptionAvailable is still `null` we're mid-probe — render the
// restore button as disabled so the user never sees a path they can't
// actually take (I1 surface). Once resolved, `false` hides the panel body
// via the unavailable banner below.
const encryptionResolved = encryptionAvailable !== null;
const encryptionUsable = encryptionAvailable === true;
// safeStorage probe finished and returned "not available" → disable the
// panel entirely; the main process refuses to write in this state (I1).
if (encryptionResolved && !encryptionUsable) {
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle size={16} />
<span className="text-sm font-medium">
{t('cloudSync.localBackups.unavailableTitle')}
</span>
</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.unavailableDesc')}
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="max-w-lg">
<div className="text-sm font-medium">{t('cloudSync.localBackups.retentionTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.retentionDesc')}
</div>
</div>
<div className="space-y-2 md:min-w-[260px] md:shrink-0">
<div className="flex items-end gap-2 md:justify-end">
<Input
type="number"
min={1}
max={100}
value={maxBackupsInput}
onChange={(e) => setMaxBackupsInput(e.target.value)}
className="w-28"
/>
<Button
variant="outline"
onClick={() => void handleSaveMaxBackups()}
disabled={isSavingMaxBackups}
className="gap-2"
>
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
{t('common.save')}
</Button>
</div>
</div>
</div>
</div>
{!restoreAllowed && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
<AlertTriangle size={14} />
<span className="font-medium">
{t('cloudSync.localBackups.lockedTitle')}
</span>
</div>
{t('cloudSync.localBackups.lockedDesc')}
</div>
)}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.desc')}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => void refreshBackups()}
disabled={isLoading}
className="gap-1"
>
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
{t('settings.system.refresh')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void handleOpenBackupDirectory()}
className="gap-1"
>
<FolderOpen size={14} />
{t('settings.system.openFolder')}
</Button>
</div>
</div>
{backups.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
{t('cloudSync.localBackups.empty')}
</div>
) : (
<div className="space-y-2">
{backups.map((backup) => (
<div
key={backup.id}
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{backup.syncDataVersion
? `v${backup.syncDataVersion}`
: formatTimestamp(backup.createdAt)}
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(backup.reason)}</span>
{backup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(backup.createdAt)}</span>
</>
)}
{backup.sourceAppVersion && backup.targetAppVersion && (
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: backup.sourceAppVersion,
to: backup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.counts', {
hosts: String(backup.preview.hostCount),
keys: String(backup.preview.keyCount),
snippets: String(backup.preview.snippetCount),
})}
</div>
</div>
{restoreAllowed && (
<Button
size="sm"
variant="outline"
onClick={() => setPendingRestoreBackup(backup)}
// Disable every row while ANY restore is in
// flight. Each restore runs a full
// `applyProtectedSyncPayload` — multiple
// localStorage writes + the apply-in-progress
// sentinel. `withRestoreBarrier` serializes
// across windows but does NOT serialize
// same-window re-entry, so two overlapping
// clicks here would interleave destructive
// writes and the second run's sentinel-clear
// could mask a still-partial first apply.
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId === backup.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t('cloudSync.localBackups.restore')}
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Restore confirmation dialog (I2). Keeps the destructive action
gated behind an explicit second click, mirroring the clear-local
dialog elsewhere in this screen. */}
<Dialog
open={pendingRestoreBackup !== null}
onOpenChange={(open) => {
if (!open) setPendingRestoreBackup(null);
}}
>
<DialogContent className="sm:max-w-[440px] z-[70]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
{t('cloudSync.localBackups.restoreConfirmTitle')}
</DialogTitle>
<DialogDescription>
{t('cloudSync.localBackups.restoreConfirmDesc')}
</DialogDescription>
</DialogHeader>
{pendingRestoreBackup && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
<div className="font-medium">
{pendingRestoreBackup.syncDataVersion
? `v${pendingRestoreBackup.syncDataVersion}`
: formatTimestamp(pendingRestoreBackup.createdAt)}
</div>
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
{pendingRestoreBackup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
</>
)}
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: pendingRestoreBackup.sourceAppVersion,
to: pendingRestoreBackup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-muted-foreground">
{t('cloudSync.localBackups.counts', {
hosts: String(pendingRestoreBackup.preview.hostCount),
keys: String(pendingRestoreBackup.preview.keyCount),
snippets: String(pendingRestoreBackup.preview.snippetCount),
})}
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setPendingRestoreBackup(null)}
disabled={restoringBackupId !== null}
>
{t('cloudSync.localBackups.restoreConfirmCancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
const target = pendingRestoreBackup;
if (!target) return;
setPendingRestoreBackup(null);
await performRestore(target.id);
}}
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId !== null ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t('cloudSync.localBackups.restoreConfirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};