From 5b29ce8c6a453b702b869b84b0ba1a2e5c93cbce Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Wed, 1 Jul 2026 15:28:32 -0700 Subject: [PATCH] Add encrypted backup UI to the Cloud settings section Three states under the existing login row: enable backup (registers the device; when this device mints the account key the recovery phrase is force-displayed in a dialog that only dismisses via "I've written it down"), awaiting provision (check-again for approval from another device, or restore by typing the phrase), and ready (sync now with a pushed/pulled report toast). --- app/src/components/ServerTab/CloudSection.tsx | 248 +++++++++++++++++- app/src/lib/api/client.ts | 29 ++ app/src/lib/api/types.ts | 24 ++ 3 files changed, 300 insertions(+), 1 deletion(-) diff --git a/app/src/components/ServerTab/CloudSection.tsx b/app/src/components/ServerTab/CloudSection.tsx index bbede3f4..0c0298b9 100644 --- a/app/src/components/ServerTab/CloudSection.tsx +++ b/app/src/components/ServerTab/CloudSection.tsx @@ -1,7 +1,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Cloud, Loader2 } from 'lucide-react'; +import { Cloud, Copy, Loader2, RefreshCw, ShieldCheck } from 'lucide-react'; import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import { SettingRow, SettingSection } from './SettingRow'; @@ -9,6 +18,13 @@ import { SettingRow, SettingSection } from './SettingRow'; // "Log in with browser" device pairing. The backend opens the system browser // and completes the code exchange; here we just kick it off and poll status // until the link goes live. The API key never touches the frontend. +// +// Once linked, the encrypted-backup rows drive the sync identity flows: this +// device registers as an encryption device, and either mints the account's +// master key (first device — the recovery phrase is force-displayed exactly +// once) or waits to be provisioned by another device / restored from the +// phrase. All crypto happens in the local backend; this UI only ever sees the +// phrase, and only at mint time. export function CloudSection() { const { toast } = useToast(); const queryClient = useQueryClient(); @@ -54,6 +70,7 @@ export function CloudSection() { mutationFn: () => apiClient.disconnectCloud(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cloud-status'] }); + queryClient.invalidateQueries({ queryKey: ['cloud-sync-status'] }); toast({ title: 'Disconnected', description: 'This device is no longer linked. The key stays valid until revoked in your account.', @@ -111,6 +128,8 @@ export function CloudSection() { } /> + {connected && } + {connected && ( ); } + +function BackupRows() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [phrase, setPhrase] = useState(null); + const [phraseCopied, setPhraseCopied] = useState(false); + const [restoreInput, setRestoreInput] = useState(''); + + const { data: sync } = useQuery({ + queryKey: ['cloud-sync-status'], + queryFn: () => apiClient.getCloudSyncStatus(), + }); + + const refreshSync = () => queryClient.invalidateQueries({ queryKey: ['cloud-sync-status'] }); + + const setup = useMutation({ + mutationFn: () => apiClient.setupCloudSync(), + onSuccess: (result) => { + refreshSync(); + if (result.recovery_phrase) { + // First device: the phrase exists only in this response. Force-display + // it; the dialog can only be dismissed by confirming. + setPhraseCopied(false); + setPhrase(result.recovery_phrase); + } + }, + onError: (error: Error) => + toast({ title: 'Could not enable backup', description: error.message, variant: 'destructive' }), + }); + + const adopt = useMutation({ + mutationFn: () => apiClient.adoptCloudSync(), + onSuccess: (result) => { + refreshSync(); + if (result.status === 'ready') { + toast({ title: 'Backup enabled', description: 'This device received its encryption key.' }); + } else { + toast({ + title: 'Not approved yet', + description: 'Approve this device from another synced device, then check again.', + }); + } + }, + onError: (error: Error) => + toast({ title: 'Could not check', description: error.message, variant: 'destructive' }), + }); + + const restore = useMutation({ + mutationFn: (recoveryPhrase: string) => apiClient.restoreCloudSync(recoveryPhrase), + onSuccess: () => { + refreshSync(); + setRestoreInput(''); + toast({ title: 'Backup restored', description: 'Your encryption key was recovered. Syncing is ready.' }); + }, + onError: (error: Error) => + toast({ title: 'Could not restore', description: error.message, variant: 'destructive' }), + }); + + const run = useMutation({ + mutationFn: () => apiClient.runCloudSync(), + onSuccess: (report) => { + refreshSync(); + const pushed = report.pushed + report.pushed_deletes; + const pulled = report.pulled + report.pulled_deletes; + toast({ + title: 'Sync complete', + description: + pushed === 0 && pulled === 0 + ? 'Everything is already up to date.' + : `Backed up ${pushed} ${pushed === 1 ? 'item' : 'items'}, received ${pulled}.`, + }); + }, + onError: (error: Error) => + toast({ title: 'Sync failed', description: error.message, variant: 'destructive' }), + }); + + const state = sync?.status ?? 'unregistered'; + + return ( + <> + {state === 'unregistered' && ( + setup.mutate()} size="sm"> + {setup.isPending ? ( + + ) : ( + <> + + Enable backup + + )} + + } + /> + )} + + {state === 'awaiting_provision' && ( + <> + adopt.mutate()} size="sm" variant="outline"> + {adopt.isPending ? ( + + ) : ( + <> + + Check again + + )} + + } + /> + + setRestoreInput(e.target.value)} + placeholder="correct horse battery staple …" + value={restoreInput} + /> + + + } + /> + + )} + + {state === 'ready' && ( + run.mutate()} size="sm"> + {run.isPending ? ( + <> + + Syncing… + + ) : ( + <> + + Sync now + + )} + + } + /> + )} + + setPhrase(null)} + onCopy={() => { + if (phrase) { + navigator.clipboard?.writeText(phrase); + setPhraseCopied(true); + } + }} + phrase={phrase} + /> + + ); +} + +// The one moment the recovery phrase exists outside the backend. Deliberately +// hard to dismiss: no overlay/escape close, only the explicit confirmation. +function RecoveryPhraseDialog({ + phrase, + copied, + onCopy, + onConfirm, +}: { + phrase: string | null; + copied: boolean; + onCopy: () => void; + onConfirm: () => void; +}) { + return ( + {}} open={phrase !== null}> + + + Write down your recovery phrase + + These 12 words are the only way to restore your backup if you lose all your devices. + They are never sent to the server and will not be shown again. + + +
+ {(phrase ?? '').split(' ').map((word, index) => ( +
+ {index + 1}. + {word} +
+ ))} +
+ + + + +
+
+ ); +} diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index 8090a0cd..543c647b 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -52,6 +52,9 @@ import type { MCPClientBindingUpsert, CloudLoginStartResponse, CloudStatus, + CloudSyncRunResponse, + CloudSyncSetupResponse, + CloudSyncStatus, } from './types'; function formatErrorDetail(detail: unknown, fallback: string): string { @@ -937,6 +940,32 @@ class ApiClient { async disconnectCloud(): Promise { return this.request('/cloud/disconnect', { method: 'POST' }); } + + // Encrypted backup & sync. setupCloudSync registers this install as an + // encryption device — when it returns a recovery_phrase, this device just + // minted the account key and the phrase must be force-displayed once. + async getCloudSyncStatus(): Promise { + return this.request('/cloud/sync/status'); + } + + async setupCloudSync(): Promise { + return this.request('/cloud/sync/setup', { method: 'POST' }); + } + + async restoreCloudSync(phrase: string): Promise { + return this.request('/cloud/sync/restore', { + method: 'POST', + body: JSON.stringify({ phrase }), + }); + } + + async adoptCloudSync(): Promise { + return this.request('/cloud/sync/adopt', { method: 'POST' }); + } + + async runCloudSync(): Promise { + return this.request('/cloud/sync/run', { method: 'POST' }); + } } export const apiClient = new ApiClient(); diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index 9a2c005f..27916582 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -535,3 +535,27 @@ export interface CloudStatus { key_prefix: string | null; connected_at: string | null; } + +export type CloudSyncState = 'unregistered' | 'awaiting_provision' | 'ready'; + +export interface CloudSyncStatus { + status: CloudSyncState; + device_id: string | null; + sync_cursor: number; +} + +export interface CloudSyncSetupResponse { + status: CloudSyncState; + device_id: string | null; + /** Present exactly once, when this device minted the account's key + * material. Must be force-displayed and never persisted. */ + recovery_phrase: string | null; +} + +export interface CloudSyncRunResponse { + pushed: number; + pushed_deletes: number; + pulled: number; + pulled_deletes: number; + cursor: number; +}