[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
691
components/cloud-sync/CloudSyncControls.tsx
Normal file
691
components/cloud-sync/CloudSyncControls.tsx
Normal file
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* 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 }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.71 3.5L1.15 15l3.43 6 6.55-11.5L7.71 3.5zm1.73 0l6.55 11.5H23L16.45 3.5H9.44zM8 15l-3.43 6h13.72l3.43-6H8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const OneDriveIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10.5 18.5c0 .55-.45 1-1 1h-5c-2.21 0-4-1.79-4-4 0-1.86 1.28-3.41 3-3.86v-.14c0-2.21 1.79-4 4-4 1.1 0 2.1.45 2.82 1.18A5.003 5.003 0 0 1 15 4c2.76 0 5 2.24 5 5 0 .16 0 .32-.02.47A4.5 4.5 0 0 1 24 13.5c0 2.49-2.01 4.5-4.5 4.5h-8c-.55 0-1-.45-1-1s.45-1 1-1h8c1.38 0 2.5-1.12 2.5-2.5s-1.12-2.5-2.5-2.5H19c-.28 0-.5-.22-.5-.5 0-2.21-1.79-4-4-4-1.87 0-3.44 1.28-3.88 3.02-.09.37-.41.63-.79.63-1.66 0-3 1.34-3 3v.5c0 .28-.22.5-.5.5-1.38 0-2.5 1.12-2.5 2.5s1.12 2.5 2.5 2.5h5c.55 0 1 .45 1 1z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Toggle Component
|
||||
// ============================================================================
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => 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"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Status Dot Component
|
||||
// ============================================================================
|
||||
|
||||
interface StatusDotProps {
|
||||
status: 'connected' | 'syncing' | 'error' | 'disconnected' | 'connecting';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
|
||||
if (status === 'connecting') {
|
||||
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
|
||||
}
|
||||
|
||||
const colors = {
|
||||
connected: 'bg-green-500',
|
||||
syncing: 'bg-blue-500 animate-pulse',
|
||||
error: 'bg-red-500',
|
||||
disconnected: 'bg-muted-foreground/50',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn('inline-block w-2 h-2 rounded-full', colors[status], className)} />
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Gatekeeper Screen (NO_KEY state)
|
||||
// ============================================================================
|
||||
|
||||
interface GatekeeperScreenProps {
|
||||
onSetupComplete: () => void;
|
||||
}
|
||||
|
||||
export const GatekeeperScreen: React.FC<GatekeeperScreenProps> = ({ 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<string | null>(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 (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
|
||||
<Shield className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">{t('cloudSync.gate.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-8">
|
||||
{t('cloudSync.gate.desc')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-left block">{t('cloudSync.gate.masterKey')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('cloudSync.gate.placeholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{password.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all',
|
||||
passwordStrength.level === 1 && 'w-1/4 bg-red-500',
|
||||
passwordStrength.level === 2 && 'w-2/4 bg-yellow-500',
|
||||
passwordStrength.level === 3 && 'w-3/4 bg-green-500',
|
||||
passwordStrength.level === 4 && 'w-full bg-green-600',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{passwordStrength.text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-left block">{t('cloudSync.gate.confirmMasterKey')}</Label>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('cloudSync.gate.confirmPlaceholder')}
|
||||
/>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="text-xs text-red-500 text-left">{t('cloudSync.gate.mismatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 p-3 rounded-lg border border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/50 cursor-pointer text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
className="mt-0.5 accent-red-500"
|
||||
/>
|
||||
<span className="text-xs text-red-700 dark:text-red-400">
|
||||
{t('cloudSync.gate.warning')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-left">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit || isLoading}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck size={16} />
|
||||
)}
|
||||
{t('cloudSync.gate.enableVault')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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<ProviderCardProps> = ({
|
||||
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 (
|
||||
<div className={cn(
|
||||
"flex items-center gap-4 p-4 rounded-lg border transition-colors",
|
||||
isConnected ? "bg-card" : "bg-muted/30",
|
||||
error && "border-red-300 dark:border-red-900"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"w-12 h-12 rounded-lg flex items-center justify-center",
|
||||
isConnected ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{name}</span>
|
||||
<StatusDot status={status} />
|
||||
</div>
|
||||
|
||||
{isConnected && account ? (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{account.avatarUrl && (
|
||||
<img
|
||||
src={account.avatarUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded-full"
|
||||
referrerPolicy="no-referrer"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{account.name || account.email}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {formatLastSyncLabel(lastSync)}
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help">
|
||||
{error}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{error}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onSync}
|
||||
disabled={isSyncing}
|
||||
className="gap-1"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
{t('cloudSync.provider.sync')}
|
||||
</Button>
|
||||
{extraActions}
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onEdit}
|
||||
className="gap-1"
|
||||
>
|
||||
<Settings size={14} />
|
||||
{t('action.edit')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDisconnectConfirmOpen(true)}
|
||||
className="text-muted-foreground hover:text-red-500"
|
||||
aria-label={t('cloudSync.provider.disconnect')}
|
||||
>
|
||||
<CloudOff size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : isConnecting && onCancelConnect ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancelConnect}
|
||||
className="gap-1 min-w-[136px] justify-center"
|
||||
>
|
||||
<X size={14} />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { onConnect(); }}
|
||||
className="gap-1 min-w-[136px] justify-center"
|
||||
disabled={disabled || isConnecting}
|
||||
>
|
||||
{isConnecting ? <Loader2 size={14} className="animate-spin" /> : <Cloud size={14} />}
|
||||
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={disconnectConfirmOpen}
|
||||
title={t('cloudSync.provider.disconnect.confirmTitle', { name })}
|
||||
message={t('cloudSync.provider.disconnect.confirmMessage', { name })}
|
||||
confirmLabel={t('cloudSync.provider.disconnect.confirmAction')}
|
||||
destructive
|
||||
onOpenChange={setDisconnectConfirmOpen}
|
||||
onConfirm={() => {
|
||||
setDisconnectConfirmOpen(false);
|
||||
onDisconnect();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GitHub Device Flow Modal
|
||||
// ============================================================================
|
||||
|
||||
interface GitHubDeviceFlowModalProps {
|
||||
isOpen: boolean;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
isPolling: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-lg shadow-xl w-full max-w-md p-6 relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-[#24292e] flex items-center justify-center mx-auto mb-4">
|
||||
<Github className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">{t('cloudSync.githubFlow.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{t('cloudSync.githubFlow.desc')}
|
||||
</p>
|
||||
|
||||
<div className="bg-muted rounded-lg p-4 mb-4">
|
||||
<div className="font-mono text-2xl font-bold tracking-widest mb-2">
|
||||
{userCode}
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={copyCode} className="gap-2">
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? t('cloudSync.githubFlow.copied') : t('cloudSync.githubFlow.copyCode')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
|
||||
className="w-full gap-2 mb-4"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('cloudSync.githubFlow.openGitHub')}
|
||||
</Button>
|
||||
|
||||
{isPolling && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t('cloudSync.githubFlow.waiting')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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<SyncChangeEntityKey, string> = {
|
||||
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<ConflictModalProps> = ({
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-lg shadow-xl w-full max-w-lg max-h-[calc(100vh-2rem)] p-6 relative flex flex-col">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-lg font-semibold">{t('cloudSync.conflict.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('cloudSync.conflict.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg border bg-muted/30 min-w-0">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t('cloudSync.conflict.local')}</div>
|
||||
<div className="text-sm font-medium">v{conflict.localVersion}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 break-words">
|
||||
{formatDate(conflict.localUpdatedAt)}
|
||||
</div>
|
||||
{conflict.localDeviceName && (
|
||||
<div className="text-xs text-muted-foreground break-words">
|
||||
{conflict.localDeviceName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg border bg-muted/30 min-w-0">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t('cloudSync.conflict.cloud')}</div>
|
||||
<div className="text-sm font-medium">v{conflict.remoteVersion}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 break-words">
|
||||
{formatDate(conflict.remoteUpdatedAt)}
|
||||
</div>
|
||||
{conflict.remoteDeviceName && (
|
||||
<div className="text-xs text-muted-foreground break-words">
|
||||
{conflict.remoteDeviceName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{changeRows.length > 0 && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3 mb-6 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t('cloudSync.conflict.detailsTitle')}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{changeRows.map((row) => (
|
||||
<div key={row.entityType} className="grid gap-1 text-sm">
|
||||
<span className="font-medium break-words">
|
||||
{t(CONFLICT_ENTITY_LABEL_KEYS[row.entityType])}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground break-words">
|
||||
{t('cloudSync.conflict.detailsCounts', {
|
||||
local: row.localTotal,
|
||||
cloud: row.remoteTotal,
|
||||
conflicts: row.conflictTotal,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-4 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={() => onResolve('USE_LOCAL')}
|
||||
>
|
||||
<Cloud size={14} />
|
||||
{t('cloudSync.conflict.keepLocal')}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
onClick={() => onResolve('USE_REMOTE')}
|
||||
>
|
||||
<Download size={14} />
|
||||
{t('cloudSync.conflict.useCloud')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main Dashboard (UNLOCKED state)
|
||||
// ============================================================================
|
||||
691
components/cloud-sync/CloudSyncDashboardTabs.tsx
Normal file
691
components/cloud-sync/CloudSyncDashboardTabs.tsx
Normal file
@@ -0,0 +1,691 @@
|
||||
import React, { useEffect, useState, type Dispatch, type RefObject, type SetStateAction } from 'react';
|
||||
import { Database, Github, History, Plug, Server, Trash2 } from 'lucide-react';
|
||||
import type {
|
||||
CloudProvider,
|
||||
ConvergentFieldConflict,
|
||||
ConvergentMigrationPreview,
|
||||
SyncPayload,
|
||||
} from '../../domain/sync';
|
||||
import { isBuiltinCloudProvider } from '../../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../../domain/cloudProviderIds';
|
||||
import { planPluginSyncConnect, hasPluginProviderStoredConfig } from '../../domain/pluginSyncConnect';
|
||||
import { planPluginSyncCredential, syncConfigurationSchemaWithoutSecretRequirements } from '../../domain/pluginSyncCredential';
|
||||
import { pluginConfigurationMatchesSchema } from '../../domain/pluginConfigurationSchema';
|
||||
import type { useCloudSync } from '../../application/state/useCloudSync';
|
||||
import { storePluginSyncSecretsThenConnect } from '../../application/pluginSyncConnectWithSecrets';
|
||||
import { cleanOneDriveErrorMessage, isProviderReadyForSync } from '../../domain/sync';
|
||||
import { pluginExtensionBridge } from '../../application/state/pluginExtensionBridge';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '../ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { toast } from '../ui/toast';
|
||||
import { GoogleDriveIcon, OneDriveIcon, ProviderCard, Toggle } from './CloudSyncControls';
|
||||
import { LocalBackupsPanel } from './CloudSyncLocalBackupsPanel';
|
||||
import { ConvergentSyncPanel } from './ConvergentSyncPanel';
|
||||
import { SettingsAnchor } from '../settings/settings-ui';
|
||||
|
||||
type SyncController = ReturnType<typeof useCloudSync>;
|
||||
type Translate = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
interface CloudSyncDashboardTabsProps {
|
||||
activeTab: 'providers' | 'status';
|
||||
setActiveTab: Dispatch<SetStateAction<'providers' | 'status'>>;
|
||||
t: Translate;
|
||||
sync: SyncController;
|
||||
resolvedLocale: string | null;
|
||||
localBackupsRef: RefObject<HTMLDivElement | null>;
|
||||
isConnectDisabled: (provider: CloudProvider) => boolean;
|
||||
handleConnectGitHub: () => Promise<void>;
|
||||
handleConnectGoogle: () => Promise<void>;
|
||||
handleConnectOneDrive: () => Promise<void>;
|
||||
openWebdavDialog: () => void;
|
||||
openS3Dialog: () => void;
|
||||
handleOpenHistory: () => Promise<void>;
|
||||
handleSync: (provider: CloudProvider) => Promise<void>;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
|
||||
setShowClearLocalDialog: Dispatch<SetStateAction<boolean>>;
|
||||
convergentConfig: { enabled: boolean; initialized: boolean };
|
||||
convergentPreview: ConvergentMigrationPreview | null;
|
||||
convergentBusy: boolean;
|
||||
convergentError: string | null;
|
||||
convergentConflicts: ConvergentFieldConflict[];
|
||||
onToggleConvergent: (enabled: boolean) => void | Promise<void>;
|
||||
onConfirmConvergentMigration: () => void | Promise<void>;
|
||||
onCancelConvergentMigration: () => void;
|
||||
onResolveConvergentConflict: (addressKey: string, candidateDot: string) => void | Promise<void>;
|
||||
onDowngradeConvergent: () => void | Promise<void>;
|
||||
/** Disconnect other legacy providers before connecting a plugin backend. */
|
||||
disconnectOtherProviders?: (current: CloudProvider) => Promise<void>;
|
||||
}
|
||||
|
||||
export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
t,
|
||||
sync,
|
||||
resolvedLocale,
|
||||
localBackupsRef,
|
||||
isConnectDisabled,
|
||||
handleConnectGitHub,
|
||||
handleConnectGoogle,
|
||||
handleConnectOneDrive,
|
||||
openWebdavDialog,
|
||||
openS3Dialog,
|
||||
handleOpenHistory,
|
||||
handleSync,
|
||||
onApplyPayload,
|
||||
onApplyLocalPayload,
|
||||
setShowClearLocalDialog,
|
||||
convergentConfig,
|
||||
convergentPreview,
|
||||
convergentBusy,
|
||||
convergentError,
|
||||
convergentConflicts,
|
||||
onToggleConvergent,
|
||||
onConfirmConvergentMigration,
|
||||
onCancelConvergentMigration,
|
||||
onResolveConvergentConflict,
|
||||
onDowngradeConvergent,
|
||||
disconnectOtherProviders,
|
||||
}) => {
|
||||
const [pluginSyncProviders, setPluginSyncProviders] = useState<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
configurationSchema?: unknown;
|
||||
}>>([]);
|
||||
const [pluginConnectBusy, setPluginConnectBusy] = useState<string | null>(null);
|
||||
const [pluginConfigDialog, setPluginConfigDialog] = useState<{
|
||||
providerId: string;
|
||||
title: string;
|
||||
configurationSchema?: unknown;
|
||||
} | null>(null);
|
||||
const [pluginConfigText, setPluginConfigText] = useState('{}');
|
||||
const [pluginConfigError, setPluginConfigError] = useState<string | null>(null);
|
||||
const [pluginConfigSaving, setPluginConfigSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const listed = await pluginExtensionBridge.listProviders('sync');
|
||||
if (cancelled) return;
|
||||
setPluginSyncProviders(
|
||||
(listed ?? []).map((entry) => {
|
||||
const nested = (entry as {
|
||||
provider?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
configurationSchema?: unknown;
|
||||
};
|
||||
pluginDisplayName?: string;
|
||||
}).provider;
|
||||
const id = String(nested?.id ?? '');
|
||||
return {
|
||||
id,
|
||||
title: String(
|
||||
nested?.label
|
||||
?? (entry as { pluginDisplayName?: string }).pluginDisplayName
|
||||
?? id
|
||||
?? 'Plugin sync',
|
||||
),
|
||||
configurationSchema: nested?.configurationSchema,
|
||||
};
|
||||
}).filter((entry) => entry.id.length > 0 && isPluginCloudProviderId(entry.id)),
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setPluginSyncProviders([]);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const unsubscribe = pluginExtensionBridge.onContributionsChanged(() => {
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPluginConfigDialog = (
|
||||
providerId: string,
|
||||
title: string,
|
||||
configurationSchema: unknown | undefined,
|
||||
) => {
|
||||
const connection = sync.providers[providerId];
|
||||
// Include falsy scalars and JSON null so Edit can re-save them.
|
||||
const seed = hasPluginProviderStoredConfig(connection) ? connection!.config : {};
|
||||
setPluginConfigText(JSON.stringify(seed, null, 2));
|
||||
setPluginConfigError(null);
|
||||
setPluginConfigDialog({ providerId, title, configurationSchema });
|
||||
};
|
||||
|
||||
const runPluginConnect = async (providerId: string, configuration: unknown): Promise<boolean> => {
|
||||
setPluginConnectBusy(providerId);
|
||||
try {
|
||||
if (disconnectOtherProviders) {
|
||||
await disconnectOtherProviders(providerId as CloudProvider);
|
||||
}
|
||||
const credentialPlan = planPluginSyncCredential(configuration, {
|
||||
configurationSchema: pluginSyncProviders.find((entry) => entry.id === providerId)
|
||||
?.configurationSchema,
|
||||
});
|
||||
const stored = sync.providers[providerId]?.credential;
|
||||
const existingCredential =
|
||||
stored
|
||||
&& typeof stored === 'object'
|
||||
&& stored.kind === 'secret'
|
||||
&& typeof stored.id === 'string'
|
||||
? stored as { kind: 'secret'; id: string; key: string }
|
||||
: undefined;
|
||||
if (credentialPlan.secrets.length > 0) {
|
||||
const { putPluginSyncSecret, deletePluginSyncSecrets, restorePluginSyncSecrets } = await import(
|
||||
'../../infrastructure/services/adapters/pluginSyncIpcHost'
|
||||
);
|
||||
// Put secrets before connect; roll back just-created keys if connect fails
|
||||
// so a rejected password/token is not left readable in plugin_secrets.
|
||||
// Overwrites restore the previous plaintext from the host stash.
|
||||
await storePluginSyncSecretsThenConnect({
|
||||
providerId,
|
||||
secrets: credentialPlan.secrets.map((secret) => ({
|
||||
secretKey: secret.secretKey,
|
||||
value: secret.value,
|
||||
})),
|
||||
putSecret: putPluginSyncSecret,
|
||||
deleteSecrets: deletePluginSyncSecrets,
|
||||
restoreSecrets: restorePluginSyncSecrets,
|
||||
connect: async (credential) => {
|
||||
await sync.connectPluginProvider(
|
||||
providerId,
|
||||
credentialPlan.configuration,
|
||||
credential,
|
||||
);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await sync.connectPluginProvider(
|
||||
providerId,
|
||||
credentialPlan.configuration,
|
||||
existingCredential,
|
||||
);
|
||||
}
|
||||
toast.success(t('cloudSync.connect.plugin.success'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message, t('cloudSync.connect.plugin.failedTitle'));
|
||||
return false;
|
||||
} finally {
|
||||
setPluginConnectBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePluginConnect = async (providerId: string) => {
|
||||
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
|
||||
const connection = sync.providers[providerId];
|
||||
// Config may be a valid falsy scalar or JSON null — property presence, not truthiness.
|
||||
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
|
||||
const plan = planPluginSyncConnect({
|
||||
configurationSchema: listed?.configurationSchema,
|
||||
storedConfig: connection?.config,
|
||||
hasStoredConfig,
|
||||
});
|
||||
if (plan.action === 'prompt') {
|
||||
openPluginConfigDialog(
|
||||
providerId,
|
||||
listed?.title ?? providerId,
|
||||
listed?.configurationSchema,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runPluginConnect(providerId, plan.configuration);
|
||||
};
|
||||
|
||||
const handleSavePluginConfig = async () => {
|
||||
if (!pluginConfigDialog) return;
|
||||
let configuration: unknown;
|
||||
try {
|
||||
configuration = JSON.parse(pluginConfigText) as unknown;
|
||||
} catch {
|
||||
setPluginConfigError(t('cloudSync.pluginConfig.invalidJson'));
|
||||
return;
|
||||
}
|
||||
if (pluginConfigDialog.configurationSchema !== undefined) {
|
||||
const connection = sync.providers[pluginConfigDialog.providerId];
|
||||
const hasStoredSecret = connection?.credential != null
|
||||
&& typeof connection.credential === 'object'
|
||||
&& (connection.credential as { kind?: string }).kind === 'secret';
|
||||
// Edit seeds stripped config; required secret fields stay satisfied by the
|
||||
// durable SecretRef until the user re-enters plaintext secrets.
|
||||
const schema = hasStoredSecret
|
||||
? syncConfigurationSchemaWithoutSecretRequirements(pluginConfigDialog.configurationSchema)
|
||||
: pluginConfigDialog.configurationSchema;
|
||||
if (!pluginConfigurationMatchesSchema(schema, configuration)) {
|
||||
setPluginConfigError(t('cloudSync.pluginConfig.schemaInvalid'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPluginConfigError(null);
|
||||
setPluginConfigSaving(true);
|
||||
try {
|
||||
const ok = await runPluginConnect(pluginConfigDialog.providerId, configuration);
|
||||
if (ok) setPluginConfigDialog(null);
|
||||
// On failure toast already shown; keep dialog open for correction.
|
||||
} finally {
|
||||
setPluginConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pluginProviderIds = new Set<string>([
|
||||
...pluginSyncProviders.map((entry) => entry.id),
|
||||
...Object.keys(sync.providers).filter((id) => !isBuiltinCloudProvider(id)),
|
||||
]);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
|
||||
<TabsTrigger value="status">{t('cloudSync.status.title')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="providers" className="space-y-3">
|
||||
<SettingsAnchor anchorId="sync-providers" className="space-y-3">
|
||||
<ProviderCard
|
||||
provider="github"
|
||||
name="GitHub Gist"
|
||||
icon={<Github size={24} />}
|
||||
isConnected={isProviderReadyForSync(sync.providers.github)}
|
||||
isSyncing={sync.providers.github.status === 'syncing'}
|
||||
isConnecting={sync.providers.github.status === 'connecting'}
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={isConnectDisabled('github')}
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
extraActions={
|
||||
isProviderReadyForSync(sync.providers.github) ? (
|
||||
<Button size="sm" variant="ghost" onClick={handleOpenHistory} className="gap-1">
|
||||
<History size={14} />
|
||||
{t('cloudSync.revisionHistory.viewButton')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
provider="google"
|
||||
name="Google Drive"
|
||||
icon={<GoogleDriveIcon className="w-6 h-6" />}
|
||||
isConnected={isProviderReadyForSync(sync.providers.google)}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={
|
||||
sync.providers.google.status === 'connecting' ||
|
||||
sync.pendingBrowserAuthProvider === 'google'
|
||||
}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={isConnectDisabled('google')}
|
||||
onConnect={handleConnectGoogle}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
provider="onedrive"
|
||||
name="Microsoft OneDrive"
|
||||
icon={<OneDriveIcon className="w-6 h-6" />}
|
||||
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={
|
||||
sync.providers.onedrive.status === 'connecting' ||
|
||||
sync.pendingBrowserAuthProvider === 'onedrive'
|
||||
}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={
|
||||
sync.providers.onedrive.error
|
||||
? cleanOneDriveErrorMessage(sync.providers.onedrive.error)
|
||||
: undefined
|
||||
}
|
||||
disabled={isConnectDisabled('onedrive')}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
provider="webdav"
|
||||
name={t('cloudSync.provider.webdav')}
|
||||
icon={<Server size={24} />}
|
||||
isConnected={isProviderReadyForSync(sync.providers.webdav)}
|
||||
isSyncing={sync.providers.webdav.status === 'syncing'}
|
||||
isConnecting={sync.providers.webdav.status === 'connecting'}
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={isConnectDisabled('webdav')}
|
||||
onEdit={openWebdavDialog}
|
||||
onConnect={openWebdavDialog}
|
||||
onDisconnect={() => sync.disconnectProvider('webdav')}
|
||||
onSync={() => handleSync('webdav')}
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
provider="s3"
|
||||
name={t('cloudSync.provider.s3')}
|
||||
icon={<Database size={24} />}
|
||||
isConnected={isProviderReadyForSync(sync.providers.s3)}
|
||||
isSyncing={sync.providers.s3.status === 'syncing'}
|
||||
isConnecting={sync.providers.s3.status === 'connecting'}
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={isConnectDisabled('s3')}
|
||||
onEdit={openS3Dialog}
|
||||
onConnect={openS3Dialog}
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
onSync={() => handleSync('s3')}
|
||||
/>
|
||||
|
||||
{[...pluginProviderIds].sort().map((providerId) => {
|
||||
const connection = sync.providers[providerId];
|
||||
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
|
||||
const title = listed?.title ?? providerId;
|
||||
const connected = connection ? isProviderReadyForSync(connection) : false;
|
||||
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
|
||||
const needsConfig = planPluginSyncConnect({
|
||||
configurationSchema: listed?.configurationSchema,
|
||||
storedConfig: connection?.config,
|
||||
hasStoredConfig,
|
||||
}).action === 'prompt';
|
||||
return (
|
||||
<ProviderCard
|
||||
key={providerId}
|
||||
provider={providerId as CloudProvider}
|
||||
name={title}
|
||||
icon={<Plug size={24} />}
|
||||
isConnected={connected}
|
||||
isSyncing={connection?.status === 'syncing'}
|
||||
isConnecting={
|
||||
connection?.status === 'connecting'
|
||||
|| pluginConnectBusy === providerId
|
||||
}
|
||||
account={connection?.account
|
||||
? {
|
||||
// Never load plugin-supplied avatar URLs in the main
|
||||
// renderer (offline plugin network boundary).
|
||||
id: connection.account.id,
|
||||
name: connection.account.name,
|
||||
email: connection.account.email,
|
||||
}
|
||||
: undefined}
|
||||
lastSync={connection?.lastSync}
|
||||
error={connection?.error}
|
||||
disabled={isConnectDisabled(providerId as CloudProvider)}
|
||||
onEdit={
|
||||
connected || needsConfig || hasStoredConfig
|
||||
? () => openPluginConfigDialog(
|
||||
providerId,
|
||||
title,
|
||||
listed?.configurationSchema,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onConnect={() => {
|
||||
void handlePluginConnect(providerId);
|
||||
}}
|
||||
onDisconnect={() => sync.disconnectProvider(providerId as CloudProvider)}
|
||||
onSync={() => handleSync(providerId as CloudProvider)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Dialog
|
||||
open={pluginConfigDialog != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pluginConfigSaving) setPluginConfigDialog(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('cloudSync.pluginConfig.title', {
|
||||
name: pluginConfigDialog?.title ?? '',
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.pluginConfig.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
value={pluginConfigText}
|
||||
onChange={(event) => {
|
||||
setPluginConfigText(event.target.value);
|
||||
if (pluginConfigError) setPluginConfigError(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
disabled={pluginConfigSaving}
|
||||
className="min-h-40 w-full resize-y rounded-md border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t('cloudSync.pluginConfig.label')}
|
||||
/>
|
||||
{pluginConfigError ? (
|
||||
<p role="alert" className="text-xs text-destructive">{pluginConfigError}</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pluginConfigSaving}
|
||||
onClick={() => setPluginConfigDialog(null)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={pluginConfigSaving}
|
||||
onClick={() => { void handleSavePluginConfig(); }}
|
||||
>
|
||||
{pluginConfigSaving
|
||||
? t('cloudSync.provider.connecting')
|
||||
: t('cloudSync.provider.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SettingsAnchor>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="status" className="space-y-4">
|
||||
<ConvergentSyncPanel
|
||||
t={t}
|
||||
resolvedLocale={resolvedLocale}
|
||||
config={convergentConfig}
|
||||
preview={convergentPreview}
|
||||
busy={convergentBusy}
|
||||
error={convergentError}
|
||||
conflicts={convergentConflicts}
|
||||
onToggle={onToggleConvergent}
|
||||
onConfirmMigration={onConfirmConvergentMigration}
|
||||
onCancelMigration={onCancelConvergentMigration}
|
||||
onResolveConflict={onResolveConvergentConflict}
|
||||
onDowngrade={onDowngradeConvergent}
|
||||
/>
|
||||
|
||||
<SettingsAnchor anchorId="sync-auto-sync" className="p-4 rounded-lg border bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.autoSync.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.autoSync.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={sync.autoSyncEnabled}
|
||||
onChange={(enabled) => sync.setAutoSync(enabled)}
|
||||
disabled={!sync.hasAnyConnectedProvider}
|
||||
/>
|
||||
</div>
|
||||
</SettingsAnchor>
|
||||
|
||||
<SettingsAnchor anchorId="sync-strategy" className="p-4 rounded-lg border bg-card space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.strategy.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.strategy.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={sync.syncStrategy}
|
||||
onValueChange={(value) => sync.setSyncStrategy(value as typeof sync.syncStrategy)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('cloudSync.strategy.title')}
|
||||
className="h-10"
|
||||
>
|
||||
{sync.syncStrategy === 'preferCloud'
|
||||
? t('cloudSync.strategy.preferCloud')
|
||||
: sync.syncStrategy === 'preferLocal'
|
||||
? t('cloudSync.strategy.preferLocal')
|
||||
: t('cloudSync.strategy.smartMerge')}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-w-[min(520px,var(--radix-select-trigger-width))]">
|
||||
<SelectItem value="smartMerge" className="items-start py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t('cloudSync.strategy.smartMerge')}</div>
|
||||
<div className="text-xs text-muted-foreground leading-snug">
|
||||
{t('cloudSync.strategy.smartMergeDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="preferCloud" className="items-start py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t('cloudSync.strategy.preferCloud')}</div>
|
||||
<div className="text-xs text-muted-foreground leading-snug">
|
||||
{t('cloudSync.strategy.preferCloudDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="preferLocal" className="items-start py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t('cloudSync.strategy.preferLocal')}</div>
|
||||
<div className="text-xs text-muted-foreground leading-snug">
|
||||
{t('cloudSync.strategy.preferLocalDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsAnchor>
|
||||
|
||||
{sync.hasAnyConnectedProvider && (
|
||||
<div className="space-y-3">
|
||||
{/* Version Info Cards */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg border bg-card">
|
||||
<div className="text-xs text-muted-foreground mb-1">{t('cloudSync.status.localVersion')}</div>
|
||||
<div className="text-lg font-semibold">v{sync.localVersion}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{sync.localUpdatedAt
|
||||
? new Date(sync.localUpdatedAt).toLocaleString(resolvedLocale || undefined)
|
||||
: t('cloudSync.lastSync.never')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-card">
|
||||
<div className="text-xs text-muted-foreground mb-1">{t('cloudSync.status.remoteVersion')}</div>
|
||||
<div className="text-lg font-semibold">v{sync.remoteVersion}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{sync.remoteUpdatedAt
|
||||
? new Date(sync.remoteUpdatedAt).toLocaleString(resolvedLocale || undefined)
|
||||
: t('cloudSync.lastSync.never')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync History */}
|
||||
{sync.syncHistory.length > 0 && (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="px-3 py-2 border-b border-border/60">
|
||||
<div className="text-sm font-medium">{t('cloudSync.history.title')}</div>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{sync.syncHistory.slice(0, 10).map((entry) => (
|
||||
<div key={entry.id} className="px-3 py-2 flex items-center gap-2 border-b border-border/30 last:border-b-0">
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
entry.success ? "bg-green-500" : "bg-red-500"
|
||||
)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium capitalize">
|
||||
{entry.action === 'upload'
|
||||
? t('cloudSync.history.upload')
|
||||
: entry.action === 'download'
|
||||
? t('cloudSync.history.download')
|
||||
: t('cloudSync.history.resolved')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{entry.localVersion}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{new Date(entry.timestamp).toLocaleString(resolvedLocale || undefined)}
|
||||
{entry.deviceName && ` · ${entry.deviceName}`}
|
||||
</div>
|
||||
</div>
|
||||
{entry.error && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs text-red-500 truncate max-w-24 cursor-default">
|
||||
{t('cloudSync.history.error')}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{entry.error}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsAnchor anchorId="sync-local-backups">
|
||||
<div ref={localBackupsRef}>
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyLocalPayload ?? onApplyPayload}
|
||||
/>
|
||||
</div>
|
||||
</SettingsAnchor>
|
||||
|
||||
{/* Clear Local Data */}
|
||||
<SettingsAnchor anchorId="sync-clear-local" className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.clearLocal.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.clearLocal.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowClearLocalDialog(true)}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t('cloudSync.clearLocal.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsAnchor>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
940
components/cloud-sync/CloudSyncDialogs.tsx
Normal file
940
components/cloud-sync/CloudSyncDialogs.tsx
Normal file
@@ -0,0 +1,940 @@
|
||||
import React, { type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
|
||||
import { AlertTriangle, Cloud, Database, Download, History, Key, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import type { CloudProvider, ConflictResolution, SyncPayload, SyncResult, WebDAVAuthType } from '../../domain/sync';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import type { useCloudSync } from '../../application/state/useCloudSync';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { toast } from '../ui/toast';
|
||||
import { ConflictModal, GitHubDeviceFlowModal } from './CloudSyncControls';
|
||||
|
||||
type SyncController = ReturnType<typeof useCloudSync>;
|
||||
type TextValues = Record<string, string | number>;
|
||||
type Translate = (key: string, values?: TextValues) => string;
|
||||
type StringSetter = Dispatch<SetStateAction<string>>;
|
||||
type BooleanSetter = Dispatch<SetStateAction<boolean>>;
|
||||
|
||||
type HistoryPreview = {
|
||||
sha: string;
|
||||
payload: SyncPayload;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
noteCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
deviceName?: string;
|
||||
version?: number;
|
||||
} | null;
|
||||
|
||||
interface CloudSyncDialogsProps {
|
||||
t: Translate;
|
||||
sync: SyncController;
|
||||
showGitHubModal: boolean;
|
||||
gitHubUserCode: string;
|
||||
gitHubVerificationUri: string;
|
||||
isPollingGitHub: boolean;
|
||||
activeGitHubAttemptIdRef: MutableRefObject<number | null>;
|
||||
setShowGitHubModal: BooleanSetter;
|
||||
setIsPollingGitHub: BooleanSetter;
|
||||
endPendingConnect: (provider: CloudProvider) => void;
|
||||
showConflictModal: boolean;
|
||||
setShowConflictModal: BooleanSetter;
|
||||
handleResolveConflict: (resolution: ConflictResolution) => Promise<void>;
|
||||
showHistoryModal: boolean;
|
||||
setShowHistoryModal: BooleanSetter;
|
||||
historyError: string | null;
|
||||
historyLoading: boolean;
|
||||
historyPreview: HistoryPreview;
|
||||
setHistoryPreview: Dispatch<SetStateAction<HistoryPreview>>;
|
||||
historyPreviewLoading: boolean;
|
||||
historyRevisions: Array<{ version: string; date: Date }>;
|
||||
handlePreviewRevision: (sha: string) => Promise<void>;
|
||||
handleRestoreRevision: () => Promise<void>;
|
||||
showWebdavDialog: boolean;
|
||||
setShowWebdavDialog: BooleanSetter;
|
||||
webdavEndpoint: string;
|
||||
setWebdavEndpoint: StringSetter;
|
||||
webdavAuthType: WebDAVAuthType;
|
||||
setWebdavAuthType: Dispatch<SetStateAction<WebDAVAuthType>>;
|
||||
webdavUsername: string;
|
||||
setWebdavUsername: StringSetter;
|
||||
webdavPassword: string;
|
||||
setWebdavPassword: StringSetter;
|
||||
webdavToken: string;
|
||||
setWebdavToken: StringSetter;
|
||||
showWebdavSecret: boolean;
|
||||
setShowWebdavSecret: BooleanSetter;
|
||||
webdavAllowInsecure: boolean;
|
||||
setWebdavAllowInsecure: BooleanSetter;
|
||||
webdavError: string | null;
|
||||
webdavErrorDetail: string | null;
|
||||
isSavingWebdav: boolean;
|
||||
handleSaveWebdav: () => Promise<void>;
|
||||
showS3Dialog: boolean;
|
||||
setShowS3Dialog: BooleanSetter;
|
||||
s3Endpoint: string;
|
||||
setS3Endpoint: StringSetter;
|
||||
s3Region: string;
|
||||
setS3Region: StringSetter;
|
||||
s3Bucket: string;
|
||||
setS3Bucket: StringSetter;
|
||||
s3AccessKeyId: string;
|
||||
setS3AccessKeyId: StringSetter;
|
||||
s3SecretAccessKey: string;
|
||||
setS3SecretAccessKey: StringSetter;
|
||||
s3SessionToken: string;
|
||||
setS3SessionToken: StringSetter;
|
||||
s3Prefix: string;
|
||||
setS3Prefix: StringSetter;
|
||||
s3ForcePathStyle: boolean;
|
||||
setS3ForcePathStyle: BooleanSetter;
|
||||
s3AllowInsecure: boolean;
|
||||
setS3AllowInsecure: BooleanSetter;
|
||||
showS3Secret: boolean;
|
||||
setShowS3Secret: BooleanSetter;
|
||||
s3Error: string | null;
|
||||
s3ErrorDetail: string | null;
|
||||
isSavingS3: boolean;
|
||||
handleSaveS3: () => Promise<void>;
|
||||
showChangeKeyDialog: boolean;
|
||||
setShowChangeKeyDialog: BooleanSetter;
|
||||
currentMasterKey: string;
|
||||
setCurrentMasterKey: StringSetter;
|
||||
newMasterKey: string;
|
||||
setNewMasterKey: StringSetter;
|
||||
confirmNewMasterKey: string;
|
||||
setConfirmNewMasterKey: StringSetter;
|
||||
showMasterKey: boolean;
|
||||
setShowMasterKey: BooleanSetter;
|
||||
changeKeyError: string | null;
|
||||
setChangeKeyError: Dispatch<SetStateAction<string | null>>;
|
||||
isChangingKey: boolean;
|
||||
setIsChangingKey: BooleanSetter;
|
||||
showUnlockDialog: boolean;
|
||||
setShowUnlockDialog: BooleanSetter;
|
||||
unlockMasterKey: string;
|
||||
setUnlockMasterKey: StringSetter;
|
||||
showUnlockMasterKey: boolean;
|
||||
setShowUnlockMasterKey: BooleanSetter;
|
||||
unlockError: string | null;
|
||||
setUnlockError: Dispatch<SetStateAction<string | null>>;
|
||||
isUnlocking: boolean;
|
||||
setIsUnlocking: BooleanSetter;
|
||||
showClearLocalDialog: boolean;
|
||||
setShowClearLocalDialog: BooleanSetter;
|
||||
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyConvergentPayload: (
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
ensureSyncablePayload: (payload: SyncPayload) => boolean;
|
||||
showForcePushConfirm: boolean;
|
||||
setShowForcePushConfirm: BooleanSetter;
|
||||
blockedFinding: Extract<ShrinkFinding, { suspicious: true }> | null;
|
||||
setBlockedFinding: Dispatch<SetStateAction<Extract<ShrinkFinding, { suspicious: true }> | null>>;
|
||||
}
|
||||
|
||||
export const CloudSyncDialogs: React.FC<CloudSyncDialogsProps> = ({
|
||||
t,
|
||||
sync,
|
||||
showGitHubModal,
|
||||
gitHubUserCode,
|
||||
gitHubVerificationUri,
|
||||
isPollingGitHub,
|
||||
activeGitHubAttemptIdRef,
|
||||
setShowGitHubModal,
|
||||
setIsPollingGitHub,
|
||||
endPendingConnect,
|
||||
showConflictModal,
|
||||
setShowConflictModal,
|
||||
handleResolveConflict,
|
||||
showHistoryModal,
|
||||
setShowHistoryModal,
|
||||
historyError,
|
||||
historyLoading,
|
||||
historyPreview,
|
||||
setHistoryPreview,
|
||||
historyPreviewLoading,
|
||||
historyRevisions,
|
||||
handlePreviewRevision,
|
||||
handleRestoreRevision,
|
||||
showWebdavDialog,
|
||||
setShowWebdavDialog,
|
||||
webdavEndpoint,
|
||||
setWebdavEndpoint,
|
||||
webdavAuthType,
|
||||
setWebdavAuthType,
|
||||
webdavUsername,
|
||||
setWebdavUsername,
|
||||
webdavPassword,
|
||||
setWebdavPassword,
|
||||
webdavToken,
|
||||
setWebdavToken,
|
||||
showWebdavSecret,
|
||||
setShowWebdavSecret,
|
||||
webdavAllowInsecure,
|
||||
setWebdavAllowInsecure,
|
||||
webdavError,
|
||||
webdavErrorDetail,
|
||||
isSavingWebdav,
|
||||
handleSaveWebdav,
|
||||
showS3Dialog,
|
||||
setShowS3Dialog,
|
||||
s3Endpoint,
|
||||
setS3Endpoint,
|
||||
s3Region,
|
||||
setS3Region,
|
||||
s3Bucket,
|
||||
setS3Bucket,
|
||||
s3AccessKeyId,
|
||||
setS3AccessKeyId,
|
||||
s3SecretAccessKey,
|
||||
setS3SecretAccessKey,
|
||||
s3SessionToken,
|
||||
setS3SessionToken,
|
||||
s3Prefix,
|
||||
setS3Prefix,
|
||||
s3ForcePathStyle,
|
||||
setS3ForcePathStyle,
|
||||
s3AllowInsecure,
|
||||
setS3AllowInsecure,
|
||||
showS3Secret,
|
||||
setShowS3Secret,
|
||||
s3Error,
|
||||
s3ErrorDetail,
|
||||
isSavingS3,
|
||||
handleSaveS3,
|
||||
showChangeKeyDialog,
|
||||
setShowChangeKeyDialog,
|
||||
currentMasterKey,
|
||||
setCurrentMasterKey,
|
||||
newMasterKey,
|
||||
setNewMasterKey,
|
||||
confirmNewMasterKey,
|
||||
setConfirmNewMasterKey,
|
||||
showMasterKey,
|
||||
setShowMasterKey,
|
||||
changeKeyError,
|
||||
setChangeKeyError,
|
||||
isChangingKey,
|
||||
setIsChangingKey,
|
||||
showUnlockDialog,
|
||||
setShowUnlockDialog,
|
||||
unlockMasterKey,
|
||||
setUnlockMasterKey,
|
||||
showUnlockMasterKey,
|
||||
setShowUnlockMasterKey,
|
||||
unlockError,
|
||||
setUnlockError,
|
||||
isUnlocking,
|
||||
setIsUnlocking,
|
||||
showClearLocalDialog,
|
||||
setShowClearLocalDialog,
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
onApplyConvergentPayload,
|
||||
onClearLocalData,
|
||||
ensureSyncablePayload,
|
||||
showForcePushConfirm,
|
||||
setShowForcePushConfirm,
|
||||
blockedFinding,
|
||||
setBlockedFinding
|
||||
}) => (
|
||||
<>
|
||||
{/* Modals */}
|
||||
<GitHubDeviceFlowModal
|
||||
isOpen={showGitHubModal}
|
||||
userCode={gitHubUserCode}
|
||||
verificationUri={gitHubVerificationUri}
|
||||
isPolling={isPollingGitHub}
|
||||
onClose={() => {
|
||||
activeGitHubAttemptIdRef.current = null;
|
||||
setShowGitHubModal(false);
|
||||
setIsPollingGitHub(false);
|
||||
endPendingConnect('github');
|
||||
sync.cancelOAuthConnect();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConflictModal
|
||||
open={showConflictModal}
|
||||
conflict={sync.currentConflict}
|
||||
onResolve={handleResolveConflict}
|
||||
onClose={() => setShowConflictModal(false)}
|
||||
/>
|
||||
|
||||
{/* Gist Revision History Modal (#679) */}
|
||||
<Dialog open={showHistoryModal} onOpenChange={setShowHistoryModal}>
|
||||
<DialogContent className="sm:max-w-[520px] max-h-[80vh] overflow-hidden flex flex-col z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History size={18} />
|
||||
{t('cloudSync.revisionHistory.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('cloudSync.revisionHistory.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{historyError && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-500">
|
||||
{historyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : historyPreview ? (
|
||||
// Preview of a selected revision
|
||||
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<div className="text-sm font-medium">{t('cloudSync.revisionHistory.revisionPreview')}</div>
|
||||
{historyPreview.deviceName && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.revisionHistory.device')}: {historyPreview.deviceName}
|
||||
{historyPreview.version != null && ` · v${historyPreview.version}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.hosts')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.hostCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.keys')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.keyCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.snippets')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.snippetCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.notes')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.noteCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.identities')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.identityCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setHistoryPreview(null)}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button onClick={handleRestoreRevision} className="gap-1">
|
||||
<Download size={14} />
|
||||
{t('cloudSync.revisionHistory.restoreButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
// Revision list
|
||||
<div className="overflow-y-auto flex-1 min-h-0 -mx-1">
|
||||
{historyRevisions.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
{t('cloudSync.revisionHistory.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 px-1">
|
||||
{historyRevisions.map((rev, index) => (
|
||||
<button
|
||||
key={rev.version}
|
||||
onClick={() => handlePreviewRevision(rev.version)}
|
||||
disabled={historyPreviewLoading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between p-2.5 rounded-lg text-left text-sm transition-colors",
|
||||
"hover:bg-muted/50 border border-transparent hover:border-border",
|
||||
index === 0 && "bg-primary/5 border-primary/20",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{index === 0 ? t('cloudSync.revisionHistory.current') : `${t('cloudSync.revisionHistory.revision')} #${historyRevisions.length - index}`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{rev.date.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{rev.version.slice(0, 7)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyPreviewLoading && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center rounded-lg">
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showWebdavDialog} onOpenChange={setShowWebdavDialog}>
|
||||
<DialogContent className="sm:max-w-[460px] max-h-[80vh] overflow-y-auto z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloudSync.webdav.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloudSync.webdav.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.webdav.endpoint')}</Label>
|
||||
<Input
|
||||
value={webdavEndpoint}
|
||||
onChange={(e) => setWebdavEndpoint(e.target.value)}
|
||||
placeholder="https://dav.example.com/remote.php/webdav/"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.webdav.authType')}</Label>
|
||||
<Select value={webdavAuthType} onValueChange={(value) => setWebdavAuthType(value as WebDAVAuthType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">{t('cloudSync.webdav.auth.basic')}</SelectItem>
|
||||
<SelectItem value="digest">{t('cloudSync.webdav.auth.digest')}</SelectItem>
|
||||
<SelectItem value="token">{t('cloudSync.webdav.auth.token')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{webdavAuthType !== 'token' ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.webdav.username')}</Label>
|
||||
<Input
|
||||
value={webdavUsername}
|
||||
onChange={(e) => setWebdavUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.webdav.password')}</Label>
|
||||
<Input
|
||||
type={showWebdavSecret ? 'text' : 'password'}
|
||||
value={webdavPassword}
|
||||
onChange={(e) => setWebdavPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.webdav.token')}</Label>
|
||||
<Input
|
||||
type={showWebdavSecret ? 'text' : 'password'}
|
||||
value={webdavToken}
|
||||
onChange={(e) => setWebdavToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showWebdavSecret}
|
||||
onChange={(e) => setShowWebdavSecret(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{t('cloudSync.webdav.showSecret')}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={webdavAllowInsecure}
|
||||
onChange={(e) => setWebdavAllowInsecure(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{t('cloudSync.webdav.allowInsecure')}
|
||||
</label>
|
||||
|
||||
{webdavError && (
|
||||
<p className="text-sm text-red-500">{webdavError}</p>
|
||||
)}
|
||||
{webdavErrorDetail && (
|
||||
<pre className="text-xs text-red-400 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-2">
|
||||
{webdavErrorDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowWebdavDialog(false)}
|
||||
disabled={isSavingWebdav}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveWebdav}
|
||||
disabled={isSavingWebdav}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingWebdav ? <Loader2 size={16} className="animate-spin" /> : <Cloud size={16} />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showS3Dialog} onOpenChange={setShowS3Dialog}>
|
||||
<DialogContent className="sm:max-w-[520px] max-h-[80vh] overflow-y-auto z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloudSync.s3.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloudSync.s3.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.s3.endpoint')}</Label>
|
||||
<Input
|
||||
value={s3Endpoint}
|
||||
onChange={(e) => setS3Endpoint(e.target.value)}
|
||||
placeholder="https://s3.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.s3.region')}</Label>
|
||||
<Input
|
||||
value={s3Region}
|
||||
onChange={(e) => setS3Region(e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.s3.bucket')}</Label>
|
||||
<Input
|
||||
value={s3Bucket}
|
||||
onChange={(e) => setS3Bucket(e.target.value)}
|
||||
placeholder="netcatty-backups"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.s3.accessKeyId')}</Label>
|
||||
<Input
|
||||
value={s3AccessKeyId}
|
||||
onChange={(e) => setS3AccessKeyId(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.s3.secretAccessKey')}</Label>
|
||||
<Input
|
||||
type={showS3Secret ? 'text' : 'password'}
|
||||
value={s3SecretAccessKey}
|
||||
onChange={(e) => setS3SecretAccessKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.s3.sessionToken')}</Label>
|
||||
<Input
|
||||
type={showS3Secret ? 'text' : 'password'}
|
||||
value={s3SessionToken}
|
||||
onChange={(e) => setS3SessionToken(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.s3.prefix')}</Label>
|
||||
<Input
|
||||
value={s3Prefix}
|
||||
onChange={(e) => setS3Prefix(e.target.value)}
|
||||
placeholder="backups/netcatty"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s3ForcePathStyle}
|
||||
onChange={(e) => setS3ForcePathStyle(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{t('cloudSync.s3.forcePathStyle')}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s3AllowInsecure}
|
||||
onChange={(e) => setS3AllowInsecure(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{t('cloudSync.s3.allowInsecure')}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showS3Secret}
|
||||
onChange={(e) => setShowS3Secret(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{t('cloudSync.s3.showSecret')}
|
||||
</label>
|
||||
|
||||
{s3Error && (
|
||||
<p className="text-sm text-red-500">{s3Error}</p>
|
||||
)}
|
||||
{s3ErrorDetail && (
|
||||
<pre className="text-xs text-red-400 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-2">
|
||||
{s3ErrorDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowS3Dialog(false)}
|
||||
disabled={isSavingS3}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveS3}
|
||||
disabled={isSavingS3}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingS3 ? <Loader2 size={16} className="animate-spin" /> : <Database size={16} />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showChangeKeyDialog} onOpenChange={setShowChangeKeyDialog}>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloudSync.changeKey.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.changeKey.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.changeKey.current')}</Label>
|
||||
<Input
|
||||
type={showMasterKey ? 'text' : 'password'}
|
||||
value={currentMasterKey}
|
||||
onChange={(e) => setCurrentMasterKey(e.target.value)}
|
||||
placeholder={t('cloudSync.changeKey.currentPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.changeKey.new')}</Label>
|
||||
<Input
|
||||
type={showMasterKey ? 'text' : 'password'}
|
||||
value={newMasterKey}
|
||||
onChange={(e) => setNewMasterKey(e.target.value)}
|
||||
placeholder={t('cloudSync.changeKey.newPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.changeKey.confirmNew')}</Label>
|
||||
<Input
|
||||
type={showMasterKey ? 'text' : 'password'}
|
||||
value={confirmNewMasterKey}
|
||||
onChange={(e) => setConfirmNewMasterKey(e.target.value)}
|
||||
placeholder={t('cloudSync.changeKey.confirmPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMasterKey}
|
||||
onChange={(e) => setShowMasterKey(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{t('cloudSync.changeKey.showKeys')}
|
||||
</label>
|
||||
|
||||
{changeKeyError && (
|
||||
<p className="text-sm text-red-500">{changeKeyError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowChangeKeyDialog(false)}
|
||||
disabled={isChangingKey}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setChangeKeyError(null);
|
||||
if (!currentMasterKey || !newMasterKey || !confirmNewMasterKey) {
|
||||
setChangeKeyError(t('cloudSync.changeKey.fillAll'));
|
||||
return;
|
||||
}
|
||||
if (newMasterKey.length < 8) {
|
||||
setChangeKeyError(t('cloudSync.changeKey.minLength'));
|
||||
return;
|
||||
}
|
||||
if (newMasterKey !== confirmNewMasterKey) {
|
||||
setChangeKeyError(t('cloudSync.changeKey.notMatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
let payloadForReencrypt: SyncPayload | null = null;
|
||||
if (sync.hasAnyConnectedProvider) {
|
||||
const payload = await onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) {
|
||||
setChangeKeyError(t('sync.credentialsUnavailable'));
|
||||
return;
|
||||
}
|
||||
payloadForReencrypt = payload;
|
||||
}
|
||||
|
||||
setIsChangingKey(true);
|
||||
try {
|
||||
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
|
||||
if (!ok) {
|
||||
setChangeKeyError(t('cloudSync.changeKey.incorrectCurrent'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payloadForReencrypt) {
|
||||
await sync.syncNow(payloadForReencrypt, {
|
||||
applyConvergentPayload: onApplyConvergentPayload,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(t('cloudSync.changeKey.updatedToast'));
|
||||
setShowChangeKeyDialog(false);
|
||||
} catch (error) {
|
||||
setChangeKeyError(error instanceof Error ? error.message : t('cloudSync.changeKey.failed'));
|
||||
} finally {
|
||||
setIsChangingKey(false);
|
||||
}
|
||||
}}
|
||||
disabled={isChangingKey}
|
||||
className="gap-2"
|
||||
>
|
||||
{isChangingKey ? <Loader2 size={16} className="animate-spin" /> : <Key size={16} />}
|
||||
{t('cloudSync.changeKey.updateButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showUnlockDialog} onOpenChange={setShowUnlockDialog}>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloudSync.unlock.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.unlock.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('cloudSync.unlock.masterKey')}</Label>
|
||||
<Input
|
||||
type={showUnlockMasterKey ? 'text' : 'password'}
|
||||
value={unlockMasterKey}
|
||||
onChange={(e) => setUnlockMasterKey(e.target.value)}
|
||||
placeholder={t('cloudSync.unlock.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUnlockMasterKey}
|
||||
onChange={(e) => setShowUnlockMasterKey(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{t('cloudSync.unlock.showKey')}
|
||||
</label>
|
||||
|
||||
{unlockError && (
|
||||
<p className="text-sm text-red-500">{unlockError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowUnlockDialog(false)}
|
||||
disabled={isUnlocking}
|
||||
>
|
||||
{t('cloudSync.unlock.notNow')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setUnlockError(null);
|
||||
if (!unlockMasterKey) {
|
||||
setUnlockError(t('cloudSync.unlock.empty'));
|
||||
return;
|
||||
}
|
||||
setIsUnlocking(true);
|
||||
try {
|
||||
const ok = await sync.unlock(unlockMasterKey);
|
||||
if (!ok) {
|
||||
setUnlockError(t('cloudSync.unlock.incorrect'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('cloudSync.unlock.readyToast'));
|
||||
setShowUnlockDialog(false);
|
||||
setUnlockMasterKey('');
|
||||
} catch (error) {
|
||||
setUnlockError(error instanceof Error ? error.message : t('cloudSync.unlock.failed'));
|
||||
} finally {
|
||||
setIsUnlocking(false);
|
||||
}
|
||||
}}
|
||||
disabled={isUnlocking}
|
||||
className="gap-2"
|
||||
>
|
||||
{isUnlocking ? <Loader2 size={16} className="animate-spin" /> : <ShieldCheck size={16} />}
|
||||
{t('cloudSync.unlock.unlockButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Clear Local Data Confirmation Dialog */}
|
||||
<Dialog open={showClearLocalDialog} onOpenChange={setShowClearLocalDialog}>
|
||||
<DialogContent className="sm:max-w-[400px] z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t('cloudSync.clearLocal.dialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.clearLocal.dialog.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowClearLocalDialog(false)}
|
||||
>
|
||||
{t('cloudSync.clearLocal.dialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
onClearLocalData?.();
|
||||
sync.resetLocalVersion();
|
||||
setShowClearLocalDialog(false);
|
||||
toast.success(t('cloudSync.clearLocal.toast.desc'), t('cloudSync.clearLocal.toast.title'));
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t('cloudSync.clearLocal.dialog.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Force-push confirmation modal (Task 8) */}
|
||||
{showForcePushConfirm && blockedFinding && (
|
||||
<Dialog open onOpenChange={(open) => !open && setShowForcePushConfirm(false)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('sync.forcePush.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
{t('sync.forcePush.body', {
|
||||
lost: blockedFinding.lost,
|
||||
entityType: t(`sync.entityType.${blockedFinding.entityType}`),
|
||||
})}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowForcePushConfirm(false)}>
|
||||
{t('sync.forcePush.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const localPayload = await onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) {
|
||||
setShowForcePushConfirm(false);
|
||||
return;
|
||||
}
|
||||
setShowForcePushConfirm(false);
|
||||
try {
|
||||
const results = await sync.syncNow(localPayload, {
|
||||
overrideShrink: true,
|
||||
applyConvergentPayload: onApplyConvergentPayload,
|
||||
});
|
||||
|
||||
// Apply any merged payload BEFORE clearing the banner. If a merge happened
|
||||
// during force-push (remote changed), the merged result is what the cloud
|
||||
// now has — applying it to local state prevents the next sync from
|
||||
// re-deleting the remote additions we just merged in.
|
||||
for (const result of results.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; // All providers share the same merged payload
|
||||
}
|
||||
}
|
||||
|
||||
const syncResults = Array.from(results.values()) as SyncResult[];
|
||||
const allOk = syncResults.every((r) => r.success);
|
||||
if (allOk) {
|
||||
setBlockedFinding(null);
|
||||
} else {
|
||||
// Surface the failure but KEEP the banner so the user can retry or
|
||||
// restore. Find the first error string to display.
|
||||
const firstError = syncResults
|
||||
.find((r) => !r.success)
|
||||
?.error ?? t('sync.toast.errorTitle');
|
||||
toast.error(firstError, t('sync.toast.errorTitle'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(String(err), t('sync.toast.errorTitle'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('sync.forcePush.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
446
components/cloud-sync/CloudSyncLocalBackupsPanel.tsx
Normal file
446
components/cloud-sync/CloudSyncLocalBackupsPanel.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* CloudSyncSettings - End-to-End Encrypted Cloud Sync UI
|
||||
*
|
||||
* Handles:
|
||||
* - Master key setup (gatekeeper screen)
|
||||
* - Provider connections (GitHub, Google, OneDrive)
|
||||
* - Sync status and conflict resolution
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Download,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
|
||||
import {
|
||||
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
withRestoreBarrier,
|
||||
} from '../../application/localVaultBackups';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
|
||||
import { type SyncPayload } from '../../domain/sync';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Input } from '../ui/input';
|
||||
import { toast } from '../ui/toast';
|
||||
|
||||
// ============================================================================
|
||||
interface LocalBackupsPanelProps {
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
/**
|
||||
* When true, the panel hides the Restore button entirely — e.g. while the
|
||||
* master key has not been configured yet, a restore would land credentials
|
||||
* on disk in plaintext (I3). Listing is still allowed so users can see that
|
||||
* their history exists.
|
||||
*/
|
||||
restoreDisabledReason?: 'no-master-key' | null;
|
||||
}
|
||||
|
||||
export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
onApplyPayload,
|
||||
restoreDisabledReason = null,
|
||||
}) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const {
|
||||
backups,
|
||||
isLoading,
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
readBackup,
|
||||
setMaxBackups,
|
||||
openBackupDirectory,
|
||||
} = useLocalVaultBackups();
|
||||
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
|
||||
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
|
||||
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
|
||||
// Backup chosen in the list but not yet confirmed. A two-step flow keeps
|
||||
// users from wiping their vault with a single accidental click (I2).
|
||||
const [pendingRestoreBackup, setPendingRestoreBackup] = useState<
|
||||
(typeof backups)[number] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxBackupsInput(String(maxBackups));
|
||||
}, [maxBackups]);
|
||||
|
||||
const formatTimestamp = (timestamp: number) =>
|
||||
new Date(timestamp).toLocaleString(resolvedLocale || undefined);
|
||||
|
||||
const getReasonLabel = (reason: 'app_version_change' | 'before_restore') =>
|
||||
reason === 'app_version_change'
|
||||
? t('cloudSync.localBackups.reason.appVersionChange')
|
||||
: t('cloudSync.localBackups.reason.beforeRestore');
|
||||
|
||||
const handleSaveMaxBackups = async () => {
|
||||
// Validate BEFORE calling setMaxBackups, which hands off to the
|
||||
// renderer's `sanitizeLocalVaultBackupMaxCount` clamp. Two failure
|
||||
// modes must be surfaced rather than silently clamped, because
|
||||
// both produce a misleading "saved" toast:
|
||||
//
|
||||
// 1. Empty / non-numeric input — `Number("")` coerces to 0 and
|
||||
// sanitize clamps to the default (20). A user who meant to
|
||||
// clear the field then re-type would see their retention
|
||||
// silently reset to 20 with a success message.
|
||||
//
|
||||
// 2. Out-of-range input (e.g. 500) — sanitize clamps to 100 and
|
||||
// still reports success, but the visible error string says
|
||||
// "between 1 and 100", so the user has no idea their value
|
||||
// was changed. Reject explicitly instead.
|
||||
//
|
||||
// The 1..MAX range check mirrors the main-process `sanitizeMaxCount`
|
||||
// in vaultBackupBridge.cjs so renderer and bridge agree.
|
||||
const parsed = Number(maxBackupsInput);
|
||||
const inRange =
|
||||
Number.isFinite(parsed) &&
|
||||
parsed >= MIN_LOCAL_VAULT_BACKUP_MAX_COUNT &&
|
||||
parsed <= MAX_LOCAL_VAULT_BACKUP_MAX_COUNT;
|
||||
if (!inRange || maxBackupsInput.trim() === '') {
|
||||
toast.error(
|
||||
t('cloudSync.localBackups.maxInvalid'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsSavingMaxBackups(true);
|
||||
try {
|
||||
const next = await setMaxBackups(parsed);
|
||||
setMaxBackupsInput(String(next));
|
||||
toast.success(t('cloudSync.localBackups.maxSaved', { count: String(next) }));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
} finally {
|
||||
setIsSavingMaxBackups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBackupDirectory = async () => {
|
||||
try {
|
||||
await openBackupDirectory();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const performRestore = async (backupId: string) => {
|
||||
setRestoringBackupId(backupId);
|
||||
try {
|
||||
// Hold the cross-window restore barrier around both the load
|
||||
// and the apply so another window's auto-sync cannot push a
|
||||
// pre-restore snapshot concurrently. See `withRestoreBarrier`
|
||||
// in application/localVaultBackups.ts for the read-side in
|
||||
// useAutoSync.
|
||||
//
|
||||
// In-memory React state refresh is implicit: `onApplyPayload`
|
||||
// (supplied by the hosting screen) routes through
|
||||
// `applySyncPayload` → `importDataFromString` → store writes
|
||||
// → the hook-store listeners in `useVaultState` /
|
||||
// `useCustomThemes` / etc. We do NOT explicitly re-pull host
|
||||
// lists here because a future refactor that decouples those
|
||||
// stores from the apply path would silently break the UI
|
||||
// refresh in a way that's only visible after a manual
|
||||
// restart. Any change to that chain must either preserve
|
||||
// store-listener notification OR add an explicit
|
||||
// `rehydrateAllFromStorage` call here — do not assume
|
||||
// restore is "just" a payload swap.
|
||||
await withRestoreBarrier(async () => {
|
||||
const detail = await readBackup(backupId);
|
||||
if (!detail) {
|
||||
throw new Error(t('cloudSync.localBackups.restoreMissing'));
|
||||
}
|
||||
await Promise.resolve(onApplyPayload(detail.payload));
|
||||
});
|
||||
await refreshBackups();
|
||||
toast.success(t('cloudSync.localBackups.restoreSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.localBackups.restoreFailedTitle'),
|
||||
);
|
||||
} finally {
|
||||
setRestoringBackupId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAllowed = restoreDisabledReason === null;
|
||||
// While encryptionAvailable is still `null` we're mid-probe — render the
|
||||
// restore button as disabled so the user never sees a path they can't
|
||||
// actually take (I1 surface). Once resolved, `false` hides the panel body
|
||||
// via the unavailable banner below.
|
||||
const encryptionResolved = encryptionAvailable !== null;
|
||||
const encryptionUsable = encryptionAvailable === true;
|
||||
|
||||
// safeStorage probe finished and returned "not available" → disable the
|
||||
// panel entirely; the main process refuses to write in this state (I1).
|
||||
if (encryptionResolved && !encryptionUsable) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm font-medium">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.unavailableDesc')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-lg">
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.retentionTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.retentionDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 md:min-w-[260px] md:shrink-0">
|
||||
<div className="flex items-end gap-2 md:justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxBackupsInput}
|
||||
onChange={(e) => setMaxBackupsInput(e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveMaxBackups()}
|
||||
disabled={isSavingMaxBackups}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!restoreAllowed && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
|
||||
<AlertTriangle size={14} />
|
||||
<span className="font-medium">
|
||||
{t('cloudSync.localBackups.lockedTitle')}
|
||||
</span>
|
||||
</div>
|
||||
{t('cloudSync.localBackups.lockedDesc')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshBackups()}
|
||||
disabled={isLoading}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
|
||||
{t('settings.system.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenBackupDirectory()}
|
||||
className="gap-1"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('settings.system.openFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backups.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
|
||||
{t('cloudSync.localBackups.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">
|
||||
{backup.syncDataVersion
|
||||
? `v${backup.syncDataVersion}`
|
||||
: formatTimestamp(backup.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(backup.reason)}</span>
|
||||
{backup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(backup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{backup.sourceAppVersion && backup.targetAppVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
hosts: String(backup.preview.hostCount),
|
||||
keys: String(backup.preview.keyCount),
|
||||
snippets: String(backup.preview.snippetCount),
|
||||
notes: String(backup.preview.noteCount ?? 0),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{restoreAllowed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(backup)}
|
||||
// Disable every row while ANY restore is in
|
||||
// flight. Each restore runs a full
|
||||
// `applyProtectedSyncPayload` — multiple
|
||||
// localStorage writes + the apply-in-progress
|
||||
// sentinel. `withRestoreBarrier` serializes
|
||||
// across windows but does NOT serialize
|
||||
// same-window re-entry, so two overlapping
|
||||
// clicks here would interleave destructive
|
||||
// writes and the second run's sentinel-clear
|
||||
// could mask a still-partial first apply.
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId === backup.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore confirmation dialog (I2). Keeps the destructive action
|
||||
gated behind an explicit second click, mirroring the clear-local
|
||||
dialog elsewhere in this screen. */}
|
||||
<Dialog
|
||||
open={pendingRestoreBackup !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingRestoreBackup(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[440px] z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t('cloudSync.localBackups.restoreConfirmTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.localBackups.restoreConfirmDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pendingRestoreBackup && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
|
||||
<div className="font-medium">
|
||||
{pendingRestoreBackup.syncDataVersion
|
||||
? `v${pendingRestoreBackup.syncDataVersion}`
|
||||
: formatTimestamp(pendingRestoreBackup.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
|
||||
{pendingRestoreBackup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: pendingRestoreBackup.sourceAppVersion,
|
||||
to: pendingRestoreBackup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
hosts: String(pendingRestoreBackup.preview.hostCount),
|
||||
keys: String(pendingRestoreBackup.preview.keyCount),
|
||||
snippets: String(pendingRestoreBackup.preview.snippetCount),
|
||||
notes: String(pendingRestoreBackup.preview.noteCount ?? 0),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(null)}
|
||||
disabled={restoringBackupId !== null}
|
||||
>
|
||||
{t('cloudSync.localBackups.restoreConfirmCancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const target = pendingRestoreBackup;
|
||||
if (!target) return;
|
||||
setPendingRestoreBackup(null);
|
||||
await performRestore(target.id);
|
||||
}}
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId !== null ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restoreConfirmButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
81
components/cloud-sync/ConvergentSyncPanel.test.tsx
Normal file
81
components/cloud-sync/ConvergentSyncPanel.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { ConvergentSyncPanel } from './ConvergentSyncPanel.tsx';
|
||||
|
||||
const t = (key: string, values?: Record<string, string | number>) =>
|
||||
key === 'cloudSync.convergent.conflict.secretSet'
|
||||
? 'SECRET_SET'
|
||||
: `${key}${values?.count === undefined ? '' : `:${values.count}`}`;
|
||||
|
||||
test('secret conflict candidates never render their value', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ConvergentSyncPanel
|
||||
t={t}
|
||||
resolvedLocale="en"
|
||||
config={{ enabled: true, initialized: true }}
|
||||
preview={null}
|
||||
busy={false}
|
||||
error={null}
|
||||
conflicts={[{
|
||||
address: { kind: 'entity-field', collection: 'keys', entityId: 'key-1', field: 'privateKey' },
|
||||
candidates: [{
|
||||
dot: { deviceId: 'device-a', counter: 1 },
|
||||
hlc: { wallTime: 1, logical: 0 },
|
||||
tombstone: false,
|
||||
value: 'PRIVATE-CONTENT-MUST-NOT-RENDER',
|
||||
selected: true,
|
||||
}],
|
||||
}]}
|
||||
onToggle={() => {}}
|
||||
onConfirmMigration={() => {}}
|
||||
onCancelMigration={() => {}}
|
||||
onResolveConflict={() => {}}
|
||||
onDowngrade={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(markup.includes('PRIVATE-CONTENT-MUST-NOT-RENDER'), false);
|
||||
assert.equal(markup.includes('SECRET_SET'), true);
|
||||
assert.match(
|
||||
markup,
|
||||
/data-testid="convergent-sync-icon"[^>]*class="[^"]*h-9[^"]*w-9[^"]*self-start/,
|
||||
);
|
||||
});
|
||||
|
||||
test('secret fields nested inside array candidates never reach rendered markup', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ConvergentSyncPanel
|
||||
t={t}
|
||||
resolvedLocale="en"
|
||||
config={{ enabled: true, initialized: true }}
|
||||
preview={null}
|
||||
busy={false}
|
||||
error={null}
|
||||
conflicts={[{
|
||||
address: { kind: 'setting', path: ['ai', 'providers'] },
|
||||
candidates: [{
|
||||
dot: { deviceId: 'device-a', counter: 1 },
|
||||
hlc: { wallTime: 1, logical: 0 },
|
||||
tombstone: false,
|
||||
value: [{
|
||||
id: 'provider-1',
|
||||
name: 'Private provider',
|
||||
credentials: { apiKey: 'NESTED-API-KEY-MUST-NOT-RENDER' },
|
||||
}],
|
||||
selected: true,
|
||||
}],
|
||||
}]}
|
||||
onToggle={() => {}}
|
||||
onConfirmMigration={() => {}}
|
||||
onCancelMigration={() => {}}
|
||||
onResolveConflict={() => {}}
|
||||
onDowngrade={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(markup.includes('NESTED-API-KEY-MUST-NOT-RENDER'), false);
|
||||
assert.equal(markup.includes('SECRET_SET'), true);
|
||||
});
|
||||
257
components/cloud-sync/ConvergentSyncPanel.tsx
Normal file
257
components/cloud-sync/ConvergentSyncPanel.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, FlaskConical, RotateCcw, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
ConvergentFieldConflict,
|
||||
ConvergentMigrationPreview,
|
||||
} from '../../domain/sync';
|
||||
import {
|
||||
convergentConflictAddressKey,
|
||||
dotKey,
|
||||
isConvergentConflictSecret,
|
||||
} from '../../domain/convergentSync';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type Translate = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
const ConvergentToggle: React.FC<{
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void | Promise<void>;
|
||||
}> = ({ checked, disabled, label, onChange }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label={label}
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 transition-colors ${
|
||||
checked ? 'border-amber-500 bg-amber-500' : 'border-border bg-muted'
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||
checked ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
function conflictLabel(conflict: ConvergentFieldConflict, t: Translate): string {
|
||||
const { address } = conflict;
|
||||
switch (address.kind) {
|
||||
case 'entity-presence':
|
||||
return `${address.collection}/${address.entityId} · ${t('cloudSync.convergent.field.presence')}`;
|
||||
case 'entity-position':
|
||||
return `${address.collection}/${address.entityId} · ${t('cloudSync.convergent.field.position')}`;
|
||||
case 'entity-field':
|
||||
return `${address.collection}/${address.entityId} · ${address.field}`;
|
||||
case 'setting':
|
||||
return `settings.${address.path.join('.')}`;
|
||||
case 'setting-structure':
|
||||
return address.paths.map((path) => `settings.${path.join('.')}`).join(' ↔ ');
|
||||
case 'string-entry-presence':
|
||||
return `${address.collection}/${address.value} · ${t('cloudSync.convergent.field.presence')}`;
|
||||
case 'string-entry-position':
|
||||
return `${address.collection}/${address.value} · ${t('cloudSync.convergent.field.position')}`;
|
||||
}
|
||||
}
|
||||
|
||||
function candidateValue(
|
||||
conflict: ConvergentFieldConflict,
|
||||
candidate: ConvergentFieldConflict['candidates'][number],
|
||||
t: Translate,
|
||||
): string {
|
||||
if (candidate.tombstone) return t('cloudSync.convergent.conflict.empty');
|
||||
if (isConvergentConflictSecret(conflict)) {
|
||||
return candidate.value == null
|
||||
? t('cloudSync.convergent.conflict.empty')
|
||||
: t('cloudSync.convergent.conflict.secretSet');
|
||||
}
|
||||
const encoded = JSON.stringify(candidate.value);
|
||||
if (!encoded) return t('cloudSync.convergent.conflict.empty');
|
||||
return encoded.length > 180 ? `${encoded.slice(0, 177)}...` : encoded;
|
||||
}
|
||||
|
||||
export interface ConvergentSyncPanelProps {
|
||||
t: Translate;
|
||||
resolvedLocale: string | null;
|
||||
config: { enabled: boolean; initialized: boolean };
|
||||
preview: ConvergentMigrationPreview | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
conflicts: ConvergentFieldConflict[];
|
||||
onToggle: (enabled: boolean) => void | Promise<void>;
|
||||
onConfirmMigration: () => void | Promise<void>;
|
||||
onCancelMigration: () => void;
|
||||
onResolveConflict: (addressKey: string, candidateDot: string) => void | Promise<void>;
|
||||
onDowngrade: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
t,
|
||||
resolvedLocale,
|
||||
config,
|
||||
preview,
|
||||
busy,
|
||||
error,
|
||||
conflicts,
|
||||
onToggle,
|
||||
onConfirmMigration,
|
||||
onCancelMigration,
|
||||
onResolveConflict,
|
||||
onDowngrade,
|
||||
}) => (
|
||||
<div className="space-y-4 overflow-hidden rounded-xl border border-amber-500/25 bg-gradient-to-br from-amber-500/[0.07] via-card to-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
data-testid="convergent-sync-icon"
|
||||
className="mt-0.5 flex h-9 w-9 shrink-0 self-start items-center justify-center rounded-lg border border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
<FlaskConical size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">{t('cloudSync.convergent.title')}</span>
|
||||
<span className="inline-flex rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||
{t('cloudSync.convergent.experimental')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 max-w-3xl text-xs leading-relaxed text-muted-foreground">
|
||||
{t('cloudSync.convergent.desc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConvergentToggle
|
||||
checked={config.enabled}
|
||||
onChange={onToggle}
|
||||
disabled={busy}
|
||||
label={t('cloudSync.convergent.title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.initialized && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck size={14} className="text-green-500" />
|
||||
{config.enabled
|
||||
? t('cloudSync.convergent.active')
|
||||
: t('cloudSync.convergent.paused')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-3 rounded-lg border border-border/70 bg-background/65 p-3.5 shadow-sm">
|
||||
<div className="text-sm font-semibold">{t('cloudSync.convergent.preview.title')}</div>
|
||||
<div className="grid grid-cols-1 gap-2 text-center sm:grid-cols-3">
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{Object.values(preview.entityCounts).reduce((sum, count) => sum + (count ?? 0), 0)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.entities')}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="text-lg font-semibold tabular-nums">{preview.providers.length}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.providers')}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="text-lg font-semibold tabular-nums">{preview.conflictCount}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.conflicts')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/25 px-3 py-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{t('cloudSync.convergent.preview.compatibility')}
|
||||
</div>
|
||||
{preview.providers.length > 0 && (
|
||||
<div className="divide-y rounded border text-xs">
|
||||
{preview.providers.map((provider) => (
|
||||
<div key={provider.provider} className="flex items-start justify-between gap-3 px-2 py-1.5">
|
||||
<span className="font-medium">{provider.provider}</span>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{t(`cloudSync.convergent.preview.status.${provider.status}`)} · {t('cloudSync.convergent.preview.schema')} {provider.schemaVersion}
|
||||
{provider.message ? ` · ${provider.message}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{preview.blockedReasons.length > 0 && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
{preview.blockedReasons.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<Button variant="ghost" size="sm" onClick={onCancelMigration} disabled={busy}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onConfirmMigration} disabled={busy || !preview.canInitialize}>
|
||||
{t('cloudSync.convergent.preview.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.initialized && conflicts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{t('cloudSync.convergent.conflicts.title', { count: conflicts.length })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{conflicts.map((conflict) => {
|
||||
const addressKey = convergentConflictAddressKey(conflict.address);
|
||||
return (
|
||||
<div key={addressKey} className="space-y-2 rounded-lg border border-border/70 bg-background/65 p-3 shadow-sm">
|
||||
<div className="break-all font-mono text-xs">{conflictLabel(conflict, t)}</div>
|
||||
<div className="space-y-1.5">
|
||||
{conflict.candidates.map((candidate) => (
|
||||
<div
|
||||
key={dotKey(candidate.dot)}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/15 p-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{candidateValue(conflict, candidate, t)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{candidate.dot.deviceId} · {new Date(candidate.hlc.wallTime).toLocaleString(resolvedLocale || undefined)}
|
||||
{candidate.selected ? ` · ${t('cloudSync.convergent.conflict.current')}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={candidate.selected ? 'secondary' : 'outline'}
|
||||
disabled={busy}
|
||||
onClick={() => onResolveConflict(addressKey, dotKey(candidate.dot))}
|
||||
>
|
||||
{t('cloudSync.convergent.conflict.choose')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.initialized && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3">
|
||||
<div className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">{t('cloudSync.convergent.downgrade.desc')}</div>
|
||||
<Button variant="ghost" size="sm" className="text-destructive" onClick={onDowngrade} disabled={busy}>
|
||||
<RotateCcw size={14} className="mr-1" />
|
||||
{t('cloudSync.convergent.downgrade.button')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
Reference in New Issue
Block a user