/** * 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 } from 'react'; import { AlertTriangle, Check, Cloud, CloudOff, Copy, Download, ExternalLink, Eye, EyeOff, Github, Loader2, RefreshCw, Settings, Shield, ShieldCheck, X, } from 'lucide-react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { useCloudSync } from '../../application/state/useCloudSync'; import { type CloudProvider, type ConflictInfo, type SyncChangeEntityKey, type SyncEntityChangeCounts, formatLastSync } from '../../domain/sync'; import { cn } from '../../lib/utils'; import { Button } from '../ui/button'; import { ConfirmDialog } from '../ui/confirm-dialog'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { toast } from '../ui/toast'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; // ============================================================================ // Provider Icons // ============================================================================ export const GoogleDriveIcon: React.FC<{ className?: string }> = ({ className }) => ( ); export const OneDriveIcon: React.FC<{ className?: string }> = ({ className }) => ( ); // ============================================================================ // Toggle Component // ============================================================================ interface ToggleProps { checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean; } export const Toggle: React.FC = ({ checked, onChange, disabled }) => ( onChange(!checked)} className={cn( "relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", checked ? "bg-primary" : "bg-input" )} > ); // ============================================================================ // Status Dot Component // ============================================================================ interface StatusDotProps { status: 'connected' | 'syncing' | 'error' | 'disconnected' | 'connecting'; className?: string; } export const StatusDot: React.FC = ({ status, className }) => { if (status === 'connecting') { return ; } const colors = { connected: 'bg-green-500', syncing: 'bg-blue-500 animate-pulse', error: 'bg-red-500', disconnected: 'bg-muted-foreground/50', }; return ( ); }; // ============================================================================ // Gatekeeper Screen (NO_KEY state) // ============================================================================ interface GatekeeperScreenProps { onSetupComplete: () => void; } export const GatekeeperScreen: React.FC = ({ onSetupComplete }) => { const { t } = useI18n(); const { setupMasterKey } = useCloudSync(); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const [acknowledged, setAcknowledged] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const passwordStrength = React.useMemo(() => { if (password.length < 8) return { level: 0, text: t('cloudSync.passwordStrength.tooShort') }; let score = 0; if (password.length >= 12) score++; if (/[A-Z]/.test(password)) score++; if (/[a-z]/.test(password)) score++; if (/[0-9]/.test(password)) score++; if (/[^A-Za-z0-9]/.test(password)) score++; if (score <= 2) return { level: 1, text: t('cloudSync.passwordStrength.weak') }; if (score <= 3) return { level: 2, text: t('cloudSync.passwordStrength.moderate') }; if (score <= 4) return { level: 3, text: t('cloudSync.passwordStrength.strong') }; return { level: 4, text: t('cloudSync.passwordStrength.veryStrong') }; }, [password, t]); const canSubmit = password.length >= 8 && password === confirmPassword && acknowledged; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit) return; setIsLoading(true); setError(null); try { await setupMasterKey(password, confirmPassword); toast.success(t('cloudSync.gate.enabledToast')); onSetupComplete(); } catch (err) { setError(err instanceof Error ? err.message : t('cloudSync.gate.setupFailed')); } finally { setIsLoading(false); } }; return ( {t('cloudSync.gate.title')} {t('cloudSync.gate.desc')} {t('cloudSync.gate.masterKey')} setPassword(e.target.value)} placeholder={t('cloudSync.gate.placeholder')} className="pr-10" /> setShowPassword(!showPassword)} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" > {showPassword ? : } {password.length > 0 && ( {passwordStrength.text} )} {t('cloudSync.gate.confirmMasterKey')} setConfirmPassword(e.target.value)} placeholder={t('cloudSync.gate.confirmPlaceholder')} /> {confirmPassword && password !== confirmPassword && ( {t('cloudSync.gate.mismatch')} )} setAcknowledged(e.target.checked)} className="mt-0.5 accent-red-500" /> {t('cloudSync.gate.warning')} {error && ( {error} )} {isLoading ? ( ) : ( )} {t('cloudSync.gate.enableVault')} ); }; // ============================================================================ // Provider Card Component // ============================================================================ interface ProviderCardProps { provider: CloudProvider; name: string; icon: React.ReactNode; isConnected: boolean; isSyncing: boolean; isConnecting?: boolean; account?: { name?: string; email?: string; avatarUrl?: string }; lastSync?: number; error?: string; disabled?: boolean; // Disable connect button when another provider is connected onEdit?: () => void; onConnect: () => void; onCancelConnect?: () => void; onDisconnect: () => void; onSync: () => void; extraActions?: React.ReactNode; } export const ProviderCard: React.FC = ({ provider: _provider, name, icon, isConnected, isSyncing, isConnecting, account, lastSync, error, disabled, onEdit, onConnect, onCancelConnect, onDisconnect, onSync, extraActions, }) => { const { t } = useI18n(); const [disconnectConfirmOpen, setDisconnectConfirmOpen] = useState(false); const formatLastSyncLabel = (timestamp?: number): string => { if (!timestamp) return t('cloudSync.lastSync.never'); const now = Date.now(); const diff = now - timestamp; if (diff < 60000) return t('cloudSync.lastSync.justNow'); if (diff < 3600000) return t('cloudSync.lastSync.minutesAgo', { minutes: Math.floor(diff / 60000) }); return formatLastSync(timestamp); }; const status = error ? 'error' : isSyncing ? 'syncing' : isConnected ? 'connected' : isConnecting ? 'connecting' : 'disconnected'; return ( {icon} {name} {isConnected && account ? ( {account.avatarUrl && ( )} {account.name || account.email} ยท {formatLastSyncLabel(lastSync)} ) : error ? ( {error} {error} ) : ( {isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')} )} {isConnected ? ( <> {isSyncing ? ( ) : ( )} {t('cloudSync.provider.sync')} {extraActions} {onEdit && ( {t('action.edit')} )} setDisconnectConfirmOpen(true)} className="text-muted-foreground hover:text-red-500" aria-label={t('cloudSync.provider.disconnect')} > > ) : isConnecting && onCancelConnect ? ( {t('common.cancel')} ) : ( { onConnect(); }} className="gap-1 min-w-[136px] justify-center" disabled={disabled || isConnecting} > {isConnecting ? : } {isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.connect')} )} { setDisconnectConfirmOpen(false); onDisconnect(); }} /> ); }; // ============================================================================ // GitHub Device Flow Modal // ============================================================================ interface GitHubDeviceFlowModalProps { isOpen: boolean; userCode: string; verificationUri: string; isPolling: boolean; onClose: () => void; } export const GitHubDeviceFlowModal: React.FC = ({ isOpen, userCode, verificationUri, isPolling, onClose, }) => { const { t } = useI18n(); const [copied, setCopied] = useState(false); const copyCode = useCallback(() => { navigator.clipboard.writeText(userCode); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [userCode]); if (!isOpen) return null; return ( {t('cloudSync.githubFlow.title')} {t('cloudSync.githubFlow.desc')} {userCode} {copied ? : } {copied ? t('cloudSync.githubFlow.copied') : t('cloudSync.githubFlow.copyCode')} window.open(verificationUri, "_blank", "noopener,noreferrer")} className="w-full gap-2 mb-4" > {t('cloudSync.githubFlow.openGitHub')} {isPolling && ( {t('cloudSync.githubFlow.waiting')} )} ); }; // ============================================================================ // Conflict Resolution Modal // ============================================================================ interface ConflictModalProps { open: boolean; conflict: ConflictInfo | null; onResolve: (resolution: 'USE_LOCAL' | 'USE_REMOTE') => void; onClose: () => void; } const CONFLICT_ENTITY_LABEL_KEYS: Record = { hosts: 'cloudSync.conflict.entity.hosts', keys: 'cloudSync.conflict.entity.keys', identities: 'cloudSync.conflict.entity.identities', proxyProfiles: 'cloudSync.conflict.entity.proxyProfiles', snippets: 'cloudSync.conflict.entity.snippets', notes: 'cloudSync.conflict.entity.notes', noteGroups: 'cloudSync.conflict.entity.noteGroups', customGroups: 'cloudSync.conflict.entity.customGroups', snippetPackages: 'cloudSync.conflict.entity.snippetPackages', portForwardingRules: 'cloudSync.conflict.entity.portForwardingRules', groupConfigs: 'cloudSync.conflict.entity.groupConfigs', settings: 'cloudSync.conflict.entity.settings', }; export const ConflictModal: React.FC = ({ open, conflict, onResolve, onClose, }) => { const { t, resolvedLocale } = useI18n(); if (!open || !conflict) return null; const formatDate = (timestamp: number) => { return new Date(timestamp).toLocaleString(resolvedLocale || undefined); }; const changeRows = conflict.changeSummary ? (Object.entries(conflict.changeSummary.byEntity) as Array<[SyncChangeEntityKey, SyncEntityChangeCounts | undefined]>) .flatMap(([entityType, counts]) => { if (!counts) return []; return [{ entityType, localTotal: counts.added.local + counts.modified.local + counts.deleted.local, remoteTotal: counts.added.remote + counts.modified.remote + counts.deleted.remote, conflictTotal: conflict.changeSummary?.conflicts.filter((item) => item.entityType === entityType).length ?? 0, }]; }) .filter((row) => row.localTotal > 0 || row.remoteTotal > 0 || row.conflictTotal > 0) : []; return ( {t('cloudSync.conflict.title')} {t('cloudSync.conflict.desc')} {t('cloudSync.conflict.local')} v{conflict.localVersion} {formatDate(conflict.localUpdatedAt)} {conflict.localDeviceName && ( {conflict.localDeviceName} )} {t('cloudSync.conflict.cloud')} v{conflict.remoteVersion} {formatDate(conflict.remoteUpdatedAt)} {conflict.remoteDeviceName && ( {conflict.remoteDeviceName} )} {changeRows.length > 0 && ( {t('cloudSync.conflict.detailsTitle')} {changeRows.map((row) => ( {t(CONFLICT_ENTITY_LABEL_KEYS[row.entityType])} {t('cloudSync.conflict.detailsCounts', { local: row.localTotal, cloud: row.remoteTotal, conflicts: row.conflictTotal, })} ))} )} onResolve('USE_LOCAL')} > {t('cloudSync.conflict.keepLocal')} onResolve('USE_REMOTE')} > {t('cloudSync.conflict.useCloud')} ); }; // ============================================================================ // Main Dashboard (UNLOCKED state) // ============================================================================
{t('cloudSync.gate.desc')}
{t('cloudSync.gate.mismatch')}
{error}
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
{t('cloudSync.githubFlow.desc')}
{t('cloudSync.conflict.desc')}