[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:
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
Reference in New Issue
Block a user