Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
936a519
feat(session): store password for account backup
escapedcat Apr 11, 2026
16821d0
feat(nav): add UserMenu dropdown replacing bookmark icon
escapedcat Apr 11, 2026
c069b07
fix: add password backfill test and aria-haspopup on UserMenu
escapedcat Apr 11, 2026
201da3d
feat(nav): show UserMenu always with login/logout states
escapedcat Apr 11, 2026
c15c603
refactor(nav): remove logout, users always have an account
escapedcat Apr 11, 2026
75f4ae3
feat(auth): add login page and server route
escapedcat Apr 11, 2026
8045aeb
feat(nav): add "Switch account" to logged-in user menu
escapedcat Apr 11, 2026
6a6f945
feat(nav): show username as tooltip on account icon
escapedcat Apr 11, 2026
b1df7bc
feat(save): show toast on first account creation
escapedcat Apr 11, 2026
bb11dc6
fix(nav): match user icon button size to theme toggle
escapedcat Apr 11, 2026
e62dfd8
fix(session): hydrate session immediately instead of in onMount
escapedcat Apr 11, 2026
f78a7df
Revert "fix(session): hydrate session immediately instead of in onMount"
escapedcat Apr 11, 2026
e4a5328
fix: confirm before switch account, remove dead logout key
escapedcat Apr 11, 2026
66986a6
feat(auth): add BackupModal and remove switch account confirm
escapedcat Apr 11, 2026
d6497b7
feat(auth): only show backup for auto-generated accounts
escapedcat Apr 11, 2026
3c4d8dd
fix(auth): don't store password in localStorage for known accounts
escapedcat Apr 11, 2026
0860faf
fix(saved): show empty state instead of redirecting to /map
escapedcat Apr 11, 2026
64364bf
fix(saved): redirect to /login instead of showing empty state
escapedcat Apr 11, 2026
766371c
fix(auth): add input length validation on login server route
escapedcat Apr 11, 2026
b81fc87
feat(login): add link to developer portal for account creation
escapedcat Apr 11, 2026
de4aa68
fix(backup): add Escape key handling, remove deprecated copy fallback
escapedcat Apr 11, 2026
9beafbc
fix(nav): replace "Switch account" with "Log out"
escapedcat Apr 11, 2026
a9abb2c
fix(nav): use unique IDs for UserMenu trigger buttons
escapedcat Apr 11, 2026
e69ea5a
fix(backup): handle clipboard errors and use i18n for copy toast
escapedcat Apr 11, 2026
1d6d4b2
docs(session): clarify password storage differs by account type
escapedcat Apr 11, 2026
a20c35e
fix(auth): don't trim passwords, sanitize error logs
escapedcat Apr 11, 2026
fcd4e93
fix(nav): add type=button to UserMenu action buttons
escapedcat Apr 11, 2026
074f7e0
fix(backup): replace a11y ignores with role=presentation, add type=bu…
escapedcat Apr 11, 2026
1d357ca
fix(nav): use deterministic IDs for UserMenu instances
escapedcat Apr 11, 2026
5b57045
refactor(routes): move /saved to /user/saved
escapedcat Apr 11, 2026
1334b2d
style(icon): use bookmark with checkmark for saved state
escapedcat Apr 12, 2026
c203730
feat(account): flow auth gate (#912)
escapedcat Apr 13, 2026
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
3 changes: 2 additions & 1 deletion src/components/Icon.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const materialExceptions: Record<string, string> = {
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"];
Expand Down
87 changes: 25 additions & 62 deletions src/components/SaveButton.svelte
Original file line number Diff line number Diff line change
@@ -1,95 +1,56 @@
<script lang="ts">
import SaveAuthPrompt from "$components/auth/SaveAuthPrompt.svelte";
import Icon from "$components/Icon.svelte";
import api from "$lib/axios";
import { _ } from "$lib/i18n";
import type { SavedItemType } from "$lib/savedItems";
import {
getSavedList,
putSavedList,
setSavedList,
toggleSavedLocal,
} from "$lib/savedItems";
import { session } from "$lib/session";
import { errToast } from "$lib/utils";

// The numeric ID of the item to save/unsave.
export let id: number;

// Whether this saves a place or an area.
export let type: "place" | "area" = "place";
export let type: SavedItemType = "place";

// Optional className passthrough for layout-specific styling.
let className: string | undefined = undefined;

export { className as class };

let pending = false;
let showPrompt = false;

$: savedList =
type === "place"
? ($session?.savedPlaces ?? [])
: ($session?.savedAreas ?? []);
$: savedList = getSavedList($session, type);
$: saved = savedList.includes(id);

// 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;

async function putSaved(token: string, ids: number[]): Promise<number[]> {
const res = await api.put<number[]>(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;
}

async function toggle() {
if (pending) return;
pending = true;

const previousSession = $session;
const previousSaved =
type === "place"
? (previousSession?.savedPlaces ?? [])
: (previousSession?.savedAreas ?? []);
// No session — open the auth prompt instead of silently creating an account.
// The modal handles signup/login and performs the save itself.
if (!$session) {
showPrompt = true;
return;
}

pending = true;
const previousSaved = [...savedList];
try {
let current = previousSession;
if (!current) {
current = await session.signUp();
}

const nextSaved =
type === "place"
? session.toggleSavedPlace(id)
: session.toggleSavedArea(id);
const nextSaved = toggleSavedLocal(type, id);
if (!nextSaved) throw new Error("toggle returned null (no session)");

// Write the server's canonical list back to the store so the client
// stays in sync even if the server deduplicates or rejects IDs.
const serverList = await putSaved(current.token, nextSaved);
if (type === "place") {
session.setSavedPlaces(serverList);
} else {
session.setSavedAreas(serverList);
}
const serverList = await putSavedList(type, $session.token, nextSaved);
setSavedList(type, serverList);
} catch (err) {
if (previousSession) {
if (type === "place") {
session.setSavedPlaces(previousSaved);
} else {
session.setSavedAreas(previousSaved);
}
} else {
// signUp() succeeded but the PUT failed. Don't clear the session —
// the account and token are valid. Just rollback the saved list to
// empty so the next click retries with the same account instead of
// creating another orphan.
if (type === "place") {
session.setSavedPlaces([]);
} else {
session.setSavedAreas([]);
}
}
setSavedList(type, previousSaved);
errToast($_(`merchant.saveFailed`));
console.error("SaveButton.toggle failed", err);
} finally {
Expand Down Expand Up @@ -130,3 +91,5 @@ async function toggle() {
>
</span>
</button>

<SaveAuthPrompt bind:open={showPrompt} {id} {type} />
106 changes: 106 additions & 0 deletions src/components/auth/BackupCredentials.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<script lang="ts">
import Icon from "$components/Icon.svelte";
import { _ } from "$lib/i18n";
import { errToast, successToast } from "$lib/utils";

export let username: string;
export let password: string | null;
// idPrefix avoids element-id collisions if two instances ever mount at once.
export let idPrefix = "backup";

let showPassword = false;

async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
successToast($_("backup.copied"));
} catch (err) {
errToast($_("backup.copyFailed"));
console.error("Clipboard write failed", err);
}
}
</script>

<div class="space-y-3">
<div>
<label
for="{idPrefix}-username"
class="mb-1 block text-xs font-semibold text-body dark:text-white/70"
>
{$_("backup.username")}
</label>
<div class="flex items-center gap-2">
<input
id="{idPrefix}-username"
type="text"
readonly
value={username}
class="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-primary dark:border-white/20 dark:bg-white/5 dark:text-white"
/>
<button
type="button"
on:click={() => copyToClipboard(username)}
class="shrink-0 rounded-lg border border-gray-300 p-2 transition-colors hover:bg-gray-100 dark:border-white/20 dark:hover:bg-white/10"
title={$_("backup.copy")}
>
Comment on lines +40 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add accessible names to icon-only action buttons.

On Line 40, Line 71, and Line 86 button controls are icon-only and currently rely on title. Screen readers may not consistently announce title as the button name. Add aria-label (and aria-pressed for the visibility toggle) to make these controls reliably discoverable.

Suggested patch
 			<button
 				type="button"
 				on:click={() => copyToClipboard(username)}
 				class="shrink-0 rounded-lg border border-gray-300 p-2 transition-colors hover:bg-gray-100 dark:border-white/20 dark:hover:bg-white/10"
 				title={$_("backup.copy")}
+				aria-label={$_("backup.copy")}
 			>
@@
 			<button
 				type="button"
 				on:click={() => (showPassword = !showPassword)}
 				class="shrink-0 rounded-lg border border-gray-300 p-2 transition-colors hover:bg-gray-100 dark:border-white/20 dark:hover:bg-white/10"
 				title={showPassword ? $_("backup.hide") : $_("backup.show")}
+				aria-label={showPassword ? $_("backup.hide") : $_("backup.show")}
+				aria-pressed={showPassword}
 			>
@@
 				<button
 					type="button"
 					on:click={() => copyToClipboard(password ?? "")}
 					class="shrink-0 rounded-lg border border-gray-300 p-2 transition-colors hover:bg-gray-100 dark:border-white/20 dark:hover:bg-white/10"
 					title={$_("backup.copy")}
+					aria-label={$_("backup.copy")}
 				>

Also applies to: 71-76, 86-91

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/auth/BackupCredentials.svelte` around lines 40 - 45, These
icon-only buttons (e.g., the copy button with on:click={() =>
copyToClipboard(username)} and the other icon-only action buttons in
BackupCredentials.svelte) rely only on title text which isn't reliably announced
by screen readers; add aria-label attributes to each icon-only <button> using
the same localized strings used for title (e.g., $_("backup.copy")), and for the
visibility toggle button also add aria-pressed bound to the visibility state
(e.g., aria-pressed={showPassword}) so assistive tech can discover and convey
the control state.

<Icon
type="material"
icon="content_copy"
w="16"
h="16"
class="text-primary dark:text-white"
/>
</button>
</div>
</div>
<div>
<label
for="{idPrefix}-password"
class="mb-1 block text-xs font-semibold text-body dark:text-white/70"
>
{$_("backup.password")}
</label>
<div class="flex items-center gap-2">
<input
id="{idPrefix}-password"
type={showPassword ? "text" : "password"}
readonly
value={password || $_("backup.unavailable")}
class="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-primary dark:border-white/20 dark:bg-white/5 dark:text-white"
/>
<button
type="button"
on:click={() => (showPassword = !showPassword)}
class="shrink-0 rounded-lg border border-gray-300 p-2 transition-colors hover:bg-gray-100 dark:border-white/20 dark:hover:bg-white/10"
title={showPassword ? $_("backup.hide") : $_("backup.show")}
>
<Icon
type="material"
icon={showPassword ? "visibility_off" : "visibility"}
w="16"
h="16"
class="text-primary dark:text-white"
/>
</button>
{#if password}
<button
type="button"
on:click={() => copyToClipboard(password ?? "")}
class="shrink-0 rounded-lg border border-gray-300 p-2 transition-colors hover:bg-gray-100 dark:border-white/20 dark:hover:bg-white/10"
title={$_("backup.copy")}
>
<Icon
type="material"
icon="content_copy"
w="16"
h="16"
class="text-primary dark:text-white"
/>
</button>
{/if}
</div>
</div>
</div>
<p class="mt-4 text-xs text-body dark:text-white/50">
{$_("backup.warning")}
</p>
21 changes: 21 additions & 0 deletions src/components/auth/BackupModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
import BackupCredentials from "$components/auth/BackupCredentials.svelte";
import Modal from "$components/Modal.svelte";
import { _ } from "$lib/i18n";
import { session } from "$lib/session";

export let open = false;
</script>

<Modal bind:open title={$_("backup.title")} titleId="backup-modal-title">
<p class="mb-4 text-sm text-body dark:text-white/70">
{$_("backup.description")}
</p>

{#if $session}
<BackupCredentials
username={$session.username}
password={$session.password}
/>
{/if}
</Modal>
114 changes: 114 additions & 0 deletions src/components/auth/LoginForm.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<script lang="ts">
import { get } from "svelte/store";

import api from "$lib/axios";
import { _ } from "$lib/i18n";
import type { Session } from "$lib/session";
import { session } from "$lib/session";
import { errToast } from "$lib/utils";

// Caller receives the new session after a successful login. This keeps the
// form reusable: /login navigates, the save-flow modal completes a pending
// save, both without baking navigation/save logic into the form.
export let onSuccess: (session: Session) => void | Promise<void>;

// When true, render without the "Don't have an account?" link (e.g. inside
// a modal that already frames the login choice).
export let compact = false;

let username = "";
let password = "";
let loading = false;

async function handleSubmit() {
if (!username.trim() || !password) return;
loading = true;

try {
const res = await api.post("/api/session/login", {
username: username.trim(),
password,
});

const token = res.data?.token;
if (typeof token !== "string") {
throw new Error("Login did not return a token");
}

// Don't store the password — the user already knows their own credentials.
session.login(username.trim(), "", token);

// Pull the new session value so callers get a concrete Session object
// instead of having to subscribe.
const current = get(session);
if (!current) throw new Error("session.login did not populate the store");

await onSuccess(current);
} catch (err) {
const status = (err as { response?: { status?: number } })?.response
?.status;
errToast(status === 401 ? $_("login.failed") : $_("login.error"));
console.error("Login failed:", status ?? "unknown");
} finally {
loading = false;
}
}
</script>

<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label
for="login-username"
class="mb-1 block text-sm font-semibold text-primary dark:text-white"
>
{$_("login.username")}
</label>
<input
id="login-username"
type="text"
bind:value={username}
autocomplete="username"
maxlength="100"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-primary dark:border-white/20 dark:bg-dark dark:text-white"
/>
</div>

<div>
<label
for="login-password"
class="mb-1 block text-sm font-semibold text-primary dark:text-white"
>
{$_("login.password")}
</label>
<input
id="login-password"
type="password"
bind:value={password}
autocomplete="current-password"
maxlength="200"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-primary dark:border-white/20 dark:bg-dark dark:text-white"
/>
</div>

<button
type="submit"
disabled={loading || !username.trim() || !password}
class="w-full rounded-lg bg-link px-4 py-2 font-semibold text-white transition-colors hover:bg-hover disabled:opacity-50"
>
{loading ? $_("login.loggingIn") : $_("login.submit")}
</button>
</form>

{#if !compact}
<p class="mt-4 text-center text-sm text-body dark:text-white/70">
{$_("login.noAccount")}
<a
href="https://developer.btcmap.org"
target="_blank"
rel="noopener noreferrer"
class="text-link transition-colors hover:text-hover"
>
{$_("login.createAccount")}
</a>
</p>
{/if}
Loading
Loading