diff --git a/PLAN.md b/PLAN.md index 75aa2f3..d115843 100644 --- a/PLAN.md +++ b/PLAN.md @@ -114,7 +114,9 @@ from a device. them, so first sync stays fast however long the history is. Progress is kept, so a reload carries on where it stopped. - **Account deletion:** Delete account in the sync section (pressed twice to - confirm) first signs in again, straight from the press, so Firebase accepts + confirm), or the public page https://reader.hafa.cc/delete (which Google + Play requires; it works in any browser, with no extension and no reader + data, and runs the same deletion code), first signs in again, straight from the press, so Firebase accepts deleting the sign-in; closing that changes nothing. It then waits for writes on their way, removes the user's subscriptions, entries and settings from Firestore, the device's sync progress for that account, and the Firebase diff --git a/web/README.md b/web/README.md index 51d3fc6..761ac05 100644 --- a/web/README.md +++ b/web/README.md @@ -15,6 +15,7 @@ bun export # static site in build/ Public settings (the extension's id, the Firebase web config) are committed in `src/lib/config.ts`; there are no environment variables. The privacy policy and -terms are plain pages in `src/routes/(policy)/`, built without script. +terms are plain pages in `src/routes/(policy)/`, built without script; the +delete account page there (`/delete`) is the one with script. `bun scripts/icons.ts` regenerates `static/` icons from `../design/icons/logo.svg` (needs rsvg-convert and ImageMagick). diff --git a/web/src/app.css b/web/src/app.css index 45fb360..2c58f62 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -114,6 +114,13 @@ input, --tw-prose-td-borders: var(--line); } +/* the delete page's wait, shown only when it lasts so a quick one never flashes */ +@keyframes appear { + from { + opacity: 0; + } +} + @keyframes dot-pulse { 50% { opacity: 0.45; diff --git a/web/src/lib/account-deletion.svelte.ts b/web/src/lib/account-deletion.svelte.ts new file mode 100644 index 0000000..44a86ee --- /dev/null +++ b/web/src/lib/account-deletion.svelte.ts @@ -0,0 +1,130 @@ +import { rememberSignedIn, wasSignedIn } from "./signed-in.ts"; +import { errorSummary } from "./sync/errors.ts"; +import type { SignInResult } from "./sync/firebase.ts"; + +/** Where the delete page is: before deleting, while deleting, done, or failed. */ +export type DeletionStage = "idle" | "working" | "deleted" | "failed"; + +/** What the delete page shows: a wait for the remembered account, or signed out, or signed in. */ +export type DeletionView = "loading" | "signedOut" | "signedIn"; + +/** What the delete page needs from Firebase; tests pass a stand-in. */ +export interface DeletionBackend { + /** Follow who is signed in: their email, or undefined when no one is. */ + watch: (onUser: (email: string | undefined) => void) => void; + /** Sign in with Google in a pop-up window. */ + signIn: () => Promise; + /** Sign out. */ + signOut: () => Promise; + /** Delete the signed-in account the way the app does, then sign out. */ + deleteAccount: () => Promise; +} + +/** + * The delete page's state: who is signed in, and how deleting went. Firebase + * loads only when asked for (a pointer or focus on a button, or right away + * when this browser was signed in last time), so the sign-in window can open + * straight from the click. + */ +export class AccountDeletion { + /** The signed-in account's email; undefined when no one is signed in or it is not known yet. */ + email = $state(); + /** How deleting went. */ + stage = $state("idle"); + /** Why the last sign-in did not happen: blocked window, failure, or nothing. */ + signInProblem = $state<"" | "blocked" | "failed">(""); + /** + * Whether who is signed in is known: at once for a browser not signed in + * last time, otherwise once Firebase has said, so the page does not show + * signed out and then switch. + */ + known = $state(true); + + /** What the page shows. */ + view = $derived.by((): DeletionView => { + let shown: DeletionView = "signedOut"; + if (!this.known) { + shown = "loading"; + } else if (this.email !== undefined) { + shown = "signedIn"; + } + return shown; + }); + + private readonly load: () => Promise; + private backend: Promise | undefined; + + constructor(load: () => Promise) { + this.load = load; + if (wasSignedIn()) { + this.known = false; + this.preload(); + } + } + + /** Load Firebase ahead of a click. */ + preload(): void { + this.connect().catch((error: unknown) => { + console.debug("Reader: sign-in failed to load", errorSummary(error)); + // without Firebase the page can still offer sign-in, which loads it again + this.known = true; + }); + } + + /** Sign in with Google. */ + async signIn(): Promise { + this.signInProblem = ""; + try { + const backend = await this.connect(); + if ((await backend.signIn()) === "blocked") { + this.signInProblem = "blocked"; + } + } catch (error) { + console.error("Reader sign-in failed:", errorSummary(error)); + this.signInProblem = "failed"; + } + } + + /** Sign out, to sign in with another account. */ + async useOtherAccount(): Promise { + const backend = await this.connect(); + await backend.signOut(); + } + + /** Delete the signed-in account and everything synced to it. */ + async remove(): Promise { + this.stage = "working"; + try { + const backend = await this.connect(); + await backend.deleteAccount(); + rememberSignedIn(false); + this.stage = "deleted"; + } catch (error) { + console.error( + "Reader could not delete the account:", + errorSummary(error), + ); + this.stage = "failed"; + } + } + + /** Load Firebase once; a failed load can be tried again. */ + private connect(): Promise { + if (this.backend === undefined) { + const loading = this.load().then((backend) => { + backend.watch((email) => { + this.email = email; + this.known = true; + }); + return backend; + }); + this.backend = loading; + loading.catch(() => { + if (this.backend === loading) { + this.backend = undefined; + } + }); + } + return this.backend; + } +} diff --git a/web/src/lib/account-deletion.test.ts b/web/src/lib/account-deletion.test.ts new file mode 100644 index 0000000..f15cd08 --- /dev/null +++ b/web/src/lib/account-deletion.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, expect, test } from "bun:test"; +import { + AccountDeletion, + type DeletionBackend, +} from "./account-deletion.svelte.ts"; +import type { SignInResult } from "./sync/firebase.ts"; + +/** A stand-in for Firebase: who is signed in, and how each call goes. */ +function backend(options: { + signIn?: SignInResult | Error; + deleted?: Error; +}): DeletionBackend & { calls: string[] } { + let listener: (email: string | undefined) => void = () => undefined; + const calls: string[] = []; + return { + calls, + watch(onUser): void { + listener = onUser; + onUser(undefined); + }, + signIn(): Promise { + calls.push("signIn"); + const outcome = options.signIn ?? "signed-in"; + if (outcome instanceof Error) { + return Promise.reject(outcome); + } else { + if (outcome === "signed-in") { + listener("reader@example.com"); + } + return Promise.resolve(outcome); + } + }, + signOut(): Promise { + calls.push("signOut"); + listener(undefined); + return Promise.resolve(); + }, + deleteAccount(): Promise { + calls.push("deleteAccount"); + if (options.deleted === undefined) { + listener(undefined); + return Promise.resolve(); + } else { + return Promise.reject(options.deleted); + } + }, + }; +} + +function quietly(task: () => Promise): Promise { + const original = console.error; + console.error = (): void => undefined; + return task().finally(() => { + console.error = original; + }); +} + +beforeEach(() => { + localStorage.clear(); +}); + +test("signing in shows the account, and deleting it ends signed out and done", async () => { + localStorage.setItem("h-reader:signed-in", "1"); + const stand = backend({}); + const deletion = new AccountDeletion(() => Promise.resolve(stand)); + await deletion.signIn(); + expect(deletion.email).toBe("reader@example.com"); + await deletion.remove(); + expect(deletion.stage).toBe("deleted"); + expect(deletion.email).toBeUndefined(); + expect(stand.calls).toEqual(["signIn", "deleteAccount"]); + expect(localStorage.getItem("h-reader:signed-in")).toBeNull(); +}); + +test("a failed deletion says so and can be tried again", async () => { + const deletion = new AccountDeletion(() => + Promise.resolve(backend({ deleted: new Error("offline") })), + ); + await deletion.signIn(); + await quietly(() => deletion.remove()); + expect(deletion.stage).toBe("failed"); + expect(deletion.email).toBe("reader@example.com"); +}); + +test("a blocked or failed sign-in is reported, and a closed one is not", async () => { + const blocked = new AccountDeletion(() => + Promise.resolve(backend({ signIn: "blocked" })), + ); + await blocked.signIn(); + expect(blocked.signInProblem).toBe("blocked"); + const failed = new AccountDeletion(() => + Promise.resolve(backend({ signIn: new Error("network") })), + ); + await quietly(() => failed.signIn()); + expect(failed.signInProblem).toBe("failed"); + const closed = new AccountDeletion(() => + Promise.resolve(backend({ signIn: "closed" })), + ); + await closed.signIn(); + expect(closed.signInProblem).toBe(""); + expect(closed.email).toBeUndefined(); +}); + +test("another account signs the first one out", async () => { + const stand = backend({}); + const deletion = new AccountDeletion(() => Promise.resolve(stand)); + await deletion.signIn(); + await deletion.useOtherAccount(); + expect(deletion.email).toBeUndefined(); + expect(stand.calls).toEqual(["signIn", "signOut"]); +}); + +test("Firebase loads once, and again only after a failed load", async () => { + let loads = 0; + const deletion = new AccountDeletion(() => { + loads += 1; + return loads === 1 + ? Promise.reject(new Error("offline")) + : Promise.resolve(backend({})); + }); + await quietly(() => deletion.signIn()); + expect(deletion.signInProblem).toBe("failed"); + deletion.preload(); + await deletion.signIn(); + expect(loads).toBe(2); + expect(deletion.email).toBe("reader@example.com"); +}); + +test("a browser signed in last time waits for its account instead of showing signed out first", async () => { + localStorage.setItem("h-reader:signed-in", "1"); + const { promise: loaded, resolve: finishLoading } = + Promise.withResolvers(); + const deletion = new AccountDeletion(() => loaded); + expect(deletion.view).toBe("loading"); + let report: (email: string | undefined) => void = () => undefined; + finishLoading({ + ...backend({}), + watch(onUser): void { + report = onUser; + }, + }); + await loaded; + expect(deletion.view).toBe("loading"); + report("reader@example.com"); + expect(deletion.view).toBe("signedIn"); +}); + +test("a browser never signed in shows signed out at once, without loading Firebase", () => { + let loads = 0; + const deletion = new AccountDeletion(() => { + loads += 1; + return Promise.resolve(backend({})); + }); + expect(deletion.view).toBe("signedOut"); + expect(loads).toBe(0); +}); + +test("a remembered sign-in that has ended shows signed out once Firebase says so", async () => { + localStorage.setItem("h-reader:signed-in", "1"); + const stand = backend({}); + const deletion = new AccountDeletion(() => Promise.resolve(stand)); + expect(deletion.view).toBe("loading"); + await Promise.resolve(); + await Promise.resolve(); + expect(deletion.view).toBe("signedOut"); +}); diff --git a/web/src/lib/components/google-mark.svelte b/web/src/lib/components/google-mark.svelte new file mode 100644 index 0000000..10a12b2 --- /dev/null +++ b/web/src/lib/components/google-mark.svelte @@ -0,0 +1,19 @@ + + diff --git a/web/src/lib/copy.ts b/web/src/lib/copy.ts index ce87e84..a356529 100644 --- a/web/src/lib/copy.ts +++ b/web/src/lib/copy.ts @@ -146,6 +146,20 @@ export const copy = { syncSwitchCancel: "Cancel sign-in", /** Button that starts deleting the signed-in account. */ syncDeleteAccount: "Delete account", + deletePageTitle: "Delete your Reader account", + deleteSignInLine: + "Sign in to delete your account and everything synced to it.", + deleteContinueGoogle: "Continue with Google", + deleteSignedInAs: "Signed in as", + deleteOtherAccount: "Use a different account", + deleteExplain: + "This deletes your account and everything synced to it: your feeds, articles, read marks and settings. It happens right away and can't be undone. Copies on your devices stay where they are.", + deleteEverything: "Delete everything", + deleteFailedPage: + "Deleting didn't finish. Some data may already be gone. Please try again.", + deleteDone: "Your account is deleted.", + deleteLoading: "Loading", + deleteAndroid: "The Android app has the same option in its account screen.", /** The same button once pressed, asking for a second press. */ syncDeleteConfirm: "Press again to delete your account", /** What deleting the account does. */ diff --git a/web/src/lib/signed-in.ts b/web/src/lib/signed-in.ts new file mode 100644 index 0000000..b070bd7 --- /dev/null +++ b/web/src/lib/signed-in.ts @@ -0,0 +1,24 @@ +/** Set while an account is signed in, so Firebase loads on the next visit. */ +const SIGNED_IN_KEY = "h-reader:signed-in"; + +/** Record whether an account is signed in on this browser. */ +export function rememberSignedIn(signedIn: boolean): void { + try { + if (signedIn) { + localStorage.setItem(SIGNED_IN_KEY, "1"); + } else { + localStorage.removeItem(SIGNED_IN_KEY); + } + } catch { + // storage can be blocked; sign-in then lasts only this visit + } +} + +/** Whether an account was signed in on this browser at the last visit. */ +export function wasSignedIn(): boolean { + try { + return localStorage.getItem(SIGNED_IN_KEY) === "1"; + } catch { + return false; + } +} diff --git a/web/src/lib/sync.svelte.ts b/web/src/lib/sync.svelte.ts index 5daa2d7..47743bd 100644 --- a/web/src/lib/sync.svelte.ts +++ b/web/src/lib/sync.svelte.ts @@ -1,6 +1,7 @@ import { type FirebaseSettings, firebaseConfig } from "./config.ts"; import { copy } from "./copy.ts"; import { accountPhoto } from "./photo.ts"; +import { rememberSignedIn, wasSignedIn } from "./signed-in.ts"; import type { Retention } from "./sync/documents.ts"; import { errorSummary } from "./sync/errors.ts"; import type { Engine } from "./sync/firebase.ts"; @@ -11,21 +12,6 @@ import type { SyncStatus, } from "./sync/host.ts"; -/** Set while an account is signed in, so Firebase loads on the next visit. */ -const SIGNED_IN_KEY = "h-reader:signed-in"; - -function rememberSignedIn(signedIn: boolean): void { - try { - if (signedIn) { - localStorage.setItem(SIGNED_IN_KEY, "1"); - } else { - localStorage.removeItem(SIGNED_IN_KEY); - } - } catch { - // storage can be blocked; sign-in then lasts only this visit - } -} - /** First wait before a failed start is tried again; it doubles with each failure. */ const START_RETRY_MS = 5000; /** Longest wait between tries of a failed start. */ @@ -45,14 +31,6 @@ import.meta.hot?.dispose(() => { liveEngines.clear(); }); -function wasSignedIn(): boolean { - try { - return localStorage.getItem(SIGNED_IN_KEY) === "1"; - } catch { - return false; - } -} - /** * Optional Google sign-in and Firestore sync. * diff --git a/web/src/lib/sync/firebase.ts b/web/src/lib/sync/firebase.ts index fa30dfc..88fca4a 100644 --- a/web/src/lib/sync/firebase.ts +++ b/web/src/lib/sync/firebase.ts @@ -46,6 +46,7 @@ import { where, writeBatch, } from "firebase/firestore"; +import type { DeletionBackend } from "../account-deletion.svelte.ts"; import type { FirebaseSettings } from "../config.ts"; import type { Entry } from "../types.ts"; import { BODIES, matches, shareable } from "./bodies.ts"; @@ -1409,6 +1410,87 @@ function forgetProgress(uid: string): void { } } +/** Sign in with Google in a pop-up window; closing it is not an error. */ +export async function signInWithGoogle(auth: Auth): Promise { + let result: SignInResult = "closed"; + try { + await signInWithPopup(auth, new GoogleAuthProvider()); + result = "signed-in"; + } catch (error) { + if (isBlocked(error)) { + result = "blocked"; + } else if (!isCancel(error)) { + throw error; + } + } + return result; +} + +/** What the caller of `deleteAccountOf` does at each step. */ +export interface DeleteSteps { + /** After signing in again, before anything is deleted: stop writing to the account. */ + stopping?: () => void; + /** Removing the documents failed, so the account is as it was: sync may carry on. */ + kept?: () => void; + /** The documents are gone; only the sign-in is left to remove. */ + emptied?: () => void; +} + +/** + * Delete a signed-in account, the one way both the app and the delete page + * use. Signing in again opens straight from the click, before anything is + * deleted, so Firebase accepts removing the sign-in; closing it changes + * nothing. Then, once writes on their way have landed, the account's + * documents go, with this device's sync progress for it, then the sign-in + * itself. It always ends signed out once the documents are gone, even when + * removing the sign-in fails, so nothing is uploaded into the emptied account. + */ +export async function deleteAccountOf( + firebase: Services, + current: User, + steps: DeleteSteps = {}, +): Promise { + await reauthenticateWithPopup(current, new GoogleAuthProvider()); + steps.stopping?.(); + try { + await deleteAccountData(firebase.db, current.uid); + } catch (error) { + steps.kept?.(); + throw error; + } + steps.emptied?.(); + forgetProgress(current.uid); + if (lastAccount() === current.uid) { + forgetAccount(); + } + try { + await deleteUser(current); + } finally { + await signOut(firebase.auth); + } +} + +/** The delete page's way into Firebase: sign-in and the app's own account deletion, with no reader behind it. */ +export function deletionBackend(settings: FirebaseSettings): DeletionBackend { + const firebase = firebaseServices(settings); + return { + watch(onUser): void { + onAuthStateChanged(firebase.auth, (user) => { + onUser(user?.email ?? undefined); + }); + }, + signIn: () => signInWithGoogle(firebase.auth), + signOut: () => signOut(firebase.auth), + async deleteAccount(): Promise { + const current = firebase.auth.currentUser; + if (current === null) { + throw new Error("no account is signed in"); + } + await deleteAccountOf(firebase, current); + }, + }; +} + /** Firebase's sign-in and database for the page. */ export interface Services { /** Google sign-in. */ @@ -1554,19 +1636,8 @@ export function startFirebase( update(); return { - async signIn(): Promise { - let result: SignInResult = "closed"; - try { - await signInWithPopup(auth, new GoogleAuthProvider()); - result = "signed-in"; - } catch (error) { - if (isBlocked(error)) { - result = "blocked"; - } else if (!isCancel(error)) { - throw error; - } - } - return result; + signIn(): Promise { + return signInWithGoogle(auth); }, async replaceLocal(): Promise { if (waiting && !replacing && user !== null) { @@ -1590,32 +1661,22 @@ export function startFirebase( async deleteAccount(): Promise { const current = auth.currentUser; if (current !== null) { - // signing in again opens straight from the click, before anything is - // deleted; closing or failing it leaves sync running as it was - await reauthenticateWithPopup(current, new GoogleAuthProvider()); const wasWaiting = waiting; - waiting = false; - // nothing may be written back while the account is emptied - session?.stop(true); - session = undefined; - heldForDelete = []; - try { - await deleteAccountData(db, current.uid); - } catch (error) { - resumeAfterFailedDelete(current.uid, wasWaiting); - throw error; - } - heldForDelete = undefined; - forgetProgress(current.uid); - if (lastAccount() === current.uid) { - forgetAccount(); - } - try { - await deleteUser(current); - } finally { - // the account's data is gone: signed out, this device's data is not uploaded into it again - await signOut(auth); - } + await deleteAccountOf({ auth, db }, current, { + stopping(): void { + waiting = false; + // nothing may be written back while the account is emptied + session?.stop(true); + session = undefined; + heldForDelete = []; + }, + kept(): void { + resumeAfterFailedDelete(current.uid, wasWaiting); + }, + emptied(): void { + heldForDelete = undefined; + }, + }); } }, async signOut(): Promise { diff --git a/web/src/routes/(policy)/delete/+page.svelte b/web/src/routes/(policy)/delete/+page.svelte new file mode 100644 index 0000000..2c8f3ac --- /dev/null +++ b/web/src/routes/(policy)/delete/+page.svelte @@ -0,0 +1,116 @@ + + + + {copy.deletePageTitle} + + + +

{copy.deletePageTitle}

+ +
+ {#if !mounted} + + {:else if deletion.stage === "deleted"} +
+

{copy.deleteDone}

+ {@render android()} +
+ {:else if deletion.view === "loading"} +
+ + {copy.deleteLoading} +
+ {:else if deletion.view === "signedIn"} +
+

+ {copy.deleteSignedInAs} + {deletion.email} +

+

+ +

+

{copy.deleteExplain}

+ {#if deletion.stage === "failed"} + + {/if} +
+ +
+ {@render android()} +
+ {:else} +
+

{copy.deleteSignInLine}

+
+ +
+ {#if deletion.signInProblem === "blocked"} + + {:else if deletion.signInProblem === "failed"} + + {/if} + {@render android()} +
+ {/if} +
+ +{#snippet android()} +

{copy.deleteAndroid}

+{/snippet} diff --git a/web/src/routes/(policy)/delete/+page.ts b/web/src/routes/(policy)/delete/+page.ts new file mode 100644 index 0000000..d517aa1 --- /dev/null +++ b/web/src/routes/(policy)/delete/+page.ts @@ -0,0 +1,2 @@ +/** Unlike the other policy pages, this one runs script: it signs in and deletes. */ +export const csr = true; diff --git a/web/src/routes/(policy)/privacy/+page.svelte b/web/src/routes/(policy)/privacy/+page.svelte index 6db42d8..0282d51 100644 --- a/web/src/routes/(policy)/privacy/+page.svelte +++ b/web/src/routes/(policy)/privacy/+page.svelte @@ -117,12 +117,13 @@ uninstalling the Android app removes what that device stored.
  • - To delete your account, sign in on the web or in the Android app, open the - sync section and press Delete account twice. It removes your feeds, - articles, read and starred marks and settings from your account, and your - Google sign-in from Reader, then signs you out. What is on your device stays - until you remove it as above. Shared article text holds nothing about you - and stays. + To delete your account, open Sync in the web app or the account screen in + the Android app and press Delete account twice, or use the + delete account page + in any browser. It removes your feeds, articles, read and starred marks and + settings from your account, and your Google sign-in from Reader, then signs + you out. What is on your device stays until you remove it as above. Shared + article text holds nothing about you and stays.
  • You can remove Reader's access to your Google account at any time at