From a925d4fa5f19ae0ceaca236e36fb9d98eb94b32f Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Thu, 12 Nov 2026 16:38:56 -0300 Subject: [PATCH] feat(web): the wizard on create, restore, reset, regenerate, re-found, merge and import (D253) Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/backup-merge.spec.ts | 2 + apps/web/e2e/import.spec.ts | 2 + apps/web/e2e/onboarding.spec.ts | 10 + apps/web/e2e/phrase-regeneration.spec.ts | 1 + apps/web/e2e/recovery-phrase.spec.ts | 4 +- apps/web/e2e/refounding.spec.ts | 2 + apps/web/src/app/controller.test.ts | 6 + apps/web/src/app/controller.ts | 9 + apps/web/src/i18n/messages.ts | 77 +++++- apps/web/src/ui/CreateVault.tsx | 70 +++-- apps/web/src/ui/ImportItems.tsx | 290 +++++++++++---------- apps/web/src/ui/MergeBackup.tsx | 129 +++++----- apps/web/src/ui/RefoundVault.tsx | 310 ++++++++++++----------- apps/web/src/ui/RegeneratePhrase.tsx | 270 +++++++++++--------- apps/web/src/ui/ResetPassword.tsx | 132 +++++----- apps/web/src/ui/RestoreVault.tsx | 83 +++--- apps/web/src/ui/Start.tsx | 3 +- docs/roadmap.md | 2 +- 18 files changed, 787 insertions(+), 615 deletions(-) diff --git a/apps/web/e2e/backup-merge.spec.ts b/apps/web/e2e/backup-merge.spec.ts index cefaa0d..7b852eb 100644 --- a/apps/web/e2e/backup-merge.spec.ts +++ b/apps/web/e2e/backup-merge.spec.ts @@ -120,8 +120,10 @@ test('merges a backup made on another installation, keeping the losing edit in h ), ).toBeVisible(); await expect(a.getByTestId('merge-confirm')).toContainText('verified.', { timeout: 30_000 }); + await expect(a.getByTestId('wizard-step')).toHaveText('Step 3 of 4 · Merge'); await button(a, 'Merge').click(); await expect(a.getByTestId('merge-result')).toContainText('Merged: 0 added, 1 replaced.'); + await expect(a.getByTestId('wizard-step')).toHaveText('Step 4 of 4 · Done'); // B's edit won; A's own edit is in the history, above the first state. await button(a, 'Back').click(); diff --git a/apps/web/e2e/import.spec.ts b/apps/web/e2e/import.spec.ts index 2ca2f9c..6584040 100644 --- a/apps/web/e2e/import.spec.ts +++ b/apps/web/e2e/import.spec.ts @@ -52,6 +52,8 @@ async function preview(page: Page) { await expect(shown).toContainText('2 items to import into a new folder, Imported from Bitwarden'); await expect(shown).toContainText('0 entries skipped'); await expect(shown).toContainText('0 attachments dropped'); + // No file password: the wizard counts three steps (D253). + await expect(page.getByTestId('wizard-step')).toHaveText('Step 2 of 3 · Check what comes in'); } test('imports a Bitwarden export into a landing folder in one commit, after a preview (D125)', async ({ diff --git a/apps/web/e2e/onboarding.spec.ts b/apps/web/e2e/onboarding.spec.ts index 112fe14..963febd 100644 --- a/apps/web/e2e/onboarding.spec.ts +++ b/apps/web/e2e/onboarding.spec.ts @@ -67,7 +67,17 @@ test('says that no server can reset the vault, then offers the three starts (§1 await button(page, 'Restore from a backup').click(); await expect(page.getByRole('heading', { name: 'Restore from a backup' })).toBeVisible(); await expect(page.getByTestId('restore-file')).toBeAttached(); + await expect(page.getByTestId('wizard-step')).toHaveText('Step 1 of 3 · Choose the backup'); await button(page, 'Cancel').click(); + // Creation is a wizard too, and its Cancel returns to the starts (D253). + await button(page, 'Create a new vault').click(); + await expect(page.getByTestId('wizard-step')).toHaveText( + 'Step 1 of 3 · Write down the Recovery Phrase', + ); + await button(page, 'I wrote them down').click(); + await expect(page.getByTestId('wizard-step')).toHaveText('Step 2 of 3 · Confirm the phrase'); + await button(page, 'Cancel').click(); + await expect(page.getByTestId('recovery-phrase')).toHaveCount(0); await expect(button(page, 'Create a new vault')).toBeVisible(); }); diff --git a/apps/web/e2e/phrase-regeneration.spec.ts b/apps/web/e2e/phrase-regeneration.spec.ts index df193df..20ef94d 100644 --- a/apps/web/e2e/phrase-regeneration.spec.ts +++ b/apps/web/e2e/phrase-regeneration.spec.ts @@ -34,6 +34,7 @@ async function exportBackup(page: Page): Promise { async function newPhrase(page: Page): Promise { await page.getByTestId('regenerate-start').click(); await expect(page.getByTestId('regenerate-exposed')).toContainText('re-founded'); + await expect(page.getByTestId('wizard-step')).toHaveText('Step 1 of 5 · Read the warning'); await expect( page.getByText( 'Backups made until now open only with the current phrase. Your other devices keep it, and keep making backups for it, until they sync with this one.', diff --git a/apps/web/e2e/recovery-phrase.spec.ts b/apps/web/e2e/recovery-phrase.spec.ts index ad002fb..6416a95 100644 --- a/apps/web/e2e/recovery-phrase.spec.ts +++ b/apps/web/e2e/recovery-phrase.spec.ts @@ -76,8 +76,8 @@ test('shows the phrase once, asks for three of its words, and shows it again on { exact: true }, ), ).toBeVisible(); - // No copy or download control on the phrase screen. - await expect(page.getByRole('button')).toHaveText(['I wrote them down']); + // No copy or download control on the phrase screen: only the wizard's Cancel (D253). + await expect(page.getByRole('button')).toHaveText(['I wrote them down', 'Cancel']); await expect(page.getByRole('link')).toHaveCount(0); await button(page, 'I wrote them down').click(); diff --git a/apps/web/e2e/refounding.spec.ts b/apps/web/e2e/refounding.spec.ts index 11eadec..93c35bf 100644 --- a/apps/web/e2e/refounding.spec.ts +++ b/apps/web/e2e/refounding.spec.ts @@ -50,6 +50,8 @@ test('re-founds the vault: warnings, proof, new phrase and password, and only th ).toBeVisible(); await refound.getByTestId('refound-start').click(); const warnings = refound.getByTestId('refound-warnings'); + // The warnings are the wizard's step 1, as in every destructive flow (D253). + await expect(refound.getByTestId('wizard-step')).toHaveText('Step 1 of 5 · Read the warnings'); await expect(warnings).toContainText( 'Sync this device first with every other device still in your hands: changes that are not on this device are lost. Afterwards, replace each other device’s vault by pairing it with this one, and destroy the backups of the old vault: they still open it with the old Recovery Phrase.', ); diff --git a/apps/web/src/app/controller.test.ts b/apps/web/src/app/controller.test.ts index a423744..326b6c8 100644 --- a/apps/web/src/app/controller.test.ts +++ b/apps/web/src/app/controller.test.ts @@ -2062,6 +2062,12 @@ describe('asset update offered only between flows (D152, D191)', () => { expect(seen).toEqual([true]); expect(c.holdsPhrase).toBe(true); expect(c.updateAllowed).toBe(false); + // Cancel drops the phrase, and a creation without one does nothing (D253). + c.cancelCreation(); + expect(c.holdsPhrase).toBe(false); + await c.create(PASSWORD, 'Test device'); + expect(c.status.kind).toBe('no-vault'); + c.newRecoveryPhrase(); await c.create(PASSWORD, 'Test device'); expect(c.status.kind).toBe('unlocked'); expect(c.holdsPhrase).toBe(false); diff --git a/apps/web/src/app/controller.ts b/apps/web/src/app/controller.ts index 0df9375..01338f1 100644 --- a/apps/web/src/app/controller.ts +++ b/apps/web/src/app/controller.ts @@ -637,6 +637,15 @@ export class VaultController { return words; } + /** Drops the phrase handed out for a creation: Cancel returns to the start's menu (D253). */ + cancelCreation(): void { + const status = this.#status; + if (status.kind !== 'no-vault' || !this.#phrase) return; + this.#phrase.keyring.wipeAll(); + this.#phrase = undefined; + this.#set({ ...status }); + } + /** * Whether a Recovery Phrase is held, shown or being confirmed (creation, regeneration, * re-founding): its screens keep their single control, with no About button (D179, D192). diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts index 8f1f85b..123f47e 100644 --- a/apps/web/src/i18n/messages.ts +++ b/apps/web/src/i18n/messages.ts @@ -168,7 +168,6 @@ const en = { 'The other devices of this vault do not know this device or {name} yet: re-admit this device with one of them, so they can sync with both.', close: 'Close', again: 'Start again', - cancel: 'Cancel', steps: { auth: 'Confirm it is you', codes: 'Exchange the codes', @@ -346,7 +345,6 @@ const en = { syncing: '{name} exchanges its records with this device.', done: '{name} shows the sync done.', }, - cancel: 'Cancel', unavailable: 'This browser cannot sync: it offers no WebRTC.', refused: { unregistered: @@ -440,6 +438,11 @@ const en = { open: 'Open the vault', }, restore: { + steps: { + file: 'Choose the backup', + secret: 'Open the backup', + password: 'Create the local password', + }, title: 'Restore from a backup', intro: 'Choose a .bvault file. It opens with its Recovery Phrase, or with its export password if it has one.', @@ -473,7 +476,6 @@ const en = { submit: 'Restore', restoring: 'Restoring the vault…', locked: 'The restore stopped before anything was saved. Open the file again.', - cancel: 'Cancel', checkPhrase: 'This vault was restored with an export password. In Settings, check that your Recovery Phrase still opens it.', dismiss: 'Dismiss', @@ -482,6 +484,7 @@ const en = { openSettings: 'Open Settings', }, reset: { + steps: { phrase: 'Type the Recovery Phrase', password: 'Choose the new password' }, forgot: 'Forgot the password?', title: 'Reset the local password', intro: @@ -539,6 +542,7 @@ const en = { 'The passkey of the erased vault no longer unlocks anything: delete the "BlindVault" passkey you no longer use in your passkey manager.', }, merge: { + steps: { file: 'Choose the backup', secret: 'Open the backup', confirm: 'Merge', done: 'Done' }, title: 'Merge a backup', intro: 'Brings the records of a backup of this vault into it, as a sync would: the newer copy of each record wins, and the older version of an item goes to its history. A merge cannot be undone.', @@ -573,6 +577,12 @@ const en = { 'This device was retired from the vault. To sync again, replace it by pairing with another device. If you did not retire it, someone holding the Master Key did: re-found the vault.', }, import: { + steps: { + file: 'Choose the file', + passphrase: 'Open the file', + preview: 'Check what comes in', + done: 'Done', + }, title: 'Import', intro: 'Brings the items of another password manager’s export into a new folder of this vault, with no history. Nothing is merged into your folders, and importing the same file again makes a second folder.', @@ -597,7 +607,6 @@ const en = { done: 'Imported {count} items into {folder}.', deleteFile: 'Delete {file} now, and empty the trash it goes to: an export holds every secret in plaintext.', - cancel: 'Cancel', readOnly: 'Import is not available while the vault is read-only.', tooLarge: 'This file is above 100 MB, or its archive unpacks beyond 100 MB or 10,000 files. It cannot be imported.', @@ -611,6 +620,11 @@ const en = { 'This device could not open the .blindpass export: its key needs 1 GiB of memory. Import it on another device of this vault, then sync.', }, create: { + steps: { + phrase: 'Write down the Recovery Phrase', + confirm: 'Confirm the phrase', + password: 'Create the local password', + }, title: 'Create a vault', intro: 'BlindVault has no server: nobody can reset your local password or recover your vault for you. Your data stays on your devices.', @@ -909,6 +923,13 @@ const en = { recoveryCreated: 'The current Recovery Phrase was created on {date} ({id}).', recoveryNoBackup: 'No backup made on this device opens with the current Recovery Phrase yet: make one in Settings › Backup.', + regenSteps: { + warn: 'Read the warning', + phrase: 'Write down the new phrase', + confirm: 'Confirm the phrase', + auth: 'Confirm it is you', + done: 'Done', + }, regenTitle: 'Regenerate the Recovery Phrase', regenIntro: 'If you lost the Recovery Phrase, regenerate it while the vault is unlocked: a new phrase replaces it, and nothing in the vault changes.', @@ -918,7 +939,6 @@ const en = { 'Backups made until now open only with the current phrase. Your other devices keep it, and keep making backups for it, until they sync with this one.', regenStart: 'Regenerate the Recovery Phrase…', regenContinue: 'Show the new phrase', - regenCancel: 'Cancel', regenPhraseTitle: 'Your new Recovery Phrase', regenAuthIntro: 'The new phrase recovers the Master Key, so regenerating asks for your local password again.', @@ -1049,6 +1069,13 @@ const en = { }, }, refound: { + steps: { + warn: 'Read the warnings', + auth: 'Confirm it is you', + phrase: 'Write down the new phrase', + confirm: 'Confirm the phrase', + password: 'Choose the new local password', + }, title: 'Re-found the vault', intro: 'If someone else may hold the Master Key (an exposed Recovery Phrase or export password, a retirement you did not make, a device you did not pair), re-found the vault: what this device holds moves to a new vault under entirely new keys.', @@ -1083,7 +1110,6 @@ const en = { submit: 'Re-found', working: 'Re-founding the vault…', busy: 'BlindVault is busy in another window. Try again once it is done.', - cancel: 'Cancel', done: 'The vault was re-founded. Make a backup of the new vault now, replace your other devices’ vault by pairing them with this one, and destroy the backups of the old vault. A passkey can be set up again in Settings.', }, templates: { @@ -1298,7 +1324,6 @@ const ptBR: Messages = { 'Os outros dispositivos deste cofre ainda não conhecem este dispositivo nem {name}: readmita este dispositivo com um deles, para que possam sincronizar com os dois.', close: 'Fechar', again: 'Começar de novo', - cancel: 'Cancelar', steps: { auth: 'Confirmar que é você', codes: 'Trocar os códigos', @@ -1483,7 +1508,6 @@ const ptBR: Messages = { syncing: '{name} troca os registros dele com este dispositivo.', done: '{name} mostra a sincronização concluída.', }, - cancel: 'Cancelar', unavailable: 'Este navegador não consegue sincronizar: ele não oferece WebRTC.', refused: { unregistered: @@ -1579,6 +1603,7 @@ const ptBR: Messages = { open: 'Abrir o cofre', }, restore: { + steps: { file: 'Escolher o backup', secret: 'Abrir o backup', password: 'Criar a senha local' }, title: 'Restaurar de um backup', intro: 'Escolha um arquivo .bvault. Ele abre com a Frase de Recuperação, ou com a senha de exportação, se tiver uma.', @@ -1613,7 +1638,6 @@ const ptBR: Messages = { submit: 'Restaurar', restoring: 'Restaurando o cofre…', locked: 'A restauração parou antes de salvar qualquer coisa. Abra o arquivo de novo.', - cancel: 'Cancelar', checkPhrase: 'Este cofre foi restaurado com uma senha de exportação. Em Configurações, confira se a sua Frase de Recuperação ainda o abre.', dismiss: 'Dispensar', @@ -1622,6 +1646,7 @@ const ptBR: Messages = { openSettings: 'Abrir Configurações', }, reset: { + steps: { phrase: 'Digitar a Frase de Recuperação', password: 'Escolher a nova senha' }, forgot: 'Esqueceu a senha?', title: 'Redefinir a senha local', intro: @@ -1679,6 +1704,12 @@ const ptBR: Messages = { 'A passkey do cofre apagado não desbloqueia mais nada: apague no seu gerenciador de passkeys a passkey "BlindVault" que você não usa mais.', }, merge: { + steps: { + file: 'Escolher o backup', + secret: 'Abrir o backup', + confirm: 'Mesclar', + done: 'Pronto', + }, title: 'Mesclar um backup', intro: 'Traz para este cofre os registros de um backup dele, como uma sincronização faria: a cópia mais nova de cada registro vence, e a versão mais antiga de um item vai para o histórico dele. Uma mesclagem não pode ser desfeita.', @@ -1714,6 +1745,12 @@ const ptBR: Messages = { 'Este dispositivo foi aposentado do cofre. Para sincronizar de novo, substitua-o pareando com outro dispositivo. Se não foi você quem o aposentou, foi alguém com a Chave Mestra: refunde o cofre.', }, import: { + steps: { + file: 'Escolher o arquivo', + passphrase: 'Abrir o arquivo', + preview: 'Conferir o que entra', + done: 'Pronto', + }, title: 'Importar', intro: 'Traz os itens da exportação de outro gerenciador de senhas para uma pasta nova deste cofre, sem histórico. Nada se mistura às suas pastas, e importar o mesmo arquivo de novo cria uma segunda pasta.', @@ -1738,7 +1775,6 @@ const ptBR: Messages = { done: '{count} itens importados para {folder}.', deleteFile: 'Apague {file} agora, e esvazie a lixeira para onde ele for: uma exportação guarda todos os segredos em texto puro.', - cancel: 'Cancelar', readOnly: 'A importação não está disponível enquanto o cofre é somente leitura.', tooLarge: 'Este arquivo passa de 100 MB, ou o arquivo compactado se expande além de 100 MB ou 10.000 arquivos. Ele não pode ser importado.', @@ -1752,6 +1788,11 @@ const ptBR: Messages = { 'Este dispositivo não conseguiu abrir a exportação .blindpass: a chave dela precisa de 1 GiB de memória. Importe em outro dispositivo deste cofre e sincronize.', }, create: { + steps: { + phrase: 'Anotar a Frase de Recuperação', + confirm: 'Confirmar a frase', + password: 'Criar a senha local', + }, title: 'Criar um cofre', intro: 'O BlindVault não tem servidor: ninguém pode redefinir a sua senha local ou recuperar o seu cofre por você. Seus dados ficam nos seus dispositivos.', @@ -2048,6 +2089,13 @@ const ptBR: Messages = { recoveryCreated: 'A Frase de Recuperação atual foi criada em {date} ({id}).', recoveryNoBackup: 'Nenhum backup feito neste dispositivo abre com a Frase de Recuperação atual ainda: faça um em Configurações › Backup.', + regenSteps: { + warn: 'Ler o aviso', + phrase: 'Anotar a nova frase', + confirm: 'Confirmar a frase', + auth: 'Confirmar que é você', + done: 'Pronto', + }, regenTitle: 'Regenerar a Frase de Recuperação', regenIntro: 'Se você perdeu a Frase de Recuperação, regenere-a com o cofre desbloqueado: uma frase nova substitui a atual, e nada no cofre muda.', @@ -2057,7 +2105,6 @@ const ptBR: Messages = { 'Os backups feitos até agora só abrem com a frase atual. Os seus outros dispositivos continuam com ela, e continuam fazendo backups para ela, até sincronizarem com este.', regenStart: 'Regenerar a Frase de Recuperação…', regenContinue: 'Mostrar a nova frase', - regenCancel: 'Cancelar', regenPhraseTitle: 'Sua nova Frase de Recuperação', regenAuthIntro: 'A nova frase recupera a Chave Mestra, então regenerar pede a sua senha local de novo.', @@ -2189,6 +2236,13 @@ const ptBR: Messages = { }, }, refound: { + steps: { + warn: 'Ler os avisos', + auth: 'Confirmar que é você', + phrase: 'Anotar a nova frase', + confirm: 'Confirmar a frase', + password: 'Escolher a nova senha local', + }, title: 'Refundar o cofre', intro: 'Se outra pessoa pode ter a Chave Mestra (uma Frase de Recuperação ou senha de exportação exposta, uma aposentadoria que você não fez, um dispositivo que você não pareou), refunde o cofre: o que este dispositivo tem passa para um cofre novo, com chaves totalmente novas.', @@ -2223,7 +2277,6 @@ const ptBR: Messages = { submit: 'Refundar', working: 'Refundando o cofre…', busy: 'O BlindVault está ocupado em outra janela. Tente de novo quando ela terminar.', - cancel: 'Cancelar', done: 'O cofre foi refundado. Faça agora um backup do cofre novo, substitua por pareamento com este o cofre dos outros dispositivos e destrua os backups do cofre antigo. Uma passkey pode ser configurada de novo em Configurações.', }, templates: { diff --git a/apps/web/src/ui/CreateVault.tsx b/apps/web/src/ui/CreateVault.tsx index 0c1fa5e..58416f1 100644 --- a/apps/web/src/ui/CreateVault.tsx +++ b/apps/web/src/ui/CreateVault.tsx @@ -7,9 +7,11 @@ import { useMessages } from '../i18n/context'; import { Button, Card } from './components'; import { NewPassword } from './NewPassword'; import { ConfirmWords, PhraseWords } from './PhraseSteps'; +import { Wizard } from './Wizard'; // Create vault → Recovery Phrase → confirmation → local password (§13.3, D179). Nothing is written -// before the password step; the words live only in this component's state until confirmed. +// before the password step; the words live only in this component's state until confirmed. One step +// at a time, and Cancel drops the phrase and returns to the start's menu (D253). type Step = | { readonly kind: 'phrase'; readonly words: readonly string[]; readonly wrong: boolean } @@ -23,11 +25,21 @@ type Step = export function CreateVault({ controller, working, + onCancel, }: { readonly controller: VaultController; readonly working: boolean; + readonly onCancel: () => void; }) { const m = useMessages(); + const c = m.create.steps; + const steps = [{ name: c.phrase }, { name: c.confirm }, { name: c.password }]; + const cancel = working + ? undefined + : () => { + controller.cancelCreation(); + onCancel(); + }; const [step, setStep] = useState(null); const restart = useCallback(() => { const words = controller.newRecoveryPhrase(); @@ -46,35 +58,45 @@ export function CreateVault({ case 'phrase': return ( -

{m.create.intro}

- { - setStep({ kind: 'confirm', words: step.words, positions: drawPositions() }); - }} - /> + +

{m.create.intro}

+ { + setStep({ kind: 'confirm', words: step.words, positions: drawPositions() }); + }} + /> +
); case 'confirm': return ( - { - // The words are no longer needed: only the controller's entropy remains. - setStep({ kind: 'password' }); - }} - onWrong={() => { - setStep({ kind: 'phrase', words: step.words, wrong: true }); - }} - /> + + { + // The words are no longer needed: only the controller's entropy remains. + setStep({ kind: 'password' }); + }} + onWrong={() => { + setStep({ kind: 'phrase', words: step.words, wrong: true }); + }} + /> + ); case 'password': - return ; + return ( + + + + + + ); } } @@ -91,7 +113,7 @@ function SetPassword({ setPassword(p); }, []); return ( - + <>

{m.create.passwordIntro}

-
+ ); } diff --git a/apps/web/src/ui/ImportItems.tsx b/apps/web/src/ui/ImportItems.tsx index 45bb187..5a2255a 100644 --- a/apps/web/src/ui/ImportItems.tsx +++ b/apps/web/src/ui/ImportItems.tsx @@ -12,10 +12,12 @@ import { useLanguage } from '../i18n/context'; import { type Messages, format } from '../i18n/messages'; import { Button, Field, INPUT, Notice } from './components'; import { writeError } from './errors'; +import { Wizard } from './Wizard'; // Import (overview §15, D123, D125): source → file → passphrase for a `.blindpass` → preview → // one commit into a new landing folder. Nothing is written before the confirmation, and a lock -// unmounts this screen, dropping what was read. +// unmounts this screen, dropping what was read. One step at a time; the file's password is a step +// only when the file has one, so the count is set once it is read (D253). type Step = | { readonly kind: 'idle' } @@ -51,11 +53,13 @@ export function ImportItems({ const [passphrase, setPassphrase] = useState(''); const [error, setError] = useState(); const [busy, setBusy] = useState(false); + const [withPassphrase, setWithPassphrase] = useState(false); const name = IMPORT_SOURCES[source].name; const cancel = () => { setError(undefined); setPassphrase(''); + setWithPassphrase(false); setStep({ kind: 'idle' }); }; const read = async (bytes: Uint8Array, fileName: string, secret?: string) => { @@ -74,18 +78,170 @@ export function ImportItems({ }); } else if (result.kind === 'passphrase' || result.kind === 'wrong-passphrase') { setError(result.kind === 'wrong-passphrase' ? m.import.wrongPassphrase : undefined); + setWithPassphrase(true); setStep({ kind: 'passphrase', bytes, fileName }); } else if (result.kind === 'locked') { cancel(); } else { setError(readError(m, result, name)); + setWithPassphrase(false); setStep({ kind: 'file' }); } }; + const g = m.import.steps; + const steps = [ + { name: g.file }, + ...(withPassphrase ? [{ name: g.passphrase }] : []), + { name: g.preview }, + { name: g.done }, + ]; + const later = withPassphrase ? 1 : 0; + const index = { idle: 1, file: 1, passphrase: 2, preview: 2 + later, done: 3 + later }[step.kind]; return (

{m.import.intro}

+ {step.kind === 'idle' ? null : ( + + {step.kind === 'file' ? ( + <> + + {m.import.plaintext} + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + // Refused before reading it into memory (D130). + if (file.size > MAX_IMPORT_SIZE) { + setError(m.import.tooLarge); + return; + } + setBusy(true); + void file.arrayBuffer().then((buffer) => read(new Uint8Array(buffer), file.name)); + }} + /> + + ) : null} + {step.kind === 'passphrase' ? ( +
{ + e.preventDefault(); + void read(step.bytes, step.fileName, passphrase); + }} + > + { + setPassphrase(e.target.value); + }} + /> + + + ) : null} + {step.kind === 'preview' ? ( +
+
    +
  • + {format(m.import.found, { + count: String(step.mapped.items.length), + folder: step.folder, + })} +
  • +
  • {format(m.import.skipped, { count: String(step.mapped.skipped) })}
  • +
  • {format(m.import.attachments, { count: String(step.mapped.attachments) })}
  • +
  • {format(m.import.notes, { count: String(step.mapped.notes) })}
  • +
+ {error ? ( +

+ {error} +

+ ) : null} + {step.mapped.items.length > 0 ? ( + + ) : ( +

{m.import.nothing}

+ )} +
+ ) : null} + {step.kind === 'done' ? ( +
+

+ {format(m.import.done, { count: String(step.count), folder: step.folder })} +

+ {format(m.import.deleteFile, { file: step.fileName })} +
+ ) : null} +
+ )} {!session.writable ? ( {m.import.readOnly} ) : step.kind === 'idle' || step.kind === 'done' ? ( @@ -95,143 +251,13 @@ export function ImportItems({ data-testid="import-start" onClick={() => { setError(undefined); + setWithPassphrase(false); setStep({ kind: 'file' }); }} > {m.import.choose} ) : null} - {step.kind === 'file' ? ( - <> - - {m.import.plaintext} - { - const file = e.target.files?.[0]; - e.target.value = ''; - if (!file) return; - // Refused before reading it into memory (D130). - if (file.size > MAX_IMPORT_SIZE) { - setError(m.import.tooLarge); - return; - } - setBusy(true); - void file.arrayBuffer().then((buffer) => read(new Uint8Array(buffer), file.name)); - }} - /> - - ) : null} - {step.kind === 'passphrase' ? ( -
{ - e.preventDefault(); - void read(step.bytes, step.fileName, passphrase); - }} - > - { - setPassphrase(e.target.value); - }} - /> - - - ) : null} - {step.kind === 'preview' ? ( -
-
    -
  • - {format(m.import.found, { - count: String(step.mapped.items.length), - folder: step.folder, - })} -
  • -
  • {format(m.import.skipped, { count: String(step.mapped.skipped) })}
  • -
  • {format(m.import.attachments, { count: String(step.mapped.attachments) })}
  • -
  • {format(m.import.notes, { count: String(step.mapped.notes) })}
  • -
- {error ? ( -

- {error} -

- ) : null} - {step.mapped.items.length > 0 ? ( - - ) : ( -

{m.import.nothing}

- )} -
- ) : null} - {step.kind === 'done' ? ( -
-

- {format(m.import.done, { count: String(step.count), folder: step.folder })} -

- {format(m.import.deleteFile, { file: step.fileName })} -
- ) : null} - {(step.kind === 'file' || step.kind === 'passphrase' || step.kind === 'preview') && !busy ? ( - - ) : null}
); } diff --git a/apps/web/src/ui/MergeBackup.tsx b/apps/web/src/ui/MergeBackup.tsx index 4192690..fcb0c60 100644 --- a/apps/web/src/ui/MergeBackup.tsx +++ b/apps/web/src/ui/MergeBackup.tsx @@ -9,9 +9,11 @@ import { Button, Notice } from './components'; import { useOpenBackup } from './RegeneratePhrase'; import { ChooseFile, OpenBackup, fileError } from './RestoreVault'; import { recordTypeName } from './Settings'; +import { Wizard } from './Wizard'; // Backup merge (overview §10, sync-protocol.md §1, D182): file → secret → confirmation → result. -// The file is validated in full before the confirmation, and a merge writes nothing until then. +// The file is validated in full before the confirmation, and a merge writes nothing until then. One +// step at a time (D253). type Step = | { readonly kind: 'idle' } @@ -45,9 +47,74 @@ export function MergeBackup({ dateStyle: 'long', ...(time ? { timeStyle: 'short' as const } : {}), }).format(at); + const g = m.merge.steps; + const index = { idle: 1, file: 1, secret: 2, confirm: 3, done: 4 }[step.kind]; return (

{m.merge.intro}

+ {step.kind === 'idle' ? null : ( + + {step.kind === 'file' ? ( + { + if (check.kind === 'ok') { + setError(undefined); + setStep({ kind: 'secret', header: check.header }); + } else { + setError(fileError(m, check)); + } + }} + /> + ) : null} + {step.kind === 'secret' ? ( + { + setError(undefined); + setStep({ kind: 'confirm', exportedAt }); + }} + onDropped={back} + /> + ) : null} + {step.kind === 'confirm' ? ( +
+

+ {format(m.merge.confirm, { date: date(step.exportedAt, true) })} +

+ +
+ ) : null} + {step.kind === 'done' ? ( + date(at, false)} + /> + ) : null} +
+ )} {step.kind === 'idle' || step.kind === 'done' ? ( - - ) : null} - {step.kind === 'done' ? ( - date(at, false)} - /> - ) : null} - {step.kind === 'file' || step.kind === 'secret' || (step.kind === 'confirm' && !merging) ? ( - - ) : null}
); } diff --git a/apps/web/src/ui/RefoundVault.tsx b/apps/web/src/ui/RefoundVault.tsx index b52f48b..f575399 100644 --- a/apps/web/src/ui/RefoundVault.tsx +++ b/apps/web/src/ui/RefoundVault.tsx @@ -12,11 +12,12 @@ import { type Messages, format } from '../i18n/messages'; import { Button, Field, Notice, TARGET } from './components'; import { NewPassword } from './NewPassword'; import { ConfirmWords, PhraseWords } from './PhraseSteps'; +import { Wizard } from './Wizard'; // Re-founding (§14, D28, D46, D47, D72, D116, D187): warnings and their confirmation → local // password or passkey → new phrase → confirmation → new local password → commit. Nothing is written // before the last step; the inactivity lock pauses while the controller holds the phrase (D104), -// and leaving this screen or a lock drops it (D93). +// and leaving this screen or a lock drops it (D93). One step at a time, the warnings first (D253). type Step = | { readonly kind: 'idle' } @@ -109,6 +110,15 @@ export function RefoundVault({ } }); }; + const g = r.steps; + const steps = [ + { name: g.warn }, + { name: g.auth }, + { name: g.phrase }, + { name: g.confirm }, + { name: g.password }, + ]; + const index = { idle: 1, warn: 1, auth: 2, phrase: 3, confirm: 4, password: 5 }[step.kind]; return (
{step.kind === 'idle' ? ( @@ -125,165 +135,163 @@ export function RefoundVault({ {r.start} - ) : null} - {step.kind === 'warn' ? ( -
- {session.retired ? r.warnRetired : r.warnSync} - {r.warnTrash} - - {corrupt > 0 ? ( - - - {format(r.warnCorrupt, { n: String(corrupt) })} - - - ) : null} - {rejected ? ( - - - {format(r.warnRejected, { names: rejected })} - - + ) : ( + + {step.kind === 'warn' ? ( +
+ {session.retired ? r.warnRetired : r.warnSync} + {r.warnTrash} + + {corrupt > 0 ? ( + + + {format(r.warnCorrupt, { n: String(corrupt) })} + + + ) : null} + {rejected ? ( + + + {format(r.warnRejected, { names: rejected })} + + + ) : null} +

{r.warnCopied}

+ + {rejected ? ( + + ) : null} + +
) : null} -

{r.warnCopied}

-
- ) : null} - {step.kind === 'auth' ? ( -
{ - e.preventDefault(); - auth({ kind: 'password', password: current }); - }} - > -

{r.authIntro}

- { - setCurrent(e.target.value); - setError(undefined); - }} - error={error} - data-testid="refound-password" - /> - - {controller.hasPasskey ? ( - + /> ) : null} - - ) : null} - {step.kind === 'phrase' ? ( - { - setStep({ kind: 'confirm', words: step.words, positions: drawPositions() }); - }} - /> - ) : null} - {step.kind === 'confirm' ? ( - { - // The words are no longer needed: only the controller's entropy remains. - setStep({ kind: 'password' }); - }} - onWrong={() => { - setStep({ kind: 'phrase', words: step.words, wrong: true }); - }} - /> - ) : null} - {step.kind === 'password' ? ( -
{ - e.preventDefault(); - if (!next) return; - setWorking(true); - setError(undefined); - void controller.refound(next, deviceName(navigator.userAgent)).then((outcome) => { - setWorking(false); - // `ok`: the new vault's session replaces this screen. - if (outcome.kind === 'busy') setError(r.busy); - else if (outcome.kind !== 'ok') setStep({ kind: 'idle' }); - }); - }} - > -

{r.passwordIntro}

- - - {error ? ( -

- {error} -

+ {step.kind === 'confirm' ? ( + { + // The words are no longer needed: only the controller's entropy remains. + setStep({ kind: 'password' }); + }} + onWrong={() => { + setStep({ kind: 'phrase', words: step.words, wrong: true }); + }} + /> + ) : null} + {step.kind === 'password' ? ( + { + e.preventDefault(); + if (!next) return; + setWorking(true); + setError(undefined); + void controller.refound(next, deviceName(navigator.userAgent)).then((outcome) => { + setWorking(false); + // `ok`: the new vault's session replaces this screen. + if (outcome.kind === 'busy') setError(r.busy); + else if (outcome.kind !== 'ok') setStep({ kind: 'idle' }); + }); + }} + > +

{r.passwordIntro}

+ + + {error ? ( +

+ {error} +

+ ) : null} + ) : null} - - ) : null} - {step.kind !== 'idle' && !working ? ( - - ) : null} + + )}
); } diff --git a/apps/web/src/ui/RegeneratePhrase.tsx b/apps/web/src/ui/RegeneratePhrase.tsx index 578e281..a15ffad 100644 --- a/apps/web/src/ui/RegeneratePhrase.tsx +++ b/apps/web/src/ui/RegeneratePhrase.tsx @@ -8,10 +8,12 @@ import { useLanguage } from '../i18n/context'; import { type Messages, format } from '../i18n/messages'; import { Button, Field, Notice } from './components'; import { ConfirmWords, PhraseWords } from './PhraseSteps'; +import { Wizard } from './Wizard'; // Recovery Phrase regeneration (§5.4, D185): warning → new phrase → confirmation → local password // or passkey → result. Nothing is written before the last step; the inactivity lock pauses while -// the controller holds the phrase (D104), and leaving this screen drops it. +// the controller holds the phrase (D104), and leaving this screen drops it. One step at a time +// (D253). type Step = | { readonly kind: 'idle' } @@ -100,144 +102,160 @@ export function RegeneratePhrase({ else setError(regenerationError(m, outcome)); }); }; + const g = m.settings.regenSteps; + const steps = [ + { name: g.warn }, + { name: g.phrase }, + { name: g.confirm }, + { name: g.auth }, + { name: g.done }, + ]; + const blockNotices = ( + <> + {block?.reason === 'newer-content' ? {m.settings.regenNewer} : null} + {block?.reason === 'future-clock' || block?.reason === 'read-only' ? ( + + {format( + block.reason === 'future-clock' + ? m.settings.regenFutureClock + : m.settings.regenReadOnly, + { date: date(block.until) }, + )} + + ) : null} + + ); + if (step.kind === 'idle') { + return ( +
+

{m.settings.regenTitle}

+ {blockNotices} + {block ? null : ( + <> +

{m.settings.regenIntro}

+ + + )} +
+ ); + } + const index = { warn: 1, phrase: 2, confirm: 3, auth: 4, done: 5 }[step.kind]; return (

{m.settings.regenTitle}

- {step.kind === 'idle' || step.kind === 'done' ? ( - <> - {step.kind === 'done' ? ( -
-

- {format(m.settings.regenDone, { - id: phraseLabel(step.recovery.phraseId), - date: date(step.recovery.phraseCreatedAt), - })} -

- {m.settings.regenOtherDevices} -

{m.settings.regenOldBackups}

- -
- ) : null} - {block?.reason === 'newer-content' ? {m.settings.regenNewer} : null} - {block?.reason === 'future-clock' || block?.reason === 'read-only' ? ( + + {step.kind === 'warn' ? ( + <> - {format( - block.reason === 'future-clock' - ? m.settings.regenFutureClock - : m.settings.regenReadOnly, - { date: date(block.until) }, - )} + {m.settings.regenExposed} - ) : null} - {step.kind === 'idle' && !block ? ( - <> -

{m.settings.regenIntro}

+

{m.settings.regenBackups}

+ + + ) : null} + {step.kind === 'phrase' ? ( + { + setStep({ kind: 'confirm', words: step.words, positions: drawPositions() }); + }} + /> + ) : null} + {step.kind === 'confirm' ? ( + { + // The words are no longer needed: only the controller's entropy remains. + setStep({ kind: 'auth' }); + }} + onWrong={() => { + setStep({ kind: 'phrase', words: step.words, wrong: true }); + }} + /> + ) : null} + {step.kind === 'auth' ? ( +
{ + e.preventDefault(); + run({ kind: 'password', password }); + }} + > +

{m.settings.regenAuthIntro}

+ { + setPassword(e.target.value); + setError(undefined); + }} + error={error} + data-testid="regenerate-password" + /> + + {controller.hasPasskey ? ( - - ) : null} - - ) : null} - {step.kind === 'warn' ? ( - <> - - {m.settings.regenExposed} - -

{m.settings.regenBackups}

- - - ) : null} - {step.kind === 'phrase' ? ( - { - setStep({ kind: 'confirm', words: step.words, positions: drawPositions() }); - }} - /> - ) : null} - {step.kind === 'confirm' ? ( - { - // The words are no longer needed: only the controller's entropy remains. - setStep({ kind: 'auth' }); - }} - onWrong={() => { - setStep({ kind: 'phrase', words: step.words, wrong: true }); - }} - /> - ) : null} - {step.kind === 'auth' ? ( - { - e.preventDefault(); - run({ kind: 'password', password }); - }} - > -

{m.settings.regenAuthIntro}

- { - setPassword(e.target.value); - setError(undefined); - }} - error={error} - data-testid="regenerate-password" - /> - - {controller.hasPasskey ? ( - - ) : null} - - ) : null} - {step.kind === 'warn' || - step.kind === 'phrase' || - step.kind === 'confirm' || - (step.kind === 'auth' && !working) ? ( - - ) : null} +
+ ) : null} + + {step.kind === 'done' ? blockNotices : null} ); } diff --git a/apps/web/src/ui/ResetPassword.tsx b/apps/web/src/ui/ResetPassword.tsx index 9722362..b5c2c78 100644 --- a/apps/web/src/ui/ResetPassword.tsx +++ b/apps/web/src/ui/ResetPassword.tsx @@ -5,10 +5,11 @@ import { useMessages } from '../i18n/context'; import { type Messages, format } from '../i18n/messages'; import { Button, Card, TextArea } from './components'; import { NewPassword } from './NewPassword'; +import { Wizard } from './Wizard'; // A forgotten local password reset with the Recovery Phrase (§14, D173, D184): the phrase, then // the new password. The vault stays locked until the reset commits, and the phrase is checked -// only then, against the Recovery Envelope this device holds. +// only then, against the Recovery Envelope this device holds. One step at a time (D253). export function ResetPassword({ controller, @@ -29,71 +30,72 @@ export function ResetPassword({ }, []); return ( - {step === 'phrase' ? ( -
{ - e.preventDefault(); - setError(undefined); - setStep('password'); - }} - > -

{m.reset.intro}

-