diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 869d4cf46..e72ad138a 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -20,7 +20,8 @@ const materialExceptions: Record = { currency_bitcoin: "material-symbols:currency-bitcoin", close_round: "ic:round-close", my_location: "material-symbols:my-location-rounded", - bookmark_filled: "ic:baseline-bookmark", + bookmark_filled: "ic:baseline-bookmark-added", + account_circle_filled: "ic:baseline-account-circle", }; const faBrandIcons = ["x-twitter", "instagram", "facebook", "twitter"]; diff --git a/src/components/SaveButton.svelte b/src/components/SaveButton.svelte index fb7d77a28..94c786c2c 100644 --- a/src/components/SaveButton.svelte +++ b/src/components/SaveButton.svelte @@ -1,7 +1,14 @@ + +
+
+ +
+ + +
+
+
+ +
+ + + {#if password} + + {/if} +
+
+
+

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

diff --git a/src/components/auth/BackupModal.svelte b/src/components/auth/BackupModal.svelte new file mode 100644 index 000000000..3d785336b --- /dev/null +++ b/src/components/auth/BackupModal.svelte @@ -0,0 +1,21 @@ + + + +

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

+ + {#if $session} + + {/if} +
diff --git a/src/components/auth/LoginForm.svelte b/src/components/auth/LoginForm.svelte new file mode 100644 index 000000000..36dd7f52b --- /dev/null +++ b/src/components/auth/LoginForm.svelte @@ -0,0 +1,114 @@ + + +
+
+ + +
+ +
+ + +
+ + +
+ +{#if !compact} +

+ {$_("login.noAccount")} + + {$_("login.createAccount")} + +

+{/if} diff --git a/src/components/auth/SaveAuthPrompt.svelte b/src/components/auth/SaveAuthPrompt.svelte new file mode 100644 index 000000000..9c5fa897d --- /dev/null +++ b/src/components/auth/SaveAuthPrompt.svelte @@ -0,0 +1,177 @@ + + + + {#if view === "choice"} +

+ {$_(promptDescriptionKey)} +

+
+ + +
+ {:else if view === "login"} + + + {:else if view === "backup" && $session} +

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

+ + + {/if} +
diff --git a/src/components/layout/Header.svelte b/src/components/layout/Header.svelte index 5727857e3..5cdd3e9ae 100644 --- a/src/components/layout/Header.svelte +++ b/src/components/layout/Header.svelte @@ -1,12 +1,11 @@ + +
+ + + {#if open} + (open = false)} + > +
+ {#if $session} + + + {$_("nav.mySaved")} + + + {#if $session.autoGenerated} + + {/if} + +
+ + + {:else} + + + {$_("nav.login")} + + {/if} +
+
+ {/if} +
+ + diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 86118cf2f..a78947a31 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -35,7 +35,12 @@ "taggingIssues": "Tagging Issues", "communities": "Communities", "countries": "Countries", - "saved": "Saved" + "saved": "Saved", + "mySaved": "My Saved", + "account": "Account", + "login": "Log in", + "logout": "Log out", + "backupAccount": "Back up account" }, "saved": { "title": "My Saved", @@ -46,6 +51,44 @@ "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.", + "noAccount": "Don't have an account?", + "createAccount": "Create one here" + }, + "backup": { + "title": "Back up account", + "description": "Save these credentials to log in on another device.", + "username": "Username", + "password": "Password", + "copy": "Copy", + "show": "Show password", + "hide": "Hide password", + "unavailable": "Not available (old account)", + "warning": "These credentials are the only way to recover your saved places.", + "copied": "Copied to clipboard", + "copyFailed": "Copy failed — please copy manually" + }, + "save": { + "accountCreatedPlace": "Account created — your saved places are stored on this device.", + "accountCreatedArea": "Account created — your saved areas are stored on this device.", + "prompt": { + "titlePlace": "Save this place", + "titleArea": "Save this area", + "descriptionPlace": "Log in to your existing account, or create a new one to keep your saved places.", + "descriptionArea": "Log in to your existing account, or create a new one to keep your saved areas.", + "createAccount": "Create account", + "login": "Log in", + "back": "Back", + "done": "Done" + } + }, "search": { "placeholderWorldwide": "Search worldwide...", "placeholderNearby": "Search nearby...", diff --git a/src/lib/savedItems.ts b/src/lib/savedItems.ts new file mode 100644 index 000000000..8e090899e --- /dev/null +++ b/src/lib/savedItems.ts @@ -0,0 +1,81 @@ +import api from "$lib/axios"; +import type { Session } from "$lib/session"; +import { session } from "$lib/session"; + +export type SavedItemType = "place" | "area"; + +// SvelteKit server routes that proxy to the btcmap API (avoids CORS preflight). +const PROXY_ENDPOINTS = { + place: "/api/session/saved-places", + area: "/api/session/saved-areas", +} as const; + +export async function putSavedList( + type: SavedItemType, + token: string, + ids: number[], +): Promise { + const res = await api.put(PROXY_ENDPOINTS[type], ids, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!Array.isArray(res.data)) { + throw new Error( + `PUT ${PROXY_ENDPOINTS[type]} returned an unexpected response`, + ); + } + return res.data; +} + +export function setSavedList(type: SavedItemType, ids: number[]) { + if (type === "place") { + session.setSavedPlaces(ids); + } else { + session.setSavedAreas(ids); + } +} + +export function getSavedList(s: Session | null, type: SavedItemType): number[] { + if (!s) return []; + return type === "place" ? s.savedPlaces : s.savedAreas; +} + +export function toggleSavedLocal( + type: SavedItemType, + id: number, +): number[] | null { + return type === "place" + ? session.toggleSavedPlace(id) + : session.toggleSavedArea(id); +} + +export type HydrateResult = { + place: boolean; + area: boolean; +}; + +// Fetches the server-side saved-places and saved-areas lists and populates +// the session store. Uses allSettled so one failing endpoint doesn't block +// the other; returns per-type success so callers can avoid overwriting +// server state with a stale local list when hydration failed. +export async function hydrateSavedFromServer( + token: string, +): Promise { + const headers = { Authorization: `Bearer ${token}` }; + const [placesRes, areasRes] = await Promise.allSettled([ + api.get(PROXY_ENDPOINTS.place, { headers }), + api.get(PROXY_ENDPOINTS.area, { headers }), + ]); + const placeOk = + placesRes.status === "fulfilled" && Array.isArray(placesRes.value.data); + const areaOk = + areasRes.status === "fulfilled" && Array.isArray(areasRes.value.data); + if (placeOk) { + const ids = placesRes.value.data.map((p: { id: number }) => p.id); + session.setSavedPlaces(ids); + } + if (areaOk) { + const ids = areasRes.value.data.map((a: { id: number }) => a.id); + session.setSavedAreas(ids); + } + return { place: placeOk, area: areaOk }; +} diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts index de36fad7a..031ad350d 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", autoGenerated: true, ...data }), + ); } describe("session store", () => { @@ -119,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({ @@ -136,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(); diff --git a/src/lib/session.ts b/src/lib/session.ts index 31ba7f30b..7be66fd29 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -10,18 +10,23 @@ import api from "$lib/axios"; // they lose access to their saved items. A backup flow (set password, link // Nostr) will be added later. // -// SECURITY — localStorage token blast radius: -// Storing the Bearer token in localStorage is acceptable here ONLY because -// the account is a throwaway with no recoverable data or PII. The token -// grants access to its own saved_places/saved_areas and nothing else. -// DO NOT reuse this pattern for real user accounts with durable data, -// payment info, or elevated roles — an XSS would exfiltrate the token. -// For real accounts, migrate to httpOnly cookies or similar. +// SECURITY — localStorage blast radius: +// The Bearer token is always stored in localStorage. The password is +// stored only for auto-generated accounts (so the backup modal can +// show it). Manual logins store an empty password — the user already +// knows their credentials. An XSS could exfiltrate the token (and +// the password for auto-generated accounts). This is acceptable for +// throwaway accounts with only saved_places/saved_areas data. +// DO NOT reuse this pattern for real user accounts +// with durable data, payment info, or elevated roles. For real +// accounts, migrate to httpOnly cookies or similar. export type Session = { username: string; + password: string; token: string; savedPlaces: number[]; savedAreas: number[]; + autoGenerated: boolean; }; const STORAGE_KEY = "btcmap_session"; @@ -43,6 +48,17 @@ 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 = ""; + } + // Backfill autoGenerated for old sessions — assume auto-generated + // since the login flow didn't exist when they were created. + if (typeof parsed.autoGenerated !== "boolean") { + parsed.autoGenerated = true; + } return parsed as Session; } catch { return null; @@ -83,9 +99,11 @@ function createSessionStore() { const session: Session = { username, + password, token, savedPlaces: [], savedAreas: [], + autoGenerated: true, }; saveToStorage(session); set(session); @@ -193,6 +211,21 @@ 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: [], + autoGenerated: false, + }; + 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..02a5dbac9 --- /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" || username.length > 100) { + error(400, "Missing or invalid username"); + } + if (!password || typeof password !== "string" || password.length > 200) { + error(400, "Missing or invalid 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?.status); + 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..87347098f --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,34 @@ + + + + {$_("login.title")} | BTC Map + + +
+
+

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

+ + +
+
diff --git a/src/routes/saved/+page.svelte b/src/routes/user/saved/+page.svelte similarity index 99% rename from src/routes/saved/+page.svelte rename to src/routes/user/saved/+page.svelte index 4b859e31c..d54473a5a 100644 --- a/src/routes/saved/+page.svelte +++ b/src/routes/user/saved/+page.svelte @@ -38,7 +38,7 @@ onMount(async () => { session.init(); if (!$session) { - goto("/map"); + goto("/login"); return; }