Accounts Phase 1c: sign-in UI, magic-link landing, auth store

- Shared reactive auth store (auth.user) + postJSON helper (sends the cookie).
- SignIn modal: email -> "check your inbox" (calm, no password); Google slots in
  here in Phase 2.
- /auth/verify route exchanges the magic-link token for a session, then home.
- Header shows "Sign in" or an account avatar; the You sheet gains "Signed in as
  …" + Sign out (or a Sign in row). Anonymous browsing is unchanged.
This commit is contained in:
jay
2026-06-03 01:19:30 +00:00
parent d2ae56dc65
commit 9237180608
6 changed files with 310 additions and 2 deletions
+20
View File
@@ -3,3 +3,23 @@ export async function getJSON(url) {
if (!r.ok) throw new Error((await r.text().catch(() => '')) || r.statusText);
return r.json();
}
export async function postJSON(url, body) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
credentials: 'same-origin', // send/receive the session cookie
});
if (!r.ok) {
let msg = r.statusText;
try {
const j = await r.json();
if (j && j.detail) msg = j.detail;
} catch {
/* non-JSON error body */
}
throw new Error(msg);
}
return r.json();
}
+35
View File
@@ -0,0 +1,35 @@
// Shared, reactive auth state (Svelte 5 runes in a module). Components read
// `auth.user` and it stays reactive across the app.
import { getJSON, postJSON } from './api.js';
export const auth = $state({ user: null, ready: false });
export async function refresh() {
try {
auth.user = await getJSON('/api/auth/me');
} catch {
auth.user = null;
} finally {
auth.ready = true;
}
}
// Request a magic link. Reply is intentionally identical for any address.
export function startEmail(email) {
return postJSON('/api/auth/email/start', { email });
}
// Exchange a magic-link token for a session (cookie set server-side).
export async function verifyToken(token) {
const res = await postJSON('/api/auth/email/verify', { token });
auth.user = res.user;
return res;
}
export async function logout() {
try {
await postJSON('/api/auth/logout', {});
} finally {
auth.user = null;
}
}
+18 -1
View File
@@ -1,5 +1,6 @@
<script>
let { onBoundaries, onHistory, filtersOn = false } = $props();
let { onBoundaries, onHistory, onaccount, user = null, filtersOn = false } = $props();
let initial = $derived((user?.display_name || user?.email || '?').trim()[0].toUpperCase());
</script>
<header class="appbar">
@@ -20,6 +21,13 @@
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<span>History</span>
</button>
{#if user}
<button class="acct" onclick={onaccount} title={user.email} aria-label="Your account">
<span class="avatar">{initial}</span>
</button>
{:else}
<button class="signin" onclick={onaccount}>Sign in</button>
{/if}
</nav>
</div>
</header>
@@ -49,6 +57,15 @@
.utils button svg { width: 17px; height: 17px; }
.utils button:hover { background: var(--accent-soft); color: var(--accent-deep); }
.utils button.on { color: var(--accent-deep); }
.utils .signin { border-color: var(--line); color: var(--accent-deep); }
.utils .signin:hover { background: var(--accent-soft); }
.acct { padding: 4px; }
.avatar {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 999px;
background: var(--accent); color: #fff; font-weight: 600; font-size: 0.8rem;
font-family: var(--label);
}
/* On phones the utilities live in the bottom tab bar ("You") instead. */
@media (max-width: 720px) {
+149
View File
@@ -0,0 +1,149 @@
<script>
import { startEmail } from '$lib/auth.svelte.js';
let { onclose } = $props();
let email = $state('');
let status = $state('idle'); // idle | sending | sent | error
let error = $state('');
async function submit(e) {
e.preventDefault();
if (!email || status === 'sending') return;
status = 'sending';
error = '';
try {
await startEmail(email);
status = 'sent';
} catch (err) {
status = 'error';
error = err?.message || 'Something went wrong — try again in a moment.';
}
}
</script>
<div class="overlay" onclick={onclose} role="presentation">
<div class="sheet rise" role="dialog" aria-modal="true" aria-label="Sign in" onclick={(e) => e.stopPropagation()}>
<button class="x" onclick={onclose} aria-label="Close">×</button>
{#if status === 'sent'}
<h2>Check your inbox</h2>
<p class="sub">
We've sent a one-time sign-in link to <strong>{email}</strong>. It works once and
expires in 15 minutes.
</p>
<button class="primary" onclick={onclose}>Done</button>
{:else}
<h2>Sign in to Upbeat Bytes</h2>
<p class="sub">
Save articles and keep your history across devices. We'll email you a one-time
link — no password to remember.
</p>
<form onsubmit={submit}>
<input
type="email"
bind:value={email}
placeholder="you@example.com"
autocomplete="email"
required
/>
<button class="primary" type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Email me a link'}
</button>
</form>
{#if status === 'error'}<p class="err">{error}</p>{/if}
<!-- Phase 2: "Continue with Google" button slots in here -->
<p class="fine">No account needed to browse — signing in just saves your stuff.</p>
{/if}
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(10, 22, 38, 0.32);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 40;
}
.sheet {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
width: 100%;
max-width: 400px;
padding: 28px 26px 22px;
position: relative;
}
.x {
position: absolute;
top: 12px;
right: 14px;
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: var(--muted);
cursor: pointer;
}
h2 {
font-size: 1.5rem;
margin: 0 0 8px;
}
.sub {
margin: 0 0 18px;
color: var(--muted);
font-size: 0.95rem;
}
form {
display: flex;
flex-direction: column;
gap: 10px;
}
input {
font: inherit;
padding: 11px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--bg);
color: var(--ink);
}
input:focus {
outline: none;
border-color: var(--accent);
}
.primary {
font: inherit;
font-weight: 600;
background: var(--accent);
color: #fff;
border: none;
border-radius: 999px;
padding: 11px 16px;
cursor: pointer;
transition: background 0.14s ease;
}
.primary:hover {
background: var(--accent-deep);
}
.primary:disabled {
opacity: 0.6;
cursor: default;
}
.err {
margin: 10px 0 0;
color: #9a3b3b;
font-size: 0.86rem;
}
.fine {
margin: 16px 0 0;
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
</style>