Files
NetMesh/components/cloud-sync/CloudSyncDialogs.tsx

941 lines
44 KiB
TypeScript
Raw Normal View History

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>
)}
</>
);