[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:
446
components/cloud-sync/CloudSyncLocalBackupsPanel.tsx
Normal file
446
components/cloud-sync/CloudSyncLocalBackupsPanel.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* CloudSyncSettings - End-to-End Encrypted Cloud Sync UI
|
||||
*
|
||||
* Handles:
|
||||
* - Master key setup (gatekeeper screen)
|
||||
* - Provider connections (GitHub, Google, OneDrive)
|
||||
* - Sync status and conflict resolution
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Download,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
|
||||
import {
|
||||
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
withRestoreBarrier,
|
||||
} from '../../application/localVaultBackups';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
|
||||
import { type SyncPayload } from '../../domain/sync';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Input } from '../ui/input';
|
||||
import { toast } from '../ui/toast';
|
||||
|
||||
// ============================================================================
|
||||
interface LocalBackupsPanelProps {
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
/**
|
||||
* When true, the panel hides the Restore button entirely — e.g. while the
|
||||
* master key has not been configured yet, a restore would land credentials
|
||||
* on disk in plaintext (I3). Listing is still allowed so users can see that
|
||||
* their history exists.
|
||||
*/
|
||||
restoreDisabledReason?: 'no-master-key' | null;
|
||||
}
|
||||
|
||||
export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
onApplyPayload,
|
||||
restoreDisabledReason = null,
|
||||
}) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const {
|
||||
backups,
|
||||
isLoading,
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
readBackup,
|
||||
setMaxBackups,
|
||||
openBackupDirectory,
|
||||
} = useLocalVaultBackups();
|
||||
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
|
||||
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
|
||||
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
|
||||
// Backup chosen in the list but not yet confirmed. A two-step flow keeps
|
||||
// users from wiping their vault with a single accidental click (I2).
|
||||
const [pendingRestoreBackup, setPendingRestoreBackup] = useState<
|
||||
(typeof backups)[number] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxBackupsInput(String(maxBackups));
|
||||
}, [maxBackups]);
|
||||
|
||||
const formatTimestamp = (timestamp: number) =>
|
||||
new Date(timestamp).toLocaleString(resolvedLocale || undefined);
|
||||
|
||||
const getReasonLabel = (reason: 'app_version_change' | 'before_restore') =>
|
||||
reason === 'app_version_change'
|
||||
? t('cloudSync.localBackups.reason.appVersionChange')
|
||||
: t('cloudSync.localBackups.reason.beforeRestore');
|
||||
|
||||
const handleSaveMaxBackups = async () => {
|
||||
// Validate BEFORE calling setMaxBackups, which hands off to the
|
||||
// renderer's `sanitizeLocalVaultBackupMaxCount` clamp. Two failure
|
||||
// modes must be surfaced rather than silently clamped, because
|
||||
// both produce a misleading "saved" toast:
|
||||
//
|
||||
// 1. Empty / non-numeric input — `Number("")` coerces to 0 and
|
||||
// sanitize clamps to the default (20). A user who meant to
|
||||
// clear the field then re-type would see their retention
|
||||
// silently reset to 20 with a success message.
|
||||
//
|
||||
// 2. Out-of-range input (e.g. 500) — sanitize clamps to 100 and
|
||||
// still reports success, but the visible error string says
|
||||
// "between 1 and 100", so the user has no idea their value
|
||||
// was changed. Reject explicitly instead.
|
||||
//
|
||||
// The 1..MAX range check mirrors the main-process `sanitizeMaxCount`
|
||||
// in vaultBackupBridge.cjs so renderer and bridge agree.
|
||||
const parsed = Number(maxBackupsInput);
|
||||
const inRange =
|
||||
Number.isFinite(parsed) &&
|
||||
parsed >= MIN_LOCAL_VAULT_BACKUP_MAX_COUNT &&
|
||||
parsed <= MAX_LOCAL_VAULT_BACKUP_MAX_COUNT;
|
||||
if (!inRange || maxBackupsInput.trim() === '') {
|
||||
toast.error(
|
||||
t('cloudSync.localBackups.maxInvalid'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsSavingMaxBackups(true);
|
||||
try {
|
||||
const next = await setMaxBackups(parsed);
|
||||
setMaxBackupsInput(String(next));
|
||||
toast.success(t('cloudSync.localBackups.maxSaved', { count: String(next) }));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
} finally {
|
||||
setIsSavingMaxBackups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBackupDirectory = async () => {
|
||||
try {
|
||||
await openBackupDirectory();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const performRestore = async (backupId: string) => {
|
||||
setRestoringBackupId(backupId);
|
||||
try {
|
||||
// Hold the cross-window restore barrier around both the load
|
||||
// and the apply so another window's auto-sync cannot push a
|
||||
// pre-restore snapshot concurrently. See `withRestoreBarrier`
|
||||
// in application/localVaultBackups.ts for the read-side in
|
||||
// useAutoSync.
|
||||
//
|
||||
// In-memory React state refresh is implicit: `onApplyPayload`
|
||||
// (supplied by the hosting screen) routes through
|
||||
// `applySyncPayload` → `importDataFromString` → store writes
|
||||
// → the hook-store listeners in `useVaultState` /
|
||||
// `useCustomThemes` / etc. We do NOT explicitly re-pull host
|
||||
// lists here because a future refactor that decouples those
|
||||
// stores from the apply path would silently break the UI
|
||||
// refresh in a way that's only visible after a manual
|
||||
// restart. Any change to that chain must either preserve
|
||||
// store-listener notification OR add an explicit
|
||||
// `rehydrateAllFromStorage` call here — do not assume
|
||||
// restore is "just" a payload swap.
|
||||
await withRestoreBarrier(async () => {
|
||||
const detail = await readBackup(backupId);
|
||||
if (!detail) {
|
||||
throw new Error(t('cloudSync.localBackups.restoreMissing'));
|
||||
}
|
||||
await Promise.resolve(onApplyPayload(detail.payload));
|
||||
});
|
||||
await refreshBackups();
|
||||
toast.success(t('cloudSync.localBackups.restoreSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.localBackups.restoreFailedTitle'),
|
||||
);
|
||||
} finally {
|
||||
setRestoringBackupId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAllowed = restoreDisabledReason === null;
|
||||
// While encryptionAvailable is still `null` we're mid-probe — render the
|
||||
// restore button as disabled so the user never sees a path they can't
|
||||
// actually take (I1 surface). Once resolved, `false` hides the panel body
|
||||
// via the unavailable banner below.
|
||||
const encryptionResolved = encryptionAvailable !== null;
|
||||
const encryptionUsable = encryptionAvailable === true;
|
||||
|
||||
// safeStorage probe finished and returned "not available" → disable the
|
||||
// panel entirely; the main process refuses to write in this state (I1).
|
||||
if (encryptionResolved && !encryptionUsable) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm font-medium">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.unavailableDesc')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-lg">
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.retentionTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.retentionDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 md:min-w-[260px] md:shrink-0">
|
||||
<div className="flex items-end gap-2 md:justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxBackupsInput}
|
||||
onChange={(e) => setMaxBackupsInput(e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveMaxBackups()}
|
||||
disabled={isSavingMaxBackups}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!restoreAllowed && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
|
||||
<AlertTriangle size={14} />
|
||||
<span className="font-medium">
|
||||
{t('cloudSync.localBackups.lockedTitle')}
|
||||
</span>
|
||||
</div>
|
||||
{t('cloudSync.localBackups.lockedDesc')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshBackups()}
|
||||
disabled={isLoading}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
|
||||
{t('settings.system.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenBackupDirectory()}
|
||||
className="gap-1"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('settings.system.openFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backups.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
|
||||
{t('cloudSync.localBackups.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">
|
||||
{backup.syncDataVersion
|
||||
? `v${backup.syncDataVersion}`
|
||||
: formatTimestamp(backup.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(backup.reason)}</span>
|
||||
{backup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(backup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{backup.sourceAppVersion && backup.targetAppVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
hosts: String(backup.preview.hostCount),
|
||||
keys: String(backup.preview.keyCount),
|
||||
snippets: String(backup.preview.snippetCount),
|
||||
notes: String(backup.preview.noteCount ?? 0),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{restoreAllowed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(backup)}
|
||||
// Disable every row while ANY restore is in
|
||||
// flight. Each restore runs a full
|
||||
// `applyProtectedSyncPayload` — multiple
|
||||
// localStorage writes + the apply-in-progress
|
||||
// sentinel. `withRestoreBarrier` serializes
|
||||
// across windows but does NOT serialize
|
||||
// same-window re-entry, so two overlapping
|
||||
// clicks here would interleave destructive
|
||||
// writes and the second run's sentinel-clear
|
||||
// could mask a still-partial first apply.
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId === backup.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore confirmation dialog (I2). Keeps the destructive action
|
||||
gated behind an explicit second click, mirroring the clear-local
|
||||
dialog elsewhere in this screen. */}
|
||||
<Dialog
|
||||
open={pendingRestoreBackup !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingRestoreBackup(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[440px] z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t('cloudSync.localBackups.restoreConfirmTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.localBackups.restoreConfirmDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pendingRestoreBackup && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
|
||||
<div className="font-medium">
|
||||
{pendingRestoreBackup.syncDataVersion
|
||||
? `v${pendingRestoreBackup.syncDataVersion}`
|
||||
: formatTimestamp(pendingRestoreBackup.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
|
||||
{pendingRestoreBackup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: pendingRestoreBackup.sourceAppVersion,
|
||||
to: pendingRestoreBackup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
hosts: String(pendingRestoreBackup.preview.hostCount),
|
||||
keys: String(pendingRestoreBackup.preview.keyCount),
|
||||
snippets: String(pendingRestoreBackup.preview.snippetCount),
|
||||
notes: String(pendingRestoreBackup.preview.noteCount ?? 0),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(null)}
|
||||
disabled={restoringBackupId !== null}
|
||||
>
|
||||
{t('cloudSync.localBackups.restoreConfirmCancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const target = pendingRestoreBackup;
|
||||
if (!target) return;
|
||||
setPendingRestoreBackup(null);
|
||||
await performRestore(target.id);
|
||||
}}
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId !== null ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restoreConfirmButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user