Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1091 lines
47 KiB
TypeScript
1091 lines
47 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, 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<SyncPayload>;
|
|
onBuildLocalPayload: () => SyncPayload;
|
|
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
|
|
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
|
onApplyConvergentPayload: (
|
|
payload: SyncPayload,
|
|
commitReplica: () => Promise<void>,
|
|
) => Promise<void>;
|
|
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
|
|
onClearLocalData?: () => void;
|
|
}
|
|
|
|
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
|
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, string | number | boolean | null | undefined>,
|
|
): 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<CloudProvider>(['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<number | null>(null);
|
|
|
|
// Conflict modal
|
|
const [showConflictModal, setShowConflictModal] = useState(false);
|
|
|
|
// Gist revision history (#679)
|
|
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
|
const [historyRevisions, setHistoryRevisions] = useState<Array<{ version: string; date: Date }>>([]);
|
|
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<string | null>(null);
|
|
const [pendingConnectProvider, setPendingConnectProvider] = useState<CloudProvider | null>(null);
|
|
const pendingConnectProviderRef = useRef<CloudProvider | null>(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<string | null>(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<string | null>(null);
|
|
|
|
// WebDAV dialog state
|
|
const [showWebdavDialog, setShowWebdavDialog] = useState(false);
|
|
const [webdavEndpoint, setWebdavEndpoint] = useState('');
|
|
const [webdavAuthType, setWebdavAuthType] = useState<WebDAVAuthType>('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<string | null>(null);
|
|
const [webdavErrorDetail, setWebdavErrorDetail] = useState<string | null>(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<string | null>(null);
|
|
const [s3ErrorDetail, setS3ErrorDetail] = useState<string | null>(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<Extract<ShrinkFinding, { suspicious: true }> | null>(null);
|
|
const [showForcePushConfirm, setShowForcePushConfirm] = useState(false);
|
|
|
|
// Ref for scrolling to LocalBackupsPanel when the banner's Restore button is clicked
|
|
const localBackupsRef = useRef<HTMLDivElement>(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<PreparedConvergentMigration | null>(null);
|
|
const [convergentPreview, setConvergentPreview] = useState<ConvergentMigrationPreview | null>(null);
|
|
const [convergentBusy, setConvergentBusy] = useState(false);
|
|
const [convergentError, setConvergentError] = useState<string | null>(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<CloudProvider, SyncResult> | 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<CloudProvider, SyncResult>).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<CloudProvider, SyncResult>).values()).every((r) => r.success);
|
|
if (!allOk) {
|
|
const firstError = Array.from((results as Map<CloudProvider, SyncResult>).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 (
|
|
<div className="space-y-6">
|
|
{/* Header with status */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-green-500/10 flex items-center justify-center">
|
|
<ShieldCheck className="w-5 h-5 text-green-500" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">
|
|
{sync.isUnlocked ? t('cloudSync.header.vaultReady') : t('cloudSync.header.preparingVault')}
|
|
</span>
|
|
<StatusDot status={sync.isUnlocked ? 'connected' : 'connecting'} />
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t('cloudSync.header.providersConnected', { count: sync.connectedProviderCount })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="gap-1"
|
|
onClick={() => {
|
|
setChangeKeyError(null);
|
|
setCurrentMasterKey('');
|
|
setNewMasterKey('');
|
|
setConfirmNewMasterKey('');
|
|
setShowMasterKey(false);
|
|
setShowChangeKeyDialog(true);
|
|
}}
|
|
>
|
|
<Key size={14} />
|
|
{t('cloudSync.changeKey')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{blockedFinding && (
|
|
<SyncBlockedBanner
|
|
finding={blockedFinding}
|
|
onRestore={() => {
|
|
setActiveTab('status');
|
|
requestAnimationFrame(() => {
|
|
localBackupsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
});
|
|
}}
|
|
onForcePush={() => setShowForcePushConfirm(true)}
|
|
/>
|
|
)}
|
|
|
|
<CloudSyncDashboardTabs
|
|
activeTab={activeTab}
|
|
setActiveTab={setActiveTab}
|
|
t={t}
|
|
sync={sync}
|
|
resolvedLocale={resolvedLocale}
|
|
localBackupsRef={localBackupsRef}
|
|
isConnectDisabled={isConnectDisabled}
|
|
handleConnectGitHub={handleConnectGitHub}
|
|
handleConnectGoogle={handleConnectGoogle}
|
|
handleConnectOneDrive={handleConnectOneDrive}
|
|
openWebdavDialog={openWebdavDialog}
|
|
openS3Dialog={openS3Dialog}
|
|
handleOpenHistory={handleOpenHistory}
|
|
handleSync={handleSync}
|
|
onApplyPayload={onApplyPayload}
|
|
onApplyLocalPayload={onApplyLocalPayload}
|
|
setShowClearLocalDialog={setShowClearLocalDialog}
|
|
convergentConfig={sync.convergentSyncConfig}
|
|
convergentPreview={convergentPreview}
|
|
convergentBusy={convergentBusy}
|
|
convergentError={convergentError}
|
|
convergentConflicts={sync.convergentConflicts}
|
|
onToggleConvergent={handleToggleConvergent}
|
|
onConfirmConvergentMigration={handleConfirmConvergentMigration}
|
|
onCancelConvergentMigration={() => {
|
|
setPreparedConvergentMigration(null);
|
|
setConvergentPreview(null);
|
|
setConvergentError(null);
|
|
}}
|
|
onResolveConvergentConflict={handleResolveConvergentConflict}
|
|
onDowngradeConvergent={handleDowngradeConvergent}
|
|
disconnectOtherProviders={disconnectOtherProviders}
|
|
/>
|
|
|
|
<CloudSyncDialogs
|
|
t={t}
|
|
sync={sync}
|
|
showGitHubModal={showGitHubModal}
|
|
gitHubUserCode={gitHubUserCode}
|
|
gitHubVerificationUri={gitHubVerificationUri}
|
|
isPollingGitHub={isPollingGitHub}
|
|
activeGitHubAttemptIdRef={activeGitHubAttemptIdRef}
|
|
setShowGitHubModal={setShowGitHubModal}
|
|
setIsPollingGitHub={setIsPollingGitHub}
|
|
endPendingConnect={endPendingConnect}
|
|
showConflictModal={showConflictModal}
|
|
setShowConflictModal={setShowConflictModal}
|
|
handleResolveConflict={handleResolveConflict}
|
|
showHistoryModal={showHistoryModal}
|
|
setShowHistoryModal={setShowHistoryModal}
|
|
historyError={historyError}
|
|
historyLoading={historyLoading}
|
|
historyPreview={historyPreview}
|
|
setHistoryPreview={setHistoryPreview}
|
|
historyPreviewLoading={historyPreviewLoading}
|
|
historyRevisions={historyRevisions}
|
|
handlePreviewRevision={handlePreviewRevision}
|
|
handleRestoreRevision={handleRestoreRevision}
|
|
showWebdavDialog={showWebdavDialog}
|
|
setShowWebdavDialog={setShowWebdavDialog}
|
|
webdavEndpoint={webdavEndpoint}
|
|
setWebdavEndpoint={setWebdavEndpoint}
|
|
webdavAuthType={webdavAuthType}
|
|
setWebdavAuthType={setWebdavAuthType}
|
|
webdavUsername={webdavUsername}
|
|
setWebdavUsername={setWebdavUsername}
|
|
webdavPassword={webdavPassword}
|
|
setWebdavPassword={setWebdavPassword}
|
|
webdavToken={webdavToken}
|
|
setWebdavToken={setWebdavToken}
|
|
showWebdavSecret={showWebdavSecret}
|
|
setShowWebdavSecret={setShowWebdavSecret}
|
|
webdavAllowInsecure={webdavAllowInsecure}
|
|
setWebdavAllowInsecure={setWebdavAllowInsecure}
|
|
webdavError={webdavError}
|
|
webdavErrorDetail={webdavErrorDetail}
|
|
isSavingWebdav={isSavingWebdav}
|
|
handleSaveWebdav={handleSaveWebdav}
|
|
showS3Dialog={showS3Dialog}
|
|
setShowS3Dialog={setShowS3Dialog}
|
|
s3Endpoint={s3Endpoint}
|
|
setS3Endpoint={setS3Endpoint}
|
|
s3Region={s3Region}
|
|
setS3Region={setS3Region}
|
|
s3Bucket={s3Bucket}
|
|
setS3Bucket={setS3Bucket}
|
|
s3AccessKeyId={s3AccessKeyId}
|
|
setS3AccessKeyId={setS3AccessKeyId}
|
|
s3SecretAccessKey={s3SecretAccessKey}
|
|
setS3SecretAccessKey={setS3SecretAccessKey}
|
|
s3SessionToken={s3SessionToken}
|
|
setS3SessionToken={setS3SessionToken}
|
|
s3Prefix={s3Prefix}
|
|
setS3Prefix={setS3Prefix}
|
|
s3ForcePathStyle={s3ForcePathStyle}
|
|
setS3ForcePathStyle={setS3ForcePathStyle}
|
|
s3AllowInsecure={s3AllowInsecure}
|
|
setS3AllowInsecure={setS3AllowInsecure}
|
|
showS3Secret={showS3Secret}
|
|
setShowS3Secret={setShowS3Secret}
|
|
s3Error={s3Error}
|
|
s3ErrorDetail={s3ErrorDetail}
|
|
isSavingS3={isSavingS3}
|
|
handleSaveS3={handleSaveS3}
|
|
showChangeKeyDialog={showChangeKeyDialog}
|
|
setShowChangeKeyDialog={setShowChangeKeyDialog}
|
|
currentMasterKey={currentMasterKey}
|
|
setCurrentMasterKey={setCurrentMasterKey}
|
|
newMasterKey={newMasterKey}
|
|
setNewMasterKey={setNewMasterKey}
|
|
confirmNewMasterKey={confirmNewMasterKey}
|
|
setConfirmNewMasterKey={setConfirmNewMasterKey}
|
|
showMasterKey={showMasterKey}
|
|
setShowMasterKey={setShowMasterKey}
|
|
changeKeyError={changeKeyError}
|
|
setChangeKeyError={setChangeKeyError}
|
|
isChangingKey={isChangingKey}
|
|
setIsChangingKey={setIsChangingKey}
|
|
showUnlockDialog={showUnlockDialog}
|
|
setShowUnlockDialog={setShowUnlockDialog}
|
|
unlockMasterKey={unlockMasterKey}
|
|
setUnlockMasterKey={setUnlockMasterKey}
|
|
showUnlockMasterKey={showUnlockMasterKey}
|
|
setShowUnlockMasterKey={setShowUnlockMasterKey}
|
|
unlockError={unlockError}
|
|
setUnlockError={setUnlockError}
|
|
isUnlocking={isUnlocking}
|
|
setIsUnlocking={setIsUnlocking}
|
|
showClearLocalDialog={showClearLocalDialog}
|
|
setShowClearLocalDialog={setShowClearLocalDialog}
|
|
onBuildPayload={onBuildPayload}
|
|
onApplyPayload={onApplyPayload}
|
|
onApplyConvergentPayload={onApplyConvergentPayload}
|
|
onClearLocalData={onClearLocalData}
|
|
ensureSyncablePayload={ensureSyncablePayload}
|
|
showForcePushConfirm={showForcePushConfirm}
|
|
setShowForcePushConfirm={setShowForcePushConfirm}
|
|
blockedFinding={blockedFinding}
|
|
setBlockedFinding={setBlockedFinding}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// Main Export - CloudSyncSettings
|
|
// ============================================================================
|
|
|
|
interface CloudSyncSettingsProps {
|
|
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
|
|
onBuildLocalPayload: () => SyncPayload;
|
|
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
|
|
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
|
onApplyConvergentPayload: (
|
|
payload: SyncPayload,
|
|
commitReplica: () => Promise<void>,
|
|
) => Promise<void>;
|
|
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
|
|
onClearLocalData?: () => void;
|
|
}
|
|
|
|
export const CloudSyncSettings: React.FC<CloudSyncSettingsProps> = (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 (
|
|
<div className="space-y-6">
|
|
<GatekeeperScreen onSetupComplete={() => { }} />
|
|
{/* 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). */}
|
|
<LocalBackupsPanel
|
|
onApplyPayload={props.onApplyPayload}
|
|
restoreDisabledReason="no-master-key"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <SyncDashboard {...props} />;
|
|
};
|
|
|
|
export default CloudSyncSettings;
|