From 936a519f45caf84fd7102a247bdfdcc93c57e12f Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 11 Apr 2026 11:51:40 +0800 Subject: [PATCH 01/45] feat(session): store password for account backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add password field to Session type so the backup modal can show credentials later. The password is generated in doSignUp() and was previously discarded after the API call. Old sessions are backfilled with an empty string — the password is lost but the token still works. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/session.test.ts | 6 +++++- src/lib/session.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts index de36fad7a..641278b99 100644 --- a/src/lib/session.test.ts +++ b/src/lib/session.test.ts @@ -20,11 +20,15 @@ async function createTestSession() { // pre-populated localStorage function seedStorage(data: { username: string; + password?: string; token: string; savedPlaces: number[]; savedAreas?: number[]; }) { - localStorage.setItem("btcmap_session", JSON.stringify(data)); + localStorage.setItem( + "btcmap_session", + JSON.stringify({ password: "pw", ...data }), + ); } describe("session store", () => { diff --git a/src/lib/session.ts b/src/lib/session.ts index 31ba7f30b..5521a9a7a 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -19,6 +19,7 @@ import api from "$lib/axios"; // For real accounts, migrate to httpOnly cookies or similar. export type Session = { username: string; + password: string; token: string; savedPlaces: number[]; savedAreas: number[]; @@ -43,6 +44,12 @@ function loadFromStorage(): Session | null { if (!Array.isArray(parsed.savedAreas)) { parsed.savedAreas = []; } + // Backfill password for sessions created before backup was available. + // Empty string means the password is lost — the user can still use + // their current token but can't back up or log in on another device. + if (typeof parsed.password !== "string") { + parsed.password = ""; + } return parsed as Session; } catch { return null; @@ -83,6 +90,7 @@ function createSessionStore() { const session: Session = { username, + password, token, savedPlaces: [], savedAreas: [], From 16821d00ce8c4dcf2e14c1108ecb8342b7c13514 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 11 Apr 2026 11:57:47 +0800 Subject: [PATCH 02/45] feat(nav): add UserMenu dropdown replacing bookmark icon Replace the bookmark link in the header with a user icon that opens a dropdown menu with "My Saved" and "Log out". Uses svelte-outclick for outside click handling (same as NavDropdownDesktop). Only visible when the user has a session. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/Header.svelte | 25 ++--------- src/components/layout/UserMenu.svelte | 63 +++++++++++++++++++++++++++ src/lib/i18n/locales/en.json | 5 ++- 3 files changed, 70 insertions(+), 23 deletions(-) create mode 100644 src/components/layout/UserMenu.svelte diff --git a/src/components/layout/Header.svelte b/src/components/layout/Header.svelte index 5727857e3..9813acc5c 100644 --- a/src/components/layout/Header.svelte +++ b/src/components/layout/Header.svelte @@ -1,12 +1,11 @@ + +{#if $session} +
+ + + {#if open} + (open = false)} + > +
+ + + {$_("nav.mySaved")} + + +
+ + +
+
+ {/if} +
+{/if} diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 86118cf2f..d4f6e601e 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -35,7 +35,10 @@ "taggingIssues": "Tagging Issues", "communities": "Communities", "countries": "Countries", - "saved": "Saved" + "saved": "Saved", + "mySaved": "My Saved", + "account": "Account", + "logout": "Log out" }, "saved": { "title": "My Saved", From c069b07c77af156009bff1d92fcdc2505c43b178 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 11 Apr 2026 12:05:29 +0800 Subject: [PATCH 03/45] fix: add password backfill test and aria-haspopup on UserMenu - Add test for password backfill when loading old sessions without the password field (mirrors existing savedAreas backfill test) - Add aria-haspopup="true" to UserMenu trigger button for screen readers (matches NavDropdownDesktop pattern) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/UserMenu.svelte | 1 + src/lib/session.test.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/layout/UserMenu.svelte b/src/components/layout/UserMenu.svelte index d130e5e92..a02787eff 100644 --- a/src/components/layout/UserMenu.svelte +++ b/src/components/layout/UserMenu.svelte @@ -26,6 +26,7 @@ function handleLogout() { on:click={() => (open = !open)} class="text-white transition-opacity hover:opacity-80" aria-label={$_("nav.account")} + aria-haspopup="true" aria-expanded={open} > diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts index 641278b99..f69ebe70d 100644 --- a/src/lib/session.test.ts +++ b/src/lib/session.test.ts @@ -123,7 +123,6 @@ describe("session store", () => { describe("loadFromStorage backfill", () => { it("backfills savedAreas when missing from old session", async () => { - // Simulate an old session without savedAreas localStorage.setItem( "btcmap_session", JSON.stringify({ @@ -140,6 +139,23 @@ describe("session store", () => { expect(current?.savedPlaces).toEqual([1, 2]); }); + it("backfills password when missing from old session", async () => { + localStorage.setItem( + "btcmap_session", + JSON.stringify({ + username: "old-user", + token: "old-tok", + savedPlaces: [1], + savedAreas: [], + }), + ); + const session = await createTestSession(); + session.init(); + + const current = get(session); + expect(current?.password).toBe(""); + }); + it("returns null for invalid data", async () => { localStorage.setItem("btcmap_session", "not-json"); const session = await createTestSession(); From 201da3d86a1f912b59930193aca38ad89bdc8696 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 11 Apr 2026 12:08:12 +0800 Subject: [PATCH 04/45] feat(nav): show UserMenu always with login/logout states UserMenu is now always visible: - Logged out: outline account icon, dropdown shows "Log in" - Logged in: filled account icon, dropdown shows "My Saved" + "Log out" Links to /login which will be built in the next step. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Icon.svelte | 1 + src/components/layout/UserMenu.svelte | 61 ++++++++++++++++----------- src/lib/i18n/locales/en.json | 1 + 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 869d4cf46..9efb1e632 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -21,6 +21,7 @@ const materialExceptions: Record = { close_round: "ic:round-close", my_location: "material-symbols:my-location-rounded", bookmark_filled: "ic:baseline-bookmark", + account_circle_filled: "ic:baseline-account-circle", }; const faBrandIcons = ["x-twitter", "instagram", "facebook", "twitter"]; diff --git a/src/components/layout/UserMenu.svelte b/src/components/layout/UserMenu.svelte index a02787eff..d3800fc29 100644 --- a/src/components/layout/UserMenu.svelte +++ b/src/components/layout/UserMenu.svelte @@ -19,27 +19,32 @@ function handleLogout() { } -{#if $session} -
- +
+ - {#if open} - (open = false)} + {#if open} + (open = false)} + > + -{/if} + {:else} + + + {$_("nav.login")} + + {/if} +
+ + {/if} +
diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index d4f6e601e..23fff5597 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -38,6 +38,7 @@ "saved": "Saved", "mySaved": "My Saved", "account": "Account", + "login": "Log in", "logout": "Log out" }, "saved": { From c15c60324acc6c430712acd156b4908329560b67 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 11 Apr 2026 12:11:53 +0800 Subject: [PATCH 05/45] refactor(nav): remove logout, users always have an account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every user gets an auto-generated account on first save. "Log out" made no sense — clicking Save again would just create another throwaway. Account switching will be handled by the login flow which replaces the current session. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/UserMenu.svelte | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/components/layout/UserMenu.svelte b/src/components/layout/UserMenu.svelte index d3800fc29..665c21b5c 100644 --- a/src/components/layout/UserMenu.svelte +++ b/src/components/layout/UserMenu.svelte @@ -12,11 +12,6 @@ let open = false; afterNavigate(() => { open = false; }); - -function handleLogout() { - session.clear(); - open = false; -}
@@ -52,16 +47,6 @@ function handleLogout() { {$_("nav.mySaved")} - -
- - {:else} Date: Sat, 11 Apr 2026 12:54:12 +0800 Subject: [PATCH 06/45] feat(auth): add login page and server route - POST /api/session/login server route proxying token creation - /login page with username + password form - session.login() method to replace current session - After login, fetches saved places/areas from server to populate store - Redirects to /saved on success - Error toast on invalid credentials or server error Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/i18n/locales/en.json | 9 ++ src/lib/session.ts | 14 +++ src/routes/api/session/login/+server.ts | 42 ++++++++ src/routes/login/+page.svelte | 123 ++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 src/routes/api/session/login/+server.ts create mode 100644 src/routes/login/+page.svelte diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 23fff5597..c1eb2cf68 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -50,6 +50,15 @@ "noAreas": "No saved areas yet.", "loadError": "Failed to load some saved items." }, + "login": { + "title": "Log in", + "username": "Username", + "password": "Password", + "submit": "Log in", + "loggingIn": "Logging in...", + "failed": "Invalid username or password.", + "error": "Something went wrong. Please try again." + }, "search": { "placeholderWorldwide": "Search worldwide...", "placeholderNearby": "Search nearby...", diff --git a/src/lib/session.ts b/src/lib/session.ts index 5521a9a7a..285891120 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -201,6 +201,20 @@ function createSessionStore() { return result; }, + // Replace the current session with a different account (login flow). + // Saved items are populated separately after login. + login: (username: string, password: string, token: string) => { + const session: Session = { + username, + password, + token, + savedPlaces: [], + savedAreas: [], + }; + saveToStorage(session); + set(session); + }, + // Clear the session (logout / forget account). No recovery. clear: () => { saveToStorage(null); diff --git a/src/routes/api/session/login/+server.ts b/src/routes/api/session/login/+server.ts new file mode 100644 index 000000000..1e74e8a21 --- /dev/null +++ b/src/routes/api/session/login/+server.ts @@ -0,0 +1,42 @@ +import { error, json } from "@sveltejs/kit"; + +import api from "$lib/axios"; + +import type { RequestHandler } from "./$types"; + +// POST /api/session/login +// Authenticates with username + password and returns a Bearer token. +// Proxies POST /v4/users/{username}/tokens to avoid CORS preflight issues. +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const { username, password } = body; + + if (!username || typeof username !== "string") { + error(400, "Missing required parameter: username"); + } + if (!password || typeof password !== "string") { + error(400, "Missing required parameter: password"); + } + + const tokenRes = await api + .post( + `https://api.btcmap.org/v4/users/${encodeURIComponent(username)}/tokens`, + {}, + { headers: { Authorization: `Bearer ${password}` } }, + ) + .catch((err) => { + const status = err?.response?.status; + if (status === 401 || status === 403) { + error(401, "Invalid username or password"); + } + console.error("Failed to create token:", err?.response?.data ?? err); + error(502, "Failed to log in"); + }); + + const token = tokenRes.data?.token; + if (typeof token !== "string") { + error(502, "Token creation returned no token"); + } + + return json({ token }); +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 000000000..48ffb340b --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,123 @@ + + + + {$_("login.title")} | BTC Map + + +
+
+

+ {$_("login.title")} +

+ +
+
+ + +
+ +
+ + +
+ + +
+
+
From 8045aebd19d0af8cfd8a719539f0350c272b96d1 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 11 Apr 2026 13:00:30 +0800 Subject: [PATCH 07/45] feat(nav): add "Switch account" to logged-in user menu Since every user always has an account (auto-generated on first save), they need a way to switch to a different account without a logout step. Links to /login which replaces the current session. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/UserMenu.svelte | 8 ++++++++ src/lib/i18n/locales/en.json | 1 + 2 files changed, 9 insertions(+) diff --git a/src/components/layout/UserMenu.svelte b/src/components/layout/UserMenu.svelte index 665c21b5c..efb7b6a98 100644 --- a/src/components/layout/UserMenu.svelte +++ b/src/components/layout/UserMenu.svelte @@ -47,6 +47,14 @@ afterNavigate(() => { {$_("nav.mySaved")}
+ + + + {$_("nav.switchAccount")} + {:else} Date: Sat, 11 Apr 2026 13:05:13 +0800 Subject: [PATCH 08/45] feat(nav): show username as tooltip on account icon Hovering the user icon now shows the account username (e.g. "glossy-wealth-1878") via the title attribute. Shows "Account" when not logged in. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/UserMenu.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/layout/UserMenu.svelte b/src/components/layout/UserMenu.svelte index efb7b6a98..90293be6c 100644 --- a/src/components/layout/UserMenu.svelte +++ b/src/components/layout/UserMenu.svelte @@ -19,7 +19,8 @@ afterNavigate(() => { id="user-menu-trigger" on:click={() => (open = !open)} class="text-white transition-opacity hover:opacity-80" - aria-label={$_("nav.account")} + aria-label={$session?.username ?? $_("nav.account")} + title={$session?.username ?? $_("nav.account")} aria-haspopup="true" aria-expanded={open} > From b1df7bc82113cb75059522802119d00e52e1cc0c Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 11 Apr 2026 13:11:15 +0800 Subject: [PATCH 09/45] feat(save): show toast on first account creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user clicks Save for the first time and the throwaway account is silently created, show a success toast: "Account created — your saved places are stored on this device." Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SaveButton.svelte | 3 ++- src/lib/i18n/locales/en.json | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/SaveButton.svelte b/src/components/SaveButton.svelte index fb7d77a28..7eda3ffc1 100644 --- a/src/components/SaveButton.svelte +++ b/src/components/SaveButton.svelte @@ -3,7 +3,7 @@ import Icon from "$components/Icon.svelte"; import api from "$lib/axios"; import { _ } from "$lib/i18n"; import { session } from "$lib/session"; -import { errToast } from "$lib/utils"; +import { errToast, successToast } from "$lib/utils"; // The numeric ID of the item to save/unsave. export let id: number; @@ -56,6 +56,7 @@ async function toggle() { let current = previousSession; if (!current) { current = await session.signUp(); + successToast($_("save.accountCreated")); } const nextSaved = diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 4b86be7f7..badd5afb2 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -60,6 +60,9 @@ "failed": "Invalid username or password.", "error": "Something went wrong. Please try again." }, + "save": { + "accountCreated": "Account created — your saved places are stored on this device." + }, "search": { "placeholderWorldwide": "Search worldwide...", "placeholderNearby": "Search nearby...", From bb11dc60e8fc01f3f4541168b47e715161f239cb Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 11 Apr 2026 13:14:12 +0800 Subject: [PATCH 10/45] fix(nav): match user icon button size to theme toggle Theme toggle is h-10 w-10. User menu trigger was unsized, causing visual misalignment. Match the dimensions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/UserMenu.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/UserMenu.svelte b/src/components/layout/UserMenu.svelte index 90293be6c..c5c453d71 100644 --- a/src/components/layout/UserMenu.svelte +++ b/src/components/layout/UserMenu.svelte @@ -18,7 +18,7 @@ afterNavigate(() => { {:else} Date: Sat, 11 Apr 2026 13:31:54 +0800 Subject: [PATCH 14/45] feat(auth): add BackupModal and remove switch account confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BackupModal shows username + password with copy buttons and show/hide toggle. Accessible from "Back up account" in UserMenu. - Old accounts without a stored password show "Not available" - Remove switch account confirmation — if the user knows their credentials (just logged in), switching is safe - Remove dead switchAccountConfirm i18n key Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/auth/BackupModal.svelte | 120 +++++++++++++++++++++++++ src/components/layout/UserMenu.svelte | 26 ++++-- src/lib/i18n/locales/en.json | 13 ++- 3 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 src/components/auth/BackupModal.svelte diff --git a/src/components/auth/BackupModal.svelte b/src/components/auth/BackupModal.svelte new file mode 100644 index 000000000..72ff560b9 --- /dev/null +++ b/src/components/auth/BackupModal.svelte @@ -0,0 +1,120 @@ + + + + +
dispatch("close")} +> +
+
+

+ {$_("backup.title")} +

+ +
+ +

+ {$_("backup.description")} +

+ + {#if $session} +
+
+ +
+ + +
+
+ +
+ +
+ + + {#if $session.password} + + {/if} +
+
+
+ +

+ {$_("backup.warning")} +

+ {/if} +
+
diff --git a/src/components/layout/UserMenu.svelte b/src/components/layout/UserMenu.svelte index ed2df4b60..7fac8f45b 100644 --- a/src/components/layout/UserMenu.svelte +++ b/src/components/layout/UserMenu.svelte @@ -1,13 +1,15 @@ + e.key === "Escape" && dispatch("close")} /> +
Date: Sat, 11 Apr 2026 14:15:58 +0800 Subject: [PATCH 22/45] fix(nav): replace "Switch account" with "Log out" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users start with no account, so "Switch account" was confusing — it implies multiple accounts. "Log out" is clearer: it means "forget this session." If they want a different account, they log out first, then log in. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/UserMenu.svelte | 15 +++++++++------ src/lib/i18n/locales/en.json | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/layout/UserMenu.svelte b/src/components/layout/UserMenu.svelte index 1973d8015..c67dff490 100644 --- a/src/components/layout/UserMenu.svelte +++ b/src/components/layout/UserMenu.svelte @@ -66,13 +66,16 @@ afterNavigate(() => {
-
{ + session.clear(); + open = false; + }} + class="flex w-full items-center gap-2 px-4 py-2 text-sm text-primary transition-colors hover:bg-gray-100 dark:text-white dark:hover:bg-white/10" > - - {$_("nav.switchAccount")} - + + {$_("nav.logout")} + {:else} Date: Sat, 11 Apr 2026 14:20:52 +0800 Subject: [PATCH 23/45] fix(nav): use unique IDs for UserMenu trigger buttons Header renders UserMenu twice (desktop + mobile), producing duplicate DOM IDs. Use a random suffix per instance so each trigger has a unique ID and the OutClick exclusion works correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/UserMenu.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/layout/UserMenu.svelte b/src/components/layout/UserMenu.svelte index c67dff490..6646fee69 100644 --- a/src/components/layout/UserMenu.svelte +++ b/src/components/layout/UserMenu.svelte @@ -10,6 +10,7 @@ import { afterNavigate } from "$app/navigation"; let open = false; let showBackup = false; +const triggerId = `user-menu-trigger-${Math.random().toString(36).slice(2, 8)}`; afterNavigate(() => { open = false; @@ -18,7 +19,7 @@ afterNavigate(() => {
{#if $session.password} + +
+
+ {$_("login.or")} +
+
+ {/if} +