Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
7 changes: 7 additions & 0 deletions web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
130 changes: 130 additions & 0 deletions web/src/lib/account-deletion.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<SignInResult>;
/** Sign out. */
signOut: () => Promise<void>;
/** Delete the signed-in account the way the app does, then sign out. */
deleteAccount: () => Promise<void>;
}

/**
* 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<string | undefined>();
/** How deleting went. */
stage = $state<DeletionStage>("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<DeletionBackend>;
private backend: Promise<DeletionBackend> | undefined;

constructor(load: () => Promise<DeletionBackend>) {
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<void> {
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<void> {
const backend = await this.connect();
await backend.signOut();
}

/** Delete the signed-in account and everything synced to it. */
async remove(): Promise<void> {
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<DeletionBackend> {
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;
}
}
166 changes: 166 additions & 0 deletions web/src/lib/account-deletion.test.ts
Original file line number Diff line number Diff line change
@@ -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<SignInResult> {
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<void> {
calls.push("signOut");
listener(undefined);
return Promise.resolve();
},
deleteAccount(): Promise<void> {
calls.push("deleteAccount");
if (options.deleted === undefined) {
listener(undefined);
return Promise.resolve();
} else {
return Promise.reject(options.deleted);
}
},
};
}

function quietly<Result>(task: () => Promise<Result>): Promise<Result> {
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<DeletionBackend>();
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");
});
19 changes: 19 additions & 0 deletions web/src/lib/components/google-mark.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!-- @component Google's "G" mark, for the Continue with Google button. -->
<svg viewBox="0 0 24 24" class="size-4 shrink-0" aria-hidden="true">
<path
fill="#4285F4"
d="M23.5 12.3c0-.8-.1-1.6-.2-2.3H12v4.4h6.5a5.6 5.6 0 0 1-2.4 3.6v3h3.9c2.2-2.1 3.5-5.1 3.5-8.7z"
/>
<path
fill="#34A853"
d="M12 24c3.2 0 6-1.1 7.9-2.9l-3.9-3c-1.1.7-2.4 1.2-4 1.2-3.1 0-5.7-2.1-6.6-4.9h-4v3.1A12 12 0 0 0 12 24z"
/>
<path
fill="#FBBC05"
d="M5.4 14.4a7.2 7.2 0 0 1 0-4.7V6.6h-4a12 12 0 0 0 0 10.9l4-3.1z"
/>
<path
fill="#EA4335"
d="M12 4.8c1.8 0 3.3.6 4.6 1.8l3.4-3.4A12 12 0 0 0 1.4 6.6l4 3.1C6.3 6.9 8.9 4.8 12 4.8z"
/>
</svg>
14 changes: 14 additions & 0 deletions web/src/lib/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
24 changes: 24 additions & 0 deletions web/src/lib/signed-in.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading