Files
NetMesh/components/CloudSyncSettings.tsx
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

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;