mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
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).
This commit is contained in:
@@ -1,7 +1,16 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { useEffect, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { SettingRow, SettingSection } from './SettingRow';
|
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
|
// "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
|
// 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.
|
// 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() {
|
export function CloudSection() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -54,6 +70,7 @@ export function CloudSection() {
|
|||||||
mutationFn: () => apiClient.disconnectCloud(),
|
mutationFn: () => apiClient.disconnectCloud(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
|
queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cloud-sync-status'] });
|
||||||
toast({
|
toast({
|
||||||
title: 'Disconnected',
|
title: 'Disconnected',
|
||||||
description: 'This device is no longer linked. The key stays valid until revoked in your account.',
|
description: 'This device is no longer linked. The key stays valid until revoked in your account.',
|
||||||
@@ -111,6 +128,8 @@ export function CloudSection() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{connected && <BackupRows />}
|
||||||
|
|
||||||
{connected && (
|
{connected && (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Manage"
|
title="Manage"
|
||||||
@@ -129,3 +148,230 @@ export function CloudSection() {
|
|||||||
</SettingSection>
|
</SettingSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BackupRows() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [phrase, setPhrase] = useState<string | null>(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' && (
|
||||||
|
<SettingRow
|
||||||
|
title="Encrypted backup"
|
||||||
|
description="Back up captures, generations, and voice profiles — encrypted on this device before anything is uploaded."
|
||||||
|
action={
|
||||||
|
<Button disabled={setup.isPending} onClick={() => setup.mutate()} size="sm">
|
||||||
|
{setup.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Enable backup
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'awaiting_provision' && (
|
||||||
|
<>
|
||||||
|
<SettingRow
|
||||||
|
title="Waiting for encryption key"
|
||||||
|
description="This account already has a backup. Approve this device from another synced device, or restore with your recovery phrase below."
|
||||||
|
action={
|
||||||
|
<Button disabled={adopt.isPending} onClick={() => adopt.mutate()} size="sm" variant="outline">
|
||||||
|
{adopt.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Check again
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
title="Restore with recovery phrase"
|
||||||
|
description="The 12 words you wrote down when you first enabled backup."
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="w-72"
|
||||||
|
onChange={(e) => setRestoreInput(e.target.value)}
|
||||||
|
placeholder="correct horse battery staple …"
|
||||||
|
value={restoreInput}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={restore.isPending || restoreInput.trim().split(/\s+/).length < 12}
|
||||||
|
onClick={() => restore.mutate(restoreInput)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{restore.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Restore'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'ready' && (
|
||||||
|
<SettingRow
|
||||||
|
title="Encrypted backup"
|
||||||
|
description="On — content is encrypted on this device before upload. The server can never read it."
|
||||||
|
action={
|
||||||
|
<Button disabled={run.isPending} onClick={() => run.mutate()} size="sm">
|
||||||
|
{run.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
Syncing…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Sync now
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecoveryPhraseDialog
|
||||||
|
copied={phraseCopied}
|
||||||
|
onConfirm={() => 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 (
|
||||||
|
<Dialog onOpenChange={() => {}} open={phrase !== null}>
|
||||||
|
<DialogContent className="max-w-lg [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Write down your recovery phrase</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
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.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-3 gap-2 py-2">
|
||||||
|
{(phrase ?? '').split(' ').map((word, index) => (
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-border bg-muted/40 px-2.5 py-1.5 text-sm"
|
||||||
|
// Position is the identity here — BIP39 phrases can repeat words.
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: static list, never reordered
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<span className="mr-1.5 text-muted-foreground tabular-nums">{index + 1}.</span>
|
||||||
|
{word}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
<Button onClick={onCopy} size="sm" type="button" variant="outline">
|
||||||
|
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} size="sm" type="button">
|
||||||
|
I've written it down
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ import type {
|
|||||||
MCPClientBindingUpsert,
|
MCPClientBindingUpsert,
|
||||||
CloudLoginStartResponse,
|
CloudLoginStartResponse,
|
||||||
CloudStatus,
|
CloudStatus,
|
||||||
|
CloudSyncRunResponse,
|
||||||
|
CloudSyncSetupResponse,
|
||||||
|
CloudSyncStatus,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||||
@@ -937,6 +940,32 @@ class ApiClient {
|
|||||||
async disconnectCloud(): Promise<CloudStatus> {
|
async disconnectCloud(): Promise<CloudStatus> {
|
||||||
return this.request<CloudStatus>('/cloud/disconnect', { method: 'POST' });
|
return this.request<CloudStatus>('/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<CloudSyncStatus> {
|
||||||
|
return this.request<CloudSyncStatus>('/cloud/sync/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupCloudSync(): Promise<CloudSyncSetupResponse> {
|
||||||
|
return this.request<CloudSyncSetupResponse>('/cloud/sync/setup', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreCloudSync(phrase: string): Promise<CloudSyncStatus> {
|
||||||
|
return this.request<CloudSyncStatus>('/cloud/sync/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phrase }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async adoptCloudSync(): Promise<CloudSyncStatus> {
|
||||||
|
return this.request<CloudSyncStatus>('/cloud/sync/adopt', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async runCloudSync(): Promise<CloudSyncRunResponse> {
|
||||||
|
return this.request<CloudSyncRunResponse>('/cloud/sync/run', { method: 'POST' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@@ -535,3 +535,27 @@ export interface CloudStatus {
|
|||||||
key_prefix: string | null;
|
key_prefix: string | null;
|
||||||
connected_at: 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user