/** * 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, useCallback, useEffect, useRef } from 'react'; import { Key, ShieldCheck, } from 'lucide-react'; import { useCloudSync } from '../application/state/useCloudSync'; import { withRestoreBarrier, } from '../application/localVaultBackups'; import { useI18n } from '../application/i18n/I18nProvider'; import { useOptionalSettingsFocus } from './settings/SettingsFocusContext'; import { findSyncPayloadEncryptedCredentialPaths, stripSyncPayloadEncryptedCredentials, } from '../domain/credentials'; import { isProviderReadyForSync, type CloudProvider, type ConvergentMigrationPreview, type SyncPayload, type SyncResult, type WebDAVAuthType, type WebDAVConfig, type S3Config, } from '../domain/sync'; import { initializePreparedConvergentMigration, prepareConvergentSyncMigration, type PreparedConvergentMigration, } from '../application/convergentSyncMigration'; import type { ShrinkFinding } from '../domain/syncGuards'; import { SyncBlockedBanner } from './sync/SyncBlockedBanner'; import { Button } from './ui/button'; import { toast } from './ui/toast'; // ============================================================================ import { GatekeeperScreen, StatusDot } from './cloud-sync/CloudSyncControls'; import { LocalBackupsPanel } from './cloud-sync/CloudSyncLocalBackupsPanel'; import { CloudSyncDialogs } from './cloud-sync/CloudSyncDialogs'; import { CloudSyncDashboardTabs } from './cloud-sync/CloudSyncDashboardTabs'; interface SyncDashboardProps { onBuildPayload: () => SyncPayload | Promise; onBuildLocalPayload: () => SyncPayload; onApplyMigrationPayload: (payload: SyncPayload) => void | Promise; onApplyPayload: (payload: SyncPayload) => void | Promise; onApplyConvergentPayload: ( payload: SyncPayload, commitReplica: () => Promise, ) => Promise; onApplyLocalPayload?: (payload: SyncPayload) => void | Promise; onClearLocalData?: () => void; } const SyncDashboard: React.FC = ({ onBuildPayload, onBuildLocalPayload, onApplyMigrationPayload, onApplyPayload, onApplyConvergentPayload, onApplyLocalPayload, onClearLocalData, }) => { const { t, resolvedLocale } = useI18n(); const sync = useCloudSync(); const normalizeEndpoint = (value: string): string => { const trimmed = value.trim(); if (!trimmed) return trimmed; if (!/^https?:\/\//i.test(trimmed)) { return `https://${trimmed}`; } return trimmed; }; const buildErrorDetails = ( error: unknown, context: Record, ): string | null => { const lines: string[] = []; Object.entries(context).forEach(([key, value]) => { if (value === undefined || value === null || value === '') return; lines.push(`${key}: ${value}`); }); if (error instanceof Error) { const err = error as Error & { cause?: unknown; code?: unknown; status?: unknown; statusText?: unknown; }; if (err.code) lines.push(`code: ${String(err.code)}`); if (err.status) lines.push(`status: ${String(err.status)}`); if (err.statusText) lines.push(`statusText: ${String(err.statusText)}`); if (err.cause) { if (typeof err.cause === 'object') { try { lines.push(`cause: ${JSON.stringify(err.cause, null, 2)}`); } catch { lines.push(`cause: ${String(err.cause)}`); } } else { lines.push(`cause: ${String(err.cause)}`); } } if (!lines.length && err.stack) lines.push(err.stack); } else if (error) { lines.push(`error: ${String(error)}`); } return lines.length ? lines.join('\n') : null; }; const getNetworkErrorMessage = (error: unknown, fallback: string): string => { if (!(error instanceof Error)) return fallback; const message = error.message || fallback; if (message.includes('UND_ERR_CONNECT_TIMEOUT') || message.includes('Connect Timeout')) { return t('cloudSync.connect.github.timeout'); } if (message.toLowerCase().includes('fetch failed')) { return t('cloudSync.connect.github.networkError'); } return message; }; const disconnectOtherProviders = async (current: CloudProvider) => { if (sync.pendingBrowserAuthProvider && sync.pendingBrowserAuthProvider !== current) { toast.info(t('cloudSync.connect.browserCancelled')); } sync.cancelOAuthConnect(); if (sync.convergentSyncConfig.initialized) return; const providers = new Set(['github', 'google', 'onedrive', 'webdav', 's3']); for (const id of Object.keys(sync.providers)) { providers.add(id as CloudProvider); } for (const provider of providers) { if (provider === current) continue; const conn = sync.providers[provider]; if (conn && isProviderReadyForSync(conn)) { await sync.disconnectProvider(provider); } } }; // GitHub Device Flow state const [showGitHubModal, setShowGitHubModal] = useState(false); const [gitHubUserCode, setGitHubUserCode] = useState(''); const [gitHubVerificationUri, setGitHubVerificationUri] = useState(''); const [isPollingGitHub, setIsPollingGitHub] = useState(false); const activeGitHubAttemptIdRef = useRef(null); // Conflict modal const [showConflictModal, setShowConflictModal] = useState(false); // Gist revision history (#679) const [showHistoryModal, setShowHistoryModal] = useState(false); const [historyRevisions, setHistoryRevisions] = useState>([]); const [historyLoading, setHistoryLoading] = useState(false); const [historyPreview, setHistoryPreview] = useState<{ sha: string; payload: SyncPayload; preview: { hostCount: number; keyCount: number; snippetCount: number; noteCount: number; identityCount: number; portForwardingRuleCount: number }; deviceName?: string; version?: number; } | null>(null); const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false); const [historyError, setHistoryError] = useState(null); const [pendingConnectProvider, setPendingConnectProvider] = useState(null); const pendingConnectProviderRef = useRef(null); const hasConnectingProvider = (Object.values(sync.providers) as Array<{ status: string }>).some( (provider) => provider.status === 'connecting' ); const isConnectDisabled = (provider: CloudProvider): boolean => { const connection = sync.providers[provider]; if (pendingConnectProvider && pendingConnectProvider !== provider) { return true; } if (pendingConnectProvider === provider) { return true; } if (hasConnectingProvider && connection?.status !== 'connecting') { return true; } // Never-connected plugin providers still honor the single-provider gate. if (!connection) { return !sync.convergentSyncConfig.initialized && sync.hasAnyConnectedProvider; } return !sync.convergentSyncConfig.initialized && sync.hasAnyConnectedProvider && !isProviderReadyForSync(connection); }; const beginPendingConnect = (provider: CloudProvider): boolean => { if (pendingConnectProviderRef.current) { return false; } pendingConnectProviderRef.current = provider; setPendingConnectProvider(provider); return true; }; const endPendingConnect = (provider: CloudProvider) => { if (pendingConnectProviderRef.current !== provider) return; pendingConnectProviderRef.current = null; setPendingConnectProvider((current) => (current === provider ? null : current)); }; // Change master key dialog const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false); const [currentMasterKey, setCurrentMasterKey] = useState(''); const [newMasterKey, setNewMasterKey] = useState(''); const [confirmNewMasterKey, setConfirmNewMasterKey] = useState(''); const [showMasterKey, setShowMasterKey] = useState(false); const [isChangingKey, setIsChangingKey] = useState(false); const [changeKeyError, setChangeKeyError] = useState(null); // One-time unlock prompt (for existing users before password is persisted) const [showUnlockDialog, setShowUnlockDialog] = useState(false); const [unlockMasterKey, setUnlockMasterKey] = useState(''); const [showUnlockMasterKey, setShowUnlockMasterKey] = useState(false); const [isUnlocking, setIsUnlocking] = useState(false); const [unlockError, setUnlockError] = useState(null); // WebDAV dialog state const [showWebdavDialog, setShowWebdavDialog] = useState(false); const [webdavEndpoint, setWebdavEndpoint] = useState(''); const [webdavAuthType, setWebdavAuthType] = useState('basic'); const [webdavUsername, setWebdavUsername] = useState(''); const [webdavPassword, setWebdavPassword] = useState(''); const [webdavToken, setWebdavToken] = useState(''); const [showWebdavSecret, setShowWebdavSecret] = useState(false); const [webdavAllowInsecure, setWebdavAllowInsecure] = useState(false); const [webdavError, setWebdavError] = useState(null); const [webdavErrorDetail, setWebdavErrorDetail] = useState(null); const [isSavingWebdav, setIsSavingWebdav] = useState(false); // S3 dialog state const [showS3Dialog, setShowS3Dialog] = useState(false); const [s3Endpoint, setS3Endpoint] = useState(''); const [s3Region, setS3Region] = useState(''); const [s3Bucket, setS3Bucket] = useState(''); const [s3AccessKeyId, setS3AccessKeyId] = useState(''); const [s3SecretAccessKey, setS3SecretAccessKey] = useState(''); const [s3SessionToken, setS3SessionToken] = useState(''); const [s3Prefix, setS3Prefix] = useState(''); const [s3ForcePathStyle, setS3ForcePathStyle] = useState(true); const [s3AllowInsecure, setS3AllowInsecure] = useState(false); const [showS3Secret, setShowS3Secret] = useState(false); const [s3Error, setS3Error] = useState(null); const [s3ErrorDetail, setS3ErrorDetail] = useState(null); const [isSavingS3, setIsSavingS3] = useState(false); // Clear local data dialog const [showClearLocalDialog, setShowClearLocalDialog] = useState(false); // Sync-blocked banner (Task 7) + force-push confirmation modal (Task 8) const [blockedFinding, setBlockedFinding] = useState | null>(null); const [showForcePushConfirm, setShowForcePushConfirm] = useState(false); // Ref for scrolling to LocalBackupsPanel when the banner's Restore button is clicked const localBackupsRef = useRef(null); // Active tab state — lets the banner's "Restore" button switch to the // local-backups tab without a separate DOM query. const [activeTab, setActiveTab] = useState<'providers' | 'status'>('providers'); const settingsFocus = useOptionalSettingsFocus(); useEffect(() => { const request = settingsFocus?.request; if (request?.tab !== 'sync' || !request.syncSubTab) return; if (request.syncSubTab === 'providers' || request.syncSubTab === 'status') { setActiveTab(request.syncSubTab); } }, [settingsFocus?.request]); const [preparedConvergentMigration, setPreparedConvergentMigration] = useState(null); const [convergentPreview, setConvergentPreview] = useState(null); const [convergentBusy, setConvergentBusy] = useState(false); const [convergentError, setConvergentError] = useState(null); const ensureSyncablePayload = useCallback( (payload: SyncPayload): boolean => { const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload); if (encryptedCredentialPaths.length === 0) return true; toast.error(t('sync.credentialsUnavailable'), t('sync.toast.errorTitle')); return false; }, [t], ); const handleToggleConvergent = useCallback(async (enabled: boolean) => { setConvergentError(null); if (!enabled) { sync.setConvergentSyncEnabled(false); setPreparedConvergentMigration(null); setConvergentPreview(null); return; } if (sync.convergentSyncConfig.initialized) { sync.setConvergentSyncEnabled(true); return; } setConvergentBusy(true); setActiveTab('status'); try { const localPayload = await onBuildPayload(); if (!ensureSyncablePayload(localPayload)) return; const prepared = await prepareConvergentSyncMigration(localPayload); setPreparedConvergentMigration(prepared); setConvergentPreview(prepared.plan.preview); } catch (error) { setConvergentError(error instanceof Error ? error.message : t('common.unknownError')); } finally { setConvergentBusy(false); } }, [ensureSyncablePayload, onBuildPayload, sync, t]); const handleConfirmConvergentMigration = useCallback(async () => { if (!preparedConvergentMigration) return; setConvergentBusy(true); setConvergentError(null); try { await initializePreparedConvergentMigration({ prepared: preparedConvergentMigration, buildCurrentPayload: onBuildPayload, buildPreApplyPayload: onBuildLocalPayload, applyPayload: onApplyMigrationPayload, translateProtectiveBackupFailure: (message) => t('cloudSync.localBackups.protectiveBackupFailed', { message }), }); sync.refreshConvergentSyncConfig(); setPreparedConvergentMigration(null); setConvergentPreview(null); toast.success(t('cloudSync.convergent.enabled')); } catch (error) { setConvergentError(error instanceof Error ? error.message : t('common.unknownError')); } finally { setConvergentBusy(false); } }, [onApplyMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]); const handleResolveConvergentConflict = useCallback(async ( addressKey: string, candidateDot: string, ) => { setConvergentBusy(true); setConvergentError(null); try { const { results } = await sync.resolveConvergentConflict( addressKey, candidateDot, onApplyConvergentPayload, ); const failed = [...results.values()].find((result) => !result.success); if (failed) throw new Error(failed.error || t('sync.autoSync.syncFailed')); toast.success(t('cloudSync.convergent.conflict.resolved')); } catch (error) { setConvergentError(error instanceof Error ? error.message : t('common.unknownError')); } finally { setConvergentBusy(false); } }, [onApplyConvergentPayload, sync, t]); const handleDowngradeConvergent = useCallback(async () => { if (!window.confirm(t('cloudSync.convergent.downgrade.confirm'))) return; setConvergentBusy(true); setConvergentError(null); try { const results = await sync.downgradeConvergentSync( true, async () => { const localPayload = await onBuildPayload(); if (!ensureSyncablePayload(localPayload)) { throw new Error(t('sync.credentialsUnavailable')); } return localPayload; }, onApplyConvergentPayload, ); const failed = [...results.values()].find((result) => !result.success); if (failed) throw new Error(failed.error || t('sync.autoSync.syncFailed')); sync.refreshConvergentSyncConfig(); setPreparedConvergentMigration(null); setConvergentPreview(null); toast.success(t('cloudSync.convergent.downgrade.done')); } catch (error) { setConvergentError(error instanceof Error ? error.message : t('common.unknownError')); } finally { setConvergentBusy(false); } }, [ensureSyncablePayload, onApplyConvergentPayload, onBuildPayload, sync, t]); // Handle conflict detection useEffect(() => { if (sync.currentConflict) { setShowConflictModal(true); } }, [sync.currentConflict]); // Subscribe to sync events to show/clear the blocked-shrink banner. // Destructure the stable useCallback reference so the effect runs once on // mount rather than re-subscribing on every render when `sync` object ref changes. const { subscribeToEvents, getShrinkBlockedFinding } = sync; // Hydrate from current manager state in case a shrink-block happened // before this component mounted (e.g., auto-sync ran while the user // was on a different tab). Without this, the banner only shows // blocks that occur after Settings is open. useEffect(() => { const existing = getShrinkBlockedFinding(); if (existing) { setBlockedFinding(existing); } }, [getShrinkBlockedFinding]); useEffect(() => { const unsub = subscribeToEvents((event) => { if (event.type === 'SYNC_BLOCKED_SHRINK') { if (event.finding.suspicious) { setBlockedFinding(event.finding); } } else if (event.type === 'SYNC_BLOCKED_CLEARED') { setBlockedFinding(null); } }); return unsub; }, [subscribeToEvents]); // If we have a master key but we're still locked (e.g. older installs), // prompt once and persist the password via safeStorage. useEffect(() => { if (sync.securityState !== 'LOCKED') { setShowUnlockDialog(false); return; } if (!sync.hasAnyConnectedProvider && !sync.autoSyncEnabled) { return; } const t = setTimeout(() => setShowUnlockDialog(true), 500); return () => clearTimeout(t); }, [sync.securityState, sync.hasAnyConnectedProvider, sync.autoSyncEnabled]); // Connect GitHub (disconnect others first - single provider only) const handleConnectGitHub = async () => { if (!beginPendingConnect('github')) return; const cancelController = new AbortController(); let authAttemptId: number | null = null; try { await disconnectOtherProviders('github'); const deviceFlow = await sync.connectGitHub(); authAttemptId = deviceFlow.authAttemptId ?? null; activeGitHubAttemptIdRef.current = authAttemptId; setGitHubUserCode(deviceFlow.userCode); setGitHubVerificationUri(deviceFlow.verificationUri); setShowGitHubModal(true); setIsPollingGitHub(true); await sync.completeGitHubAuth( deviceFlow.deviceCode, deviceFlow.interval, deviceFlow.expiresAt, () => { }, // onPending callback cancelController.signal, authAttemptId ?? undefined ); if (activeGitHubAttemptIdRef.current === authAttemptId) { activeGitHubAttemptIdRef.current = null; setIsPollingGitHub(false); setShowGitHubModal(false); } toast.success(t('cloudSync.connect.github.success')); } catch (error) { if (activeGitHubAttemptIdRef.current === authAttemptId) { activeGitHubAttemptIdRef.current = null; setIsPollingGitHub(false); setShowGitHubModal(false); } const message = getNetworkErrorMessage(error, t('common.unknownError')); if (!message.toLowerCase().includes('cancelled')) { toast.error(message, t('cloudSync.connect.github.failedTitle')); } } finally { cancelController.abort(); if (activeGitHubAttemptIdRef.current == null) { endPendingConnect('github'); } } }; // Connect Google (disconnect others first - single provider only) const handleConnectGoogle = async () => { if (!beginPendingConnect('google')) return; try { await disconnectOtherProviders('google'); await sync.connectGoogle(); // Note: Auth flow is handled automatically by oauthBridge toast.info(t('cloudSync.connect.browserContinue')); } catch (error) { const msg = error instanceof Error ? error.message : t('common.unknownError'); // Don't show toast for user-initiated cancellation (popup closed) if (!msg.includes('cancelled')) { toast.error(msg, t('cloudSync.connect.google.failedTitle')); } } finally { endPendingConnect('google'); } }; // Connect OneDrive (disconnect others first - single provider only) const handleConnectOneDrive = async () => { if (!beginPendingConnect('onedrive')) return; try { await disconnectOtherProviders('onedrive'); await sync.connectOneDrive(); // Note: Auth flow is handled automatically by oauthBridge toast.info(t('cloudSync.connect.browserContinue')); } catch (error) { const msg = error instanceof Error ? error.message : t('common.unknownError'); // Don't show toast for user-initiated cancellation (popup closed) if (!msg.includes('cancelled')) { toast.error(msg, t('cloudSync.connect.onedrive.failedTitle')); } } finally { endPendingConnect('onedrive'); } }; const openWebdavDialog = () => { if (sync.pendingBrowserAuthProvider) { toast.info(t('cloudSync.connect.browserCancelled')); } sync.cancelOAuthConnect(); const config = sync.providers.webdav.config as WebDAVConfig | undefined; setWebdavEndpoint(config?.endpoint || ''); setWebdavAuthType(config?.authType || 'basic'); setWebdavUsername(config?.username || ''); setWebdavPassword(config?.password || ''); setWebdavToken(config?.token || ''); setWebdavAllowInsecure(config?.allowInsecure || false); setShowWebdavSecret(false); setWebdavError(null); setWebdavErrorDetail(null); setShowWebdavDialog(true); }; const openS3Dialog = () => { if (sync.pendingBrowserAuthProvider) { toast.info(t('cloudSync.connect.browserCancelled')); } sync.cancelOAuthConnect(); const config = sync.providers.s3.config as S3Config | undefined; setS3Endpoint(config?.endpoint || ''); setS3Region(config?.region || ''); setS3Bucket(config?.bucket || ''); setS3AccessKeyId(config?.accessKeyId || ''); setS3SecretAccessKey(config?.secretAccessKey || ''); setS3SessionToken(config?.sessionToken || ''); setS3Prefix(config?.prefix || ''); setS3ForcePathStyle(config?.forcePathStyle ?? true); setS3AllowInsecure(config?.allowInsecure || false); setShowS3Secret(false); setS3Error(null); setS3ErrorDetail(null); setShowS3Dialog(true); }; const handleSaveWebdav = async () => { const endpoint = normalizeEndpoint(webdavEndpoint); if (!endpoint) { setWebdavError(t('cloudSync.webdav.validation.endpoint')); setWebdavErrorDetail(null); return; } if (webdavAuthType === 'token') { if (!webdavToken.trim()) { setWebdavError(t('cloudSync.webdav.validation.token')); setWebdavErrorDetail(null); return; } } else { if (!webdavUsername.trim() || !webdavPassword) { setWebdavError(t('cloudSync.webdav.validation.credentials')); setWebdavErrorDetail(null); return; } } const config: WebDAVConfig = { endpoint, authType: webdavAuthType, username: webdavAuthType === 'token' ? undefined : webdavUsername.trim(), password: webdavAuthType === 'token' ? undefined : webdavPassword, token: webdavAuthType === 'token' ? webdavToken.trim() : undefined, allowInsecure: webdavAllowInsecure ? true : undefined, }; setIsSavingWebdav(true); setWebdavError(null); setWebdavErrorDetail(null); try { await disconnectOtherProviders('webdav'); await sync.connectWebDAV(config); toast.success(t('cloudSync.connect.webdav.success')); setShowWebdavDialog(false); } catch (error) { const message = error instanceof Error ? error.message : t('common.unknownError'); setWebdavError(message); setWebdavErrorDetail(buildErrorDetails(error, { endpoint, authType: webdavAuthType })); toast.error(message, t('cloudSync.connect.webdav.failedTitle')); } finally { setIsSavingWebdav(false); } }; const handleSaveS3 = async () => { const endpoint = normalizeEndpoint(s3Endpoint); if (!endpoint || !s3Region.trim() || !s3Bucket.trim() || !s3AccessKeyId.trim() || !s3SecretAccessKey) { setS3Error(t('cloudSync.s3.validation.required')); setS3ErrorDetail(null); return; } const config: S3Config = { endpoint, region: s3Region.trim(), bucket: s3Bucket.trim(), accessKeyId: s3AccessKeyId.trim(), secretAccessKey: s3SecretAccessKey, sessionToken: s3SessionToken.trim() ? s3SessionToken.trim() : undefined, prefix: s3Prefix.trim() ? s3Prefix.trim() : undefined, forcePathStyle: s3ForcePathStyle, allowInsecure: s3AllowInsecure ? true : undefined, }; setIsSavingS3(true); setS3Error(null); setS3ErrorDetail(null); try { await disconnectOtherProviders('s3'); await sync.connectS3(config); toast.success(t('cloudSync.connect.s3.success')); setShowS3Dialog(false); } catch (error) { const message = error instanceof Error ? error.message : t('common.unknownError'); setS3Error(message); setS3ErrorDetail( buildErrorDetails(error, { endpoint, region: s3Region.trim(), bucket: s3Bucket.trim(), forcePathStyle: s3ForcePathStyle, allowInsecure: s3AllowInsecure, }), ); toast.error(message, t('cloudSync.connect.s3.failedTitle')); } finally { setIsSavingS3(false); } }; // Sync to provider const handleSync = async (provider: CloudProvider) => { try { const payload = await onBuildPayload(); if (!ensureSyncablePayload(payload)) return; const result = await sync.syncToProvider(provider, payload, { applyConvergentPayload: onApplyConvergentPayload, }); // Convergent sync fans out to every provider. Even if the // requested provider fails, another provider can verify a newer // joined replica that must be applied before reporting the error. if (result.mergedPayload && !result.mergedPayloadApplied && onApplyPayload) { const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload); await Promise.resolve(onApplyPayload(portableMerged)); if (result.remoteFile) { await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, { recordDownload: true, }); } } if (result.success) { toast.success(t('cloudSync.sync.success', { provider })); } else if (result.conflictDetected) { // Conflict modal will show automatically } else { toast.error(result.error || t('cloudSync.sync.failed'), t('cloudSync.sync.failedTitle')); } } catch (error) { toast.error(error instanceof Error ? error.message : t('common.unknownError'), t('cloudSync.sync.errorTitle')); } }; // Resolve conflict const handleResolveConflict = async (resolution: 'USE_LOCAL' | 'USE_REMOTE') => { try { const remoteResult = await sync.resolveConflict(resolution); if (remoteResult && resolution === 'USE_REMOTE') { // USE_REMOTE applies cloud data over local — same data-loss // shape as a local backup restore, so gate auto-sync in // every other window the same way. const portableRemote = stripSyncPayloadEncryptedCredentials(remoteResult.payload); await withRestoreBarrier(async () => { await Promise.resolve(onApplyPayload(portableRemote)); }); await sync.commitRemoteInspection( remoteResult.provider, remoteResult.remoteFile, portableRemote, { recordDownload: true }, ); toast.success(t('cloudSync.resolve.downloaded')); } else if (resolution === 'USE_LOCAL') { // Re-sync with local data. Hold the same cross-window // restore barrier that USE_REMOTE uses: without it, a // concurrent auto-sync tick in another window can slip // between our conflict resolution and the upload, // producing a second upload path with stale state that // races against this push. USE_LOCAL doesn't mutate the // renderer's in-memory state (no onApplyPayload call), so // the barrier is belt-and-suspenders against the other // window's push, not ours. const localPayload = await onBuildPayload(); if (!ensureSyncablePayload(localPayload)) return; let results: Map | null = null; await withRestoreBarrier(async () => { // Explicit keep-local must not re-enter smart-merge: after a // reinstall with a new master password the cloud ciphertext // is undecryptable, and merge failure returns conflictDetected // without an error string (UI shows "unknown error"). Force // upload-local so we encrypt with the current password and // overwrite remote without reading the old backup. results = await sync.syncNow(localPayload, { overrideShrink: true, conflictActionOverride: 'upload-local', applyConvergentPayload: onApplyConvergentPayload, }); }); if (results) { // Apply any merged payload BEFORE closing the modal so local state // reflects what's now on cloud (in case remote changed during the merge). for (const result of (results as Map).values()) { if (result.mergedPayload && !result.mergedPayloadApplied) { const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload); await Promise.resolve(onApplyPayload(portableMerged)); if (result.remoteFile) { await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, { recordDownload: true, }); } break; } } const allOk = Array.from((results as Map).values()).every((r) => r.success); if (!allOk) { const firstError = Array.from((results as Map).values()) .find((r) => !r.success)?.error ?? t('common.unknownError'); toast.error(firstError, t('cloudSync.resolve.failedTitle')); return; // KEEP the modal open so user can retry / pick USE_REMOTE } } toast.success(t('cloudSync.resolve.uploaded')); } setShowConflictModal(false); } catch (error) { toast.error( error instanceof Error ? error.message : t('common.unknownError'), t('cloudSync.resolve.failedTitle'), ); } }; // -- Gist revision history handlers -- const handleOpenHistory = async () => { setShowHistoryModal(true); setHistoryLoading(true); setHistoryError(null); setHistoryPreview(null); setHistoryRevisions([]); try { const revisions = await sync.getGistRevisionHistory(); setHistoryRevisions(revisions); } catch (err) { setHistoryError(err instanceof Error ? err.message : t('common.unknownError')); } finally { setHistoryLoading(false); } }; const handlePreviewRevision = async (sha: string) => { setHistoryPreviewLoading(true); setHistoryError(null); try { const result = await sync.downloadGistRevision(sha); if (result) { setHistoryPreview({ sha, payload: result.payload, preview: result.preview, deviceName: result.meta.deviceName, version: result.meta.version, }); } else { setHistoryError(t('cloudSync.revisionHistory.revisionNotFound')); } } catch { // Decrypt failures can manifest as various error types: // "Decryption failed", OperationError, "unable to authenticate // data", AES-GCM tag mismatch, etc. Show the friendly message // for any error originating from the decrypt step; network // errors would have been caught by the fetch layer already. setHistoryError(t('cloudSync.revisionHistory.decryptFailed')); } finally { setHistoryPreviewLoading(false); } }; const handleRestoreRevision = async () => { if (!historyPreview) return; // Gist revision restore is a destructive "replace local with cloud // snapshot" op — same shape as a local backup restore, same // cross-window race to block. await withRestoreBarrier(async () => { await Promise.resolve(onApplyPayload(historyPreview.payload)); }); toast.success(t('cloudSync.revisionHistory.restored')); setShowHistoryModal(false); setHistoryPreview(null); }; return (
{/* Header with status */}
{sync.isUnlocked ? t('cloudSync.header.vaultReady') : t('cloudSync.header.preparingVault')}
{t('cloudSync.header.providersConnected', { count: sync.connectedProviderCount })}
{blockedFinding && ( { setActiveTab('status'); requestAnimationFrame(() => { localBackupsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }} onForcePush={() => setShowForcePushConfirm(true)} /> )} { setPreparedConvergentMigration(null); setConvergentPreview(null); setConvergentError(null); }} onResolveConvergentConflict={handleResolveConvergentConflict} onDowngradeConvergent={handleDowngradeConvergent} disconnectOtherProviders={disconnectOtherProviders} />
); }; // ============================================================================ // Main Export - CloudSyncSettings // ============================================================================ interface CloudSyncSettingsProps { onBuildPayload: () => SyncPayload | Promise; onBuildLocalPayload: () => SyncPayload; onApplyMigrationPayload: (payload: SyncPayload) => void | Promise; onApplyPayload: (payload: SyncPayload) => void | Promise; onApplyConvergentPayload: ( payload: SyncPayload, commitReplica: () => Promise, ) => Promise; onApplyLocalPayload?: (payload: SyncPayload) => void | Promise; onClearLocalData?: () => void; } export const CloudSyncSettings: React.FC = (props) => { const { securityState } = useCloudSync(); // Simplified UX: once a master key is configured, we auto-unlock via safeStorage // so users don't have to manage a separate LOCKED screen. if (securityState === 'NO_KEY') { return (
{ }} /> {/* The master key is not configured yet. Expose the backup history for diagnostic purposes but refuse restores: the vault encryption layer can't re-protect the restored credentials until the user finishes master-key setup (I3). */}
); } return ; }; export default CloudSyncSettings;