/** * 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; /** * 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 = ({ 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(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 (
{t('cloudSync.localBackups.unavailableTitle')}
{t('cloudSync.localBackups.unavailableDesc')}
); } return (
{t('cloudSync.localBackups.retentionTitle')}
{t('cloudSync.localBackups.retentionDesc')}
setMaxBackupsInput(e.target.value)} className="w-28" />
{!restoreAllowed && (
{t('cloudSync.localBackups.lockedTitle')}
{t('cloudSync.localBackups.lockedDesc')}
)}
{t('cloudSync.localBackups.title')}
{t('cloudSync.localBackups.desc')}
{backups.length === 0 ? (
{t('cloudSync.localBackups.empty')}
) : (
{backups.map((backup) => (
{backup.syncDataVersion ? `v${backup.syncDataVersion}` : formatTimestamp(backup.createdAt)}
{getReasonLabel(backup.reason)} {backup.syncDataVersion && ( <> {formatTimestamp(backup.createdAt)} )} {backup.sourceAppVersion && backup.targetAppVersion && ( <> {t('cloudSync.localBackups.versionChange', { from: backup.sourceAppVersion, to: backup.targetAppVersion, })} )}
{t('cloudSync.localBackups.counts', { hosts: String(backup.preview.hostCount), keys: String(backup.preview.keyCount), snippets: String(backup.preview.snippetCount), notes: String(backup.preview.noteCount ?? 0), })}
{restoreAllowed && ( )}
))}
)}
{/* Restore confirmation dialog (I2). Keeps the destructive action gated behind an explicit second click, mirroring the clear-local dialog elsewhere in this screen. */} { if (!open) setPendingRestoreBackup(null); }} > {t('cloudSync.localBackups.restoreConfirmTitle')} {t('cloudSync.localBackups.restoreConfirmDesc')} {pendingRestoreBackup && (
{pendingRestoreBackup.syncDataVersion ? `v${pendingRestoreBackup.syncDataVersion}` : formatTimestamp(pendingRestoreBackup.createdAt)}
{getReasonLabel(pendingRestoreBackup.reason)} {pendingRestoreBackup.syncDataVersion && ( <> {formatTimestamp(pendingRestoreBackup.createdAt)} )} {pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && ( <> {t('cloudSync.localBackups.versionChange', { from: pendingRestoreBackup.sourceAppVersion, to: pendingRestoreBackup.targetAppVersion, })} )}
{t('cloudSync.localBackups.counts', { hosts: String(pendingRestoreBackup.preview.hostCount), keys: String(pendingRestoreBackup.preview.keyCount), snippets: String(pendingRestoreBackup.preview.snippetCount), notes: String(pendingRestoreBackup.preview.noteCount ?? 0), })}
)}
); };