941 lines
44 KiB
TypeScript
941 lines
44 KiB
TypeScript
|
|
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>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
);
|