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:
@@ -3,3 +3,23 @@ export async function getJSON(url) {
|
|||||||
if (!r.ok) throw new Error((await r.text().catch(() => '')) || r.statusText);
|
if (!r.ok) throw new Error((await r.text().catch(() => '')) || r.statusText);
|
||||||
return r.json();
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<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>
|
</script>
|
||||||
|
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
@@ -20,6 +21,13 @@
|
|||||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||||
<span>History</span>
|
<span>History</span>
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -49,6 +57,15 @@
|
|||||||
.utils button svg { width: 17px; height: 17px; }
|
.utils button svg { width: 17px; height: 17px; }
|
||||||
.utils button:hover { background: var(--accent-soft); color: var(--accent-deep); }
|
.utils button:hover { background: var(--accent-soft); color: var(--accent-deep); }
|
||||||
.utils button.on { 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. */
|
/* On phones the utilities live in the bottom tab bar ("You") instead. */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
import MoodNav from '$lib/components/MoodNav.svelte';
|
import MoodNav from '$lib/components/MoodNav.svelte';
|
||||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||||
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
|
import { auth, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
|
||||||
|
|
||||||
let moods = $state([]);
|
let moods = $state([]);
|
||||||
let topics = $state([]);
|
let topics = $state([]);
|
||||||
@@ -19,6 +21,17 @@
|
|||||||
let showBoundaries = $state(false);
|
let showBoundaries = $state(false);
|
||||||
let showHistory = $state(false);
|
let showHistory = $state(false);
|
||||||
let showYou = $state(false); // mobile "You" sheet
|
let showYou = $state(false); // mobile "You" sheet
|
||||||
|
let showSignIn = $state(false);
|
||||||
|
|
||||||
|
function openAccount() {
|
||||||
|
showYou = false;
|
||||||
|
if (auth.user) showYou = true; // account lives in the You sheet
|
||||||
|
else showSignIn = true;
|
||||||
|
}
|
||||||
|
async function signOut() {
|
||||||
|
await authLogout();
|
||||||
|
showYou = false;
|
||||||
|
}
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
@@ -227,6 +240,7 @@
|
|||||||
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
||||||
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
||||||
history = P.loadJSON(HISTORY_KEY, []);
|
history = P.loadJSON(HISTORY_KEY, []);
|
||||||
|
refreshAuth(); // resolve any existing session (non-blocking)
|
||||||
try {
|
try {
|
||||||
moods = await getJSON('/api/moods');
|
moods = await getJSON('/api/moods');
|
||||||
topics = (await getJSON('/api/categories')).topics;
|
topics = (await getJSON('/api/categories')).topics;
|
||||||
@@ -242,7 +256,15 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header onBoundaries={() => (showBoundaries = !showBoundaries)} onHistory={() => (showHistory = !showHistory)} {filtersOn} />
|
<Header
|
||||||
|
onBoundaries={() => (showBoundaries = !showBoundaries)}
|
||||||
|
onHistory={() => (showHistory = !showHistory)}
|
||||||
|
onaccount={openAccount}
|
||||||
|
user={auth.user}
|
||||||
|
{filtersOn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showSignIn}<SignIn onclose={() => (showSignIn = false)} />{/if}
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{#if moods.length}
|
{#if moods.length}
|
||||||
@@ -278,12 +300,22 @@
|
|||||||
{#if showYou}
|
{#if showYou}
|
||||||
<section class="panel rise youmenu">
|
<section class="panel rise youmenu">
|
||||||
<div class="phead"><h2>You</h2><button class="close" onclick={() => (showYou = false)}>done</button></div>
|
<div class="phead"><h2>You</h2><button class="close" onclick={() => (showYou = false)}>done</button></div>
|
||||||
|
{#if auth.user}
|
||||||
|
<div class="acctline">Signed in as <strong>{auth.user.email}</strong></div>
|
||||||
|
{/if}
|
||||||
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
|
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
|
||||||
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
|
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="yourow" onclick={() => { showYou = false; showHistory = true; }}>
|
<button class="yourow" onclick={() => { showYou = false; showHistory = true; }}>
|
||||||
<span>What you've seen</span>{#if history.length}<span class="dot">{history.length}</span>{/if}
|
<span>What you've seen</span>{#if history.length}<span class="dot">{history.length}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if auth.user}
|
||||||
|
<button class="yourow" onclick={signOut}><span>Sign out</span></button>
|
||||||
|
{:else}
|
||||||
|
<button class="yourow" onclick={() => { showYou = false; showSignIn = true; }}>
|
||||||
|
<span>Sign in</span><span class="dot">save & sync</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -413,6 +445,7 @@
|
|||||||
}
|
}
|
||||||
.youmenu .yourow:last-child { border-bottom: none; }
|
.youmenu .yourow:last-child { border-bottom: none; }
|
||||||
.youmenu .dot { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 1px 9px; font-size: 0.78rem; }
|
.youmenu .dot { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 1px 9px; font-size: 0.78rem; }
|
||||||
|
.youmenu .acctline { color: var(--muted); font-size: 0.85rem; padding: 4px 2px 10px; border-bottom: 1px solid var(--line); }
|
||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
text-align: center; color: var(--accent-deep); background: var(--accent-soft);
|
text-align: center; color: var(--accent-deep); background: var(--accent-soft);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { verifyToken } from '$lib/auth.svelte.js';
|
||||||
|
|
||||||
|
let status = $state('verifying'); // verifying | error
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const token = new URLSearchParams(window.location.search).get('token');
|
||||||
|
if (!token) {
|
||||||
|
status = 'error';
|
||||||
|
error = 'This sign-in link is missing its token.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await verifyToken(token);
|
||||||
|
goto('/', { replaceState: true }); // signed in — back home
|
||||||
|
} catch (e) {
|
||||||
|
status = 'error';
|
||||||
|
error = e?.message || 'This sign-in link is invalid or has expired.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container verify">
|
||||||
|
{#if status === 'verifying'}
|
||||||
|
<p class="muted">Signing you in…</p>
|
||||||
|
{:else}
|
||||||
|
<h1>Couldn't sign you in</h1>
|
||||||
|
<p class="muted">{error}</p>
|
||||||
|
<a class="back" href="/">← Back to Upbeat Bytes</a>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.verify {
|
||||||
|
min-height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
color: var(--accent-deep);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user