From 2278d3be73e34b53986b79b516f2fcc5f7ec2631 Mon Sep 17 00:00:00 2001 From: Edgar Mesquita Date: Wed, 30 Sep 2026 00:05:39 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20sign=20out=20of=20this=20de?= =?UTF-8?q?vice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to leave. "Desconectar" revoked the token and left the vault open on screen; "Bloquear" kept everything; and the wipe in Advanced, the closest thing to a sign-out, left the encrypted attachment cache and the invite identity on disk, with the token still live in memory. Its confirmation also promised that "the copy in the cloud stays intact" to people whose vault had never been uploaded. Sair deste aparelho now sits beside the account in the sidebar, and on the unlock screen for whoever is looking at someone else's. It removes everything of the person's from the browser — vault cache, stored key, attachment cache, invite identity, account, shared vaults, recent types — and revokes the token. Configuration and looks stay. It will not destroy data to do it. Pending edits are synced first, and if they cannot go up the person is told why before anything is deleted. A vault that was never uploaded is the only copy there is, so signing out refuses outright; the wipe in Advanced can still remove it, after saying in so many words that it is gone for good. Both doors go through one implementation and one set of words. Knowing what is pending after a reload needed the pending-sync flag to survive one, so it is now written synchronously beside the in-memory copy the sync loop already kept. --- README.md | 1 + public/privacidade.html | 16 +++- scripts/smoke-test.mjs | 80 ++++++++++++++++++ src/components/SettingsDialog.tsx | 14 ++-- src/components/sign-out-flow.ts | 25 ++++++ src/lib/sign-out.test.ts | 57 +++++++++++++ src/lib/sign-out.ts | 93 +++++++++++++++++++++ src/lib/storage.test.ts | 33 +++++++- src/lib/storage.ts | 43 +++++++++- src/screens/Onboarding.tsx | 18 +++- src/screens/VaultScreen.tsx | 14 +++- src/state/keeper.tsx | 132 +++++++++++++++++++++++++++--- 12 files changed, 494 insertions(+), 32 deletions(-) create mode 100644 src/components/sign-out-flow.ts create mode 100644 src/lib/sign-out.test.ts create mode 100644 src/lib/sign-out.ts diff --git a/README.md b/README.md index ed98600..d69f9cb 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ is never transmitted, never stored, and cannot be recovered. | Google scope | `drive.appdata` (the app's own hidden folder) + email/profile. `drive.file` — files this app created or that the user picked, and nothing else — is asked for only if you move the vault into a folder of your own, or open one shared with you | | Sharing | The vault's data key wrapped to a recipient's ECDH public key (ephemeral ECDH + HKDF + AES-GCM). The invite code carries no secret; revoking rotates the data key so a kept copy dies | | OAuth token | Short-lived access token, held in memory only | +| Signing out | *Sair deste aparelho* removes everything of the person's from the browser — vault cache, stored key, encrypted attachment cache, invite identity, account, shared vaults, recent types — and revokes the token. Pending edits are synced first, and it refuses outright to delete a vault that was never uploaded, since that is the only copy. Configuration (client ids) and looks (theme) stay | | Clipboard | Automatic clearing (default: 30s) after copying a secret; a wipe that came due while the app was in the background runs as soon as it is visible again — a background tab cannot touch the clipboard | | CSP | `default-src 'none'` with a minimal allow-list; no inline scripts; iframe blocked | | Search | Never indexes secret values — only name, description, tags and non-sensitive fields | diff --git a/public/privacidade.html b/public/privacidade.html index 96721ec..c37f77b 100644 --- a/public/privacidade.html +++ b/public/privacidade.html @@ -100,7 +100,7 @@

Política de privacidade

-

eQuantic Keeper · em vigor desde 1 de setembro de 2026

+

eQuantic Keeper · em vigor desde 29 de setembro de 2026

@@ -207,7 +207,12 @@

O que não fazemos

Como apagar tudo

  • - Neste aparelho: Configurações → Avançado → “Apagar deste dispositivo”. + Neste aparelho: o botão Sair deste aparelho, ao lado da sua conta na + barra lateral (ou na tela de desbloqueio). Ele apaga deste navegador tudo o que é seu: o cofre, a chave + guardada, os anexos cifrados, a identidade usada para receber convites, a conta conectada e os cofres + partilhados com você. Antes, sincroniza o que estiver pendente; se algo não puder subir, avisa. E recusa + apagar um cofre que nunca foi enviado para a nuvem, porque essa seria a única cópia. Para isso existe + Configurações → Avançado → “Apagar dados deste dispositivo”, que pede confirmação explícita.
  • No Drive: apague a pasta eQuantic Keeper, se você moveu o cofre para @@ -265,8 +270,11 @@

    Privacy policy (English)

    scope here can read your other files, mail, contacts or calendar.

    - Deleting your data. Configurações → Avançado → “Apagar deste dispositivo” clears this - browser; deleting the eQuantic Keeper folder removes the Drive copy; revoking the app at + Deleting your data. Sair deste aparelho, beside your account in the sidebar, + removes everything of yours from this browser — the vault, the stored key, the encrypted attachments, + the invite identity, the connected account and any shared vaults — after syncing whatever is pending. + It refuses to delete a vault that was never uploaded, since that would be the only copy; + Configurações → Avançado → “Apagar dados deste dispositivo” does that, after an explicit warning. Deleting the eQuantic Keeper folder removes the Drive copy; revoking the app at myaccount.google.com/permissions ends all access.

    diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index db9c4eb..ef05fdd 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -1969,6 +1969,86 @@ const run = async () => { }); await page.fill('input[type="search"]', ''); + /* + * 11d. Leaving the device. + * + * Last on this page because it ends the session it runs in (the phone below + * gets a context of its own). The vault here was never uploaded, which makes + * it the case that matters most: signing out must REFUSE, because this is the + * only copy there is. Then the deliberate wipe from Advanced, which must take + * everything — asserted by reading what is on disk before and after. "The + * screen changed" is exactly the check that passed for months while the + * encrypted attachment cache and the invite identity sat there untouched. + */ + const onDevice = () => + page.evaluate(async () => { + const existing = new Set((await indexedDB.databases()).map((db) => db.name)); + const count = (name, store) => + existing.has(name) + ? new Promise((resolve) => { + const request = indexedDB.open(name); + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(store)) { + db.close(); + return resolve(0); + } + const counted = db.transaction(store, 'readonly').objectStore(store).count(); + counted.onsuccess = () => { + db.close(); + resolve(counted.result); + }; + counted.onerror = () => resolve(-1); + }; + request.onerror = () => resolve(-1); + }) + : Promise.resolve(0); + return { + cache: localStorage.getItem('keeper.vault.cache.v1') !== null, + recentTypes: localStorage.getItem('keeper.recentTypes.v1') !== null, + attachments: await count('keeper-attachments', 'ciphertext'), + identity: await count('keeper-identity', 'identity'), + keystore: await count('keeper-keystore', 'derived'), + }; + }); + + const before = await onDevice(); + console.log(` (antes de sair: ${JSON.stringify(before)})`); + // Without this, every "empty afterwards" below would pass on a device that + // never had anything to begin with. + await check('antes de sair há mesmo coisas suas neste aparelho', async () => + before.cache && before.recentTypes && before.attachments > 0 && before.identity > 0); + + let refusal = { type: '', message: '' }; + page.once('dialog', (dialog) => { + refusal = { type: dialog.type(), message: dialog.message() }; + return dialog.accept(); + }); + await page.locator('[data-sidebar-footer] [data-sign-out]').click(); + await page.waitForTimeout(800); + await check('sair recusa apagar um cofre que só existe aqui', async () => + refusal.type === 'alert' && refusal.message.includes('nunca foi enviado')); + await check('e o cofre continua inteiro depois da recusa', async () => + (await onDevice()).cache && (await page.locator('main li').count()) > 0); + + await openSettings(page, 'Avançado'); + let warning = ''; + page.once('dialog', (dialog) => { + warning = dialog.message(); + return dialog.accept(); + }); + await page.click('[role="dialog"] button:has-text("Apagar dados deste dispositivo")'); + await page.waitForSelector('text=Continuar com o Google', { timeout: 10000 }); + await check('apagar diz que um cofre sem cópia se vai para sempre', async () => + warning.includes('para sempre') && !warning.includes('continua no')); + + const after = await onDevice(); + console.log(` (depois de apagar: ${JSON.stringify(after)})`); + await check('apagar leva o cofre e o rastro do que foi criado', async () => !after.cache && !after.recentTypes); + await check('apagar leva os anexos cifrados guardados no aparelho', async () => after.attachments === 0); + await check('apagar leva a identidade de convites deste aparelho', async () => after.identity === 0); + await check('apagar leva a chave guardada', async () => after.keystore === 0); + // 12. Mobile: the same vault on a phone-sized, touch-first viewport. A fresh // page (fresh context) so the flow is seeded from scratch at 375px. const phone = await browser.newPage({ diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 3bd47af..dfd5c31 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -6,6 +6,7 @@ import { useKeeper } from '../state/keeper'; import { exportBundle, exportEncrypted, exportPlaintext } from '../lib/backup'; import { estimateStrength } from '../lib/generator'; import { createPerson, getType, type CustomTypeDef, type Person } from '../lib/model'; +import { signOutFlow } from './sign-out-flow'; import { getClientId, getMsClientId, getPickerApiKey, setMsClientId, setPickerApiKey } from '../lib/storage'; import { TOMBSTONE_TTL_DAYS, activeCustomTypes, activePeople } from '../lib/vault'; import { KEEPER_FOLDER_NAME, type DrivePermission, type DriveUsage } from '../lib/drive'; @@ -1134,15 +1135,10 @@ function AdvancedPane({ onClose }: { onClose: () => void }) { size="sm" variant="danger" icon="trash" - onClick={() => { - if ( - confirm( - `Apagar o cofre salvo neste navegador? A cópia no ${service} permanece intacta e pode ser baixada de novo.`, - ) - ) { - actions.wipeDevice(); - onClose(); - } + onClick={async () => { + // The old confirmation promised "the copy in the cloud stays + // intact" to people whose vault had never been uploaded. + if (await signOutFlow(actions, 'wipe', service)) onClose(); }} > Apagar dados deste dispositivo diff --git a/src/components/sign-out-flow.ts b/src/components/sign-out-flow.ts new file mode 100644 index 0000000..91c3985 --- /dev/null +++ b/src/components/sign-out-flow.ts @@ -0,0 +1,25 @@ +import { signOutPrompt, type SignOutMode } from '../lib/sign-out'; +import type { KeeperActions } from '../state/keeper'; + +/** + * Asks, then leaves — the one path every door goes through, so the words a + * person reads before losing something cannot drift between the sidebar, the + * unlock screen and Advanced. + * + * Resolves true when the device was actually signed out, so a caller that + * lives inside a dialog knows whether to close it. + */ +export async function signOutFlow( + actions: Pick, + mode: SignOutMode, + service: string, +): Promise { + const prompt = signOutPrompt(await actions.signOutRisk(), mode, service); + if (prompt.kind === 'refuse') { + window.alert(prompt.message); + return false; + } + if (!window.confirm(prompt.message)) return false; + await actions.signOutOfDevice(); + return true; +} diff --git a/src/lib/sign-out.test.ts b/src/lib/sign-out.test.ts new file mode 100644 index 0000000..9908c08 --- /dev/null +++ b/src/lib/sign-out.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { assessSignOut, signOutPrompt } from './sign-out'; + +describe('sair deste aparelho', () => { + it('é seguro quando a nuvem já tem tudo', () => { + expect(assessSignOut({ hasVault: true, hasRemote: true, pending: false })).toEqual({ kind: 'none' }); + }); + + it('recusa quando o cofre nunca saiu daqui, mesmo sem nada pendente', () => { + // "Somente local": não há outra cópia em lado nenhum. Não é um aviso, é um não. + expect(assessSignOut({ hasVault: true, hasRemote: false, pending: false }).kind).toBe('only-copy'); + expect(assessSignOut({ hasVault: true, hasRemote: false, pending: true }).kind).toBe('only-copy'); + }); + + it('avisa das alterações que ainda não subiram, e diz porquê', () => { + expect( + assessSignOut({ hasVault: true, hasRemote: true, pending: true, reason: 'Sem conexão com o OneDrive.' }), + ).toEqual({ kind: 'unsynced', reason: 'Sem conexão com o OneDrive.' }); + }); + + it('um convidado sem cofre próprio não tem nada a perder aqui', () => { + expect(assessSignOut({ hasVault: false, hasRemote: false, pending: true })).toEqual({ kind: 'none' }); + }); +}); + +describe('o que a pessoa lê antes de apagar', () => { + it('sair recusa apagar a única cópia, e diz como resolver', () => { + const prompt = signOutPrompt({ kind: 'only-copy' }, 'sign-out', 'Google Drive'); + expect(prompt.kind).toBe('refuse'); + expect(prompt.message).toMatch(/nunca foi enviado/); + expect(prompt.message).toMatch(/exporte um backup/); + // Serve também na tela de desbloqueio, onde as Configurações só abrem com o cofre aberto. + expect(prompt.message).toMatch(/abra o cofre/); + }); + + it('apagar deixa destruir a única cópia, mas não esconde que é para sempre', () => { + const prompt = signOutPrompt({ kind: 'only-copy' }, 'wipe', 'Google Drive'); + expect(prompt.kind).toBe('confirm'); + expect(prompt.message).toMatch(/para sempre/); + // A confirmação antiga prometia exatamente isto a quem não tinha cópia nenhuma. + expect(prompt.message).not.toMatch(/continua no/); + }); + + it('com alterações por subir, diz que se perdem e porquê', () => { + const prompt = signOutPrompt({ kind: 'unsynced', reason: 'Sem internet neste momento.' }, 'sign-out', 'OneDrive'); + expect(prompt.message).toMatch(/ainda não chegaram ao OneDrive/); + expect(prompt.message).toMatch(/Sem internet neste momento\./); + expect(prompt.message).toMatch(/se perdem/); + }); + + it('quando é seguro, diz onde o cofre continua e como voltar', () => { + const prompt = signOutPrompt({ kind: 'none' }, 'sign-out', 'OneDrive'); + expect(prompt).toMatchObject({ kind: 'confirm' }); + expect(prompt.message).toMatch(/continua no OneDrive/); + expect(prompt.message).toMatch(/senha mestra/); + }); +}); diff --git a/src/lib/sign-out.ts b/src/lib/sign-out.ts new file mode 100644 index 0000000..c03b47e --- /dev/null +++ b/src/lib/sign-out.ts @@ -0,0 +1,93 @@ +/** + * Whether leaving this device can cost the person anything. + * + * Signing out removes everything of theirs from this browser, which is only + * safe when the cloud already holds all of it. Three situations, and telling + * the last two apart is the whole reason this exists: + * + * - nothing here that the cloud lacks: leave; + * - edits made here that have not gone up yet: leaving drops them, and the + * person has to know that before they press the button, not after; + * - a vault that never left this device at all: leaving destroys the only + * copy there is. Refused rather than warned about — signing out must never + * be the button that deletes someone's vault. + */ +export type SignOutRisk = + | { kind: 'none' } + | { kind: 'unsynced'; reason: string } + | { kind: 'only-copy' }; + +export interface SignOutFacts { + /** This device holds a vault of the person's own, open or cached. */ + hasVault: boolean; + /** It has been uploaded at least once, so the cloud has some version of it. */ + hasRemote: boolean; + /** Edits exist that the cloud has not received. */ + pending: boolean; + /** Why they have not gone up, when that is known. */ + reason?: string; +} + +export function assessSignOut(facts: SignOutFacts): SignOutRisk { + // A guest looking at someone else's vault has nothing of their own here: the + // vault they were reading lives in the owner's cloud, and stays there. + if (!facts.hasVault) return { kind: 'none' }; + if (!facts.hasRemote) return { kind: 'only-copy' }; + if (facts.pending) { + return { kind: 'unsynced', reason: facts.reason ?? 'As alterações ainda não chegaram à nuvem.' }; + } + return { kind: 'none' }; +} + +/** + * Which button was pressed. Signing out is the everyday action and refuses to + * destroy the only copy; wiping is the deliberate one in Advanced, and lets a + * person throw away a vault that only ever lived here — but only after saying + * in so many words that it is gone for good. + */ +export type SignOutMode = 'sign-out' | 'wipe'; + +export type SignOutPrompt = { kind: 'refuse'; message: string } | { kind: 'confirm'; message: string }; + +/** + * The words the person reads before anything is deleted. + * + * Kept here rather than in a component because what they say is the safety + * mechanism: the old confirmation in Advanced promised "the copy in the cloud + * stays intact" to someone whose vault had never been uploaded. + */ +export function signOutPrompt(risk: SignOutRisk, mode: SignOutMode, service: string): SignOutPrompt { + if (risk.kind === 'only-copy') { + if (mode === 'sign-out') { + return { + kind: 'refuse', + message: + `Este cofre só existe neste aparelho: ele nunca foi enviado para o ${service}. ` + + 'Sair agora apagaria o cofre para sempre.\n\n' + + 'Antes de sair, abra o cofre e exporte um backup (Configurações → Backup), ou conecte uma conta ' + + 'para ele subir.', + }; + } + return { + kind: 'confirm', + message: + 'Este cofre só existe neste aparelho. Apagar agora destrói o cofre para sempre: ' + + `não há cópia no ${service} nem em lugar nenhum.\n\nApagar mesmo assim?`, + }; + } + if (risk.kind === 'unsynced') { + return { + kind: 'confirm', + message: + `Há alterações feitas neste aparelho que ainda não chegaram ao ${service}. ${risk.reason}\n\n` + + 'Se continuar, elas se perdem. Continuar mesmo assim?', + }; + } + return { + kind: 'confirm', + message: + (mode === 'sign-out' ? 'Sair deste aparelho?' : 'Apagar tudo deste aparelho?') + + `\n\nTudo o que é seu sai deste navegador. O cofre continua no ${service}: ` + + 'para voltar, entre de novo com a conta e a senha mestra.', + }; +} diff --git a/src/lib/storage.test.ts b/src/lib/storage.test.ts index d7abc11..827cb35 100644 --- a/src/lib/storage.test.ts +++ b/src/lib/storage.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getProvider, setProvider } from './storage'; +import { getProvider, isSyncPending, markSyncPending, setProvider, wipeLocalData } from './storage'; /** * A localStorage, because the suite runs under node and there is none. @@ -71,3 +71,34 @@ describe('qual serviço este dispositivo usa', () => { expect(() => setProvider('microsoft')).not.toThrow(); }); }); + +describe('alterações que ainda não subiram', () => { + it('sobrevivem a um refresh, porque ficam gravadas', () => { + markSyncPending(true); + expect(isSyncPending()).toBe(true); + markSyncPending(false); + expect(isSyncPending()).toBe(false); + }); +}); + +describe('o que sai do aparelho, e o que fica', () => { + it('leva tudo o que é da pessoa, e deixa configuração e aparência', () => { + const store = stubStorage({ + // Da pessoa. + 'keeper.vault.cache.v1': '{}', + 'keeper.google.account': '{}', + 'keeper.biometric.v1': '{}', + 'keeper.drive.folder.v1': 'pasta', + 'keeper.shared.v1': '[]', + 'keeper.recentTypes.v1': '["passport"]', + 'keeper.sync.pending': '1', + // Do aparelho. + 'keeper.google.clientId': 'fork.apps.googleusercontent.com', + 'keeper.theme': 'light', + }); + + wipeLocalData(); + + expect([...store.keys()].sort()).toEqual(['keeper.google.clientId', 'keeper.theme']); + }); +}); diff --git a/src/lib/storage.ts b/src/lib/storage.ts index e18716f..de6b9ae 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -13,6 +13,8 @@ const KEY_CLIENT_ID = 'keeper.google.clientId'; const KEY_PICKER_KEY = 'keeper.google.pickerKey'; const KEY_MS_CLIENT_ID = 'keeper.microsoft.clientId'; const KEY_PROVIDER = 'keeper.storage.provider'; +/** Edits on this device that the cloud has not received. See `markSyncPending`. */ +const KEY_SYNC_PENDING = 'keeper.sync.pending'; const KEY_ACCOUNT = 'keeper.google.account'; const KEY_THEME = 'keeper.theme'; const KEY_BIOMETRIC = 'keeper.biometric.v1'; @@ -289,8 +291,47 @@ export function saveTheme(theme: 'dark' | 'light'): void { } /** Wipes every trace of the vault from this device (the Drive copy is untouched). */ +/** + * Everything in localStorage that belongs to the person rather than to the + * device: the vault, the account, the shared vaults, and the trail of what + * they have been creating. What stays is configuration and looks — the client + * ids, the theme, how the sidebar was split — none of which says anything about + * whoever used the app last. + * + * The IndexedDB halves (the stored key, the attachment cache, the invite + * identity) are separate stores with their own clear functions; signing out + * calls all of them. + */ export function wipeLocalData(): void { - for (const key of [KEY_CACHE, KEY_ACCOUNT, KEY_BIOMETRIC, KEY_DRIVE_FOLDER, KEY_SHARED_VAULTS]) safeRemove(key); + for (const key of [ + KEY_CACHE, + KEY_ACCOUNT, + KEY_BIOMETRIC, + KEY_DRIVE_FOLDER, + KEY_SHARED_VAULTS, + KEY_RECENT_TYPES, + KEY_SYNC_PENDING, + ]) { + safeRemove(key); + } +} + +/** + * Edits made on this device that have not reached the cloud. + * + * The sync loop has always kept this in memory, and that copy dies with the + * page: after a reload the app could not tell whether the local vault was + * ahead of the cloud. Signing out has to be able to tell, because it is about + * to delete the local copy. Written synchronously, like the lock, so a reload + * straight after an edit cannot outrun it. + */ +export function markSyncPending(pending: boolean): void { + if (pending) safeSet(KEY_SYNC_PENDING, '1'); + else safeRemove(KEY_SYNC_PENDING); +} + +export function isSyncPending(): boolean { + return safeGet(KEY_SYNC_PENDING) === '1'; } const NOTE_PANES_KEY = 'keeper.note.panes.v1'; diff --git a/src/screens/Onboarding.tsx b/src/screens/Onboarding.tsx index 52f57a6..f94259f 100644 --- a/src/screens/Onboarding.tsx +++ b/src/screens/Onboarding.tsx @@ -5,6 +5,7 @@ import { Icon, Wordmark } from '../components/icons'; import { InviteCodeDialog, OpenSharedButton, ensurePickerKey } from '../components/InviteCode'; import { estimateStrength } from '../lib/generator'; import { useKeeper } from '../state/keeper'; +import { signOutFlow } from '../components/sign-out-flow'; import { getMsClientId, isClientIdOverridden } from '../lib/storage'; function AuthShell({ @@ -323,7 +324,8 @@ export function CreateVaultScreen() { } export function UnlockScreen() { - const { actions, busy, account, online, connected, biometricReady } = useKeeper(); + const { actions, busy, account, online, connected, biometricReady, provider } = useKeeper(); + const service = provider === 'microsoft' ? 'OneDrive' : 'Google Drive'; const [password, setPassword] = useState(''); const submit = (event: FormEvent) => { @@ -374,9 +376,21 @@ export function UnlockScreen() { ) : null} {!connected && online ? ( ) : null} + {/* For whoever is looking at someone else's lock screen, or at their own + on a computer they are about to hand back: no password needed to + leave, because leaving only ever removes. */} + ); diff --git a/src/screens/VaultScreen.tsx b/src/screens/VaultScreen.tsx index 2a4dc71..bf2d147 100644 --- a/src/screens/VaultScreen.tsx +++ b/src/screens/VaultScreen.tsx @@ -38,6 +38,7 @@ import { PullToSync } from '../components/PullToSync'; import * as storage from '../lib/storage'; import { OpenSharedDialog, ensurePickerKey } from '../components/InviteCode'; import { useCloseOnBack } from '../components/use-close-on-back'; +import { signOutFlow } from '../components/sign-out-flow'; /** A sidebar row: one type in use, or a whole family of them. */ type SidebarEntry = @@ -1135,15 +1136,24 @@ export function VaultScreen() {
    + className="flex shrink-0 items-center gap-1 border-t border-line bg-surface px-3 pt-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.5rem)] lg:pb-2"> + {/* Beside the account, which is where people look for it. Locking is the + padlock in the header; this is the other thing — leaving the device + with nothing of yours on it. */} + void signOutFlow(actions, 'sign-out', provider === 'microsoft' ? 'OneDrive' : 'Google Drive')} + />
    ); diff --git a/src/state/keeper.tsx b/src/state/keeper.tsx index 6061e2a..7328436 100644 --- a/src/state/keeper.tsx +++ b/src/state/keeper.tsx @@ -54,7 +54,9 @@ import { unwrapWithIdentity, type ShareRecord, } from '../lib/invites'; -import { ensureIdentity } from '../lib/identity'; +import { clearIdentity, ensureIdentity } from '../lib/identity'; +import { clearCiphertext } from '../lib/blobstore'; +import { assessSignOut, type SignOutRisk } from '../lib/sign-out'; import { pickSharedItems } from '../lib/picker'; import { grantAccess, @@ -245,7 +247,15 @@ export interface KeeperActions { driveUsage(): Promise; currentVaultFile(): VaultFile | null; signOut(): Promise; - wipeDevice(): void; + /** + * What leaving this device would cost, after one attempt to sync the + * difference away. Ask this first; `signOutOfDevice` does not ask again. + */ + signOutRisk(): Promise; + /** Leaves this device as if the person had never used Keeper on it. */ + signOutOfDevice(): Promise; + /** The same as `signOutOfDevice`: one implementation, two doors. */ + wipeDevice(): Promise; setClientId(clientId: string): void; notify(message: string): void; dismissError(): void; @@ -347,8 +357,18 @@ export function KeeperProvider({ children }: { children: ReactNode }) { const driveIdRef = useRef(undefined); const revisionRef = useRef(undefined); const syncTimerRef = useRef(undefined); - /** A change that has not reached the Drive yet, and the retry chasing it. */ - const pendingSyncRef = useRef(false); + /** + * A change that has not reached the cloud yet, and the retry chasing it. + * + * Starts from storage, not from false: an edit made just before a reload is + * still waiting to go up, and both the retry loop and signing out need to + * know that. `setPending` keeps the two copies in step. + */ + const pendingSyncRef = useRef(storage.isSyncPending()); + const setPending = useCallback((pending: boolean) => { + pendingSyncRef.current = pending; + storage.markSyncPending(pending); + }, []); const retryTimerRef = useRef(undefined); const retryAttemptRef = useRef(0); const bootedRef = useRef(false); @@ -539,7 +559,7 @@ export function KeeperProvider({ children }: { children: ReactNode }) { // token aged out is what used to park the app in "somente local" until // someone pressed Sincronizar by hand. if (!drive || !auth || !navigator.onLine) { - pendingSyncRef.current = true; + setPending(true); patch({ sync: { status: 'pending', @@ -556,7 +576,7 @@ export function KeeperProvider({ children }: { children: ReactNode }) { // bury today's edits in a file the other devices abandoned — better to // stop and say so. A forced sync is still the way out, deliberately. if (movedElsewhereRef.current && drive.space.kind === 'appdata' && !options.force) { - pendingSyncRef.current = true; + setPending(true); patch({ sync: { status: 'conflict', @@ -597,7 +617,7 @@ export function KeeperProvider({ children }: { children: ReactNode }) { ...(result.revision ? { driveRevision: result.revision } : {}), cachedAt: new Date().toISOString(), }); - pendingSyncRef.current = false; + setPending(false); retryAttemptRef.current = 0; window.clearTimeout(retryTimerRef.current); patch({ sync: { status: 'saved', at: new Date().toISOString() } }); @@ -615,7 +635,7 @@ export function KeeperProvider({ children }: { children: ReactNode }) { const message = error instanceof Error ? error.message : 'Falha ao sincronizar.'; // A failed sync is a promise to try again, not a dead end: the change // is already safe on this device, and the retry loop chases it. - pendingSyncRef.current = true; + setPending(true); // An expired Google session is not a failure to retry — no timer can // fix it, because reconnecting needs a tap. Say that instead of // blinking an error every few minutes. @@ -632,7 +652,7 @@ export function KeeperProvider({ children }: { children: ReactNode }) { const scheduleSync = useCallback(() => { // Marked pending the moment a change happens, not when the debounce fires: // the retry loop is then responsible for it even if this tab goes away. - pendingSyncRef.current = true; + setPending(true); window.clearTimeout(syncTimerRef.current); syncTimerRef.current = window.setTimeout(() => { syncTimerRef.current = undefined; @@ -2071,27 +2091,109 @@ export function KeeperProvider({ children }: { children: ReactNode }) { }); }, [patch]); - const wipeDevice = useCallback(() => { - storage.wipeLocalData(); - void clearDerivedKey(); + /** + * What signing out would cost, measured after trying to close the gap. + * + * With the vault open, the account live and something still waiting to go + * up, one sync is attempted first: when it lands, the cloud holds everything + * and there is nothing to warn about. A locked vault cannot sync — there is no + * key in memory — so the stored pending flag answers instead, which is the + * reason that flag is stored at all. + */ + const signOutRisk = useCallback(async (): Promise => { + const cached = storage.loadCachedVault(); + const ownOpen = !!payloadRef.current && !guestRef.current; + const hasVault = !!cached || ownOpen || !!ownSessionRef.current; + const hasRemote = !!(driveIdRef.current ?? cached?.driveFileId); + + if (hasVault && hasRemote && pendingSyncRef.current && ownOpen && authRef.current?.isSignedIn && navigator.onLine) { + await runSync({ silent: true }).catch(() => undefined); + } + + const reason = !navigator.onLine + ? 'Este aparelho está sem internet.' + : !authRef.current?.isSignedIn + ? `A conta do ${serviceName()} não está conectada neste aparelho.` + : 'A última sincronização não terminou.'; + return assessSignOut({ hasVault, hasRemote, pending: pendingSyncRef.current, reason }); + }, [runSync]); + + /** + * Leaves this device as if the person had never used Keeper on it. + * + * Everything that is theirs goes: the vault cache, the stored key, the cached + * attachments, the invite identity, the account, the shared vaults they had + * been let into, the trail of recent types — and the token, revoked rather + * than merely forgotten. Configuration and looks stay. + * + * The wipe in Advanced used to stop at localStorage and the stored key, and + * left the encrypted attachment cache and the invite identity behind, with + * the token still live in memory. Both doors now open this one. + * + * It does not ask: by the time it runs, `signOutRisk` has been read to the + * person and they have said yes. + */ + const signOutOfDevice = useCallback(async () => { + window.clearTimeout(syncTimerRef.current); + window.clearTimeout(retryTimerRef.current); + // Revoked while the auth object still exists. Not awaited: a network round + // trip must not stand between the click and the screen changing. + void Promise.resolve(authRef.current?.signOut()).catch(() => undefined); + authRef.current = null; + driveRef.current = null; + providerRef.current = null; + derivedRef.current = null; + keysRef.current = null; payloadRef.current = null; fileRef.current = null; + guestRef.current = null; + guestKeyRef.current = null; + ownSessionRef.current = null; driveIdRef.current = undefined; revisionRef.current = undefined; + movedElsewhereRef.current = false; + retryAttemptRef.current = 0; + + // Synchronous first: this is what a reload straight after would find. + // `wipeLocalData` also drops the stored pending flag. + storage.wipeLocalData(); + pendingSyncRef.current = false; + storage.setProvider('google'); + void clearClipboard(); + // `clearDerivedKey` records the revocation synchronously before its + // transaction, so the key cannot come back even if this page dies here. + await Promise.all([ + clearDerivedKey(), + clearCiphertext().catch(() => undefined), + clearIdentity().catch(() => undefined), + ]); + setState((current) => ({ ...current, phase: storage.getClientId() ? 'signin' : 'config', + busy: false, + error: null, payload: null, account: null, connected: false, hasLocalVault: false, + sync: { status: 'idle' }, biometricEnrolled: false, biometricReady: false, - notice: `Dados locais apagados. O cofre no ${serviceName()} permanece intacto.`, + provider: 'google', + driveFolderId: null, + driveMove: null, + driveMovedElsewhere: false, + guest: null, + workspaces: [], + activeWorkspace: OWN_WORKSPACE, + notice: 'Você saiu deste aparelho. Nada seu ficou neste navegador.', })); }, []); + const wipeDevice = signOutOfDevice; + const setClientId = useCallback( (clientId: string) => { storage.setClientId(clientId); @@ -2330,6 +2432,8 @@ export function KeeperProvider({ children }: { children: ReactNode }) { currentVaultFile, signOut, wipeDevice, + signOutRisk, + signOutOfDevice, setClientId, notify: (message: string) => patch({ notice: message }), dismissError: () => patch({ error: null }), @@ -2386,6 +2490,8 @@ export function KeeperProvider({ children }: { children: ReactNode }) { unlockWithBiometrics, updatePreferences, wipeDevice, + signOutRisk, + signOutOfDevice, ], );