Accounts Phase 4: prefs sync + account/settings panel

- Prefs sync: GET/PUT /api/prefs store Calm Filters/Boundaries on the account.
  On sign-in the client adopts the account's prefs if present, else seeds them
  from the device; every change PUTs to the account so tuning follows you across
  devices. (Login side-effects run under untrack so browsing doesn't re-trigger.)
- Account panel: GET /api/account (email, connected sign-in methods, saved count,
  active sessions); Export my data (GET /api/account/export → JSON download);
  Sign out everywhere (revoke all sessions); Delete account (cascades to all
  account data) with an inline confirm. Reachable from You → Account.

Deferred to a follow-up: link/unlink a provider (OAuth link-mode) and per-session
revoke. 118 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 14:02:38 +00:00
parent 1aa250ca67
commit bb008cfaa5
6 changed files with 345 additions and 14 deletions
+4
View File
@@ -8,6 +8,10 @@ export async function postJSON(url, body) {
return sendJSON('POST', url, body);
}
export async function putJSON(url, body) {
return sendJSON('PUT', url, body);
}
export async function delJSON(url) {
return sendJSON('DELETE', url);
}
+8 -2
View File
@@ -59,7 +59,13 @@ export async function logout() {
try {
await postJSON('/api/auth/logout', {});
} finally {
auth.user = null;
savedIds.clear();
clearLocal();
}
}
// Clear client auth state without a logout call (cookie already cleared server-side,
// e.g. after delete-account or sign-out-everywhere).
export function clearLocal() {
auth.user = null;
savedIds.clear();
}
@@ -0,0 +1,119 @@
<script>
import { onMount } from 'svelte';
import { getJSON, postJSON, delJSON } from '$lib/api.js';
import { clearLocal } from '$lib/auth.svelte.js';
let { onclose } = $props();
let info = $state(null);
let error = $state('');
let confirmingDelete = $state(false);
let busy = $state('');
const PROVIDER_LABEL = { email: 'Email link', google: 'Google', apple: 'Apple' };
onMount(async () => {
try {
info = await getJSON('/api/account');
} catch {
error = "Couldn't load your account.";
}
});
async function logoutEverywhere() {
busy = 'logout';
try {
await postJSON('/api/account/logout-all', {});
clearLocal();
onclose?.();
} catch {
error = 'Could not sign out everywhere — try again.';
} finally {
busy = '';
}
}
async function deleteAccount() {
busy = 'delete';
try {
await delJSON('/api/account');
clearLocal();
onclose?.();
} catch {
error = 'Could not delete the account — try again.';
} finally {
busy = '';
}
}
</script>
<section class="panel rise">
<div class="phead">
<h2>Account</h2>
<button class="close" onclick={onclose}>done</button>
</div>
{#if error}<p class="err">{error}</p>{/if}
{#if info}
<div class="row"><span class="k">Email</span><span class="v">{info.user.email}</span></div>
<div class="row">
<span class="k">Sign-in methods</span>
<span class="v">{info.providers.map((p) => PROVIDER_LABEL[p] ?? p).join(', ')}</span>
</div>
<div class="row"><span class="k">Saved articles</span><span class="v">{info.saved_count}</span></div>
<div class="row"><span class="k">Active sessions</span><span class="v">{info.sessions}</span></div>
<div class="actions">
<a class="btn" href="/api/account/export">Export my data</a>
<button class="btn" onclick={logoutEverywhere} disabled={busy === 'logout'}>
{busy === 'logout' ? 'Signing out…' : 'Sign out everywhere'}
</button>
</div>
<div class="danger">
{#if !confirmingDelete}
<button class="link-danger" onclick={() => (confirmingDelete = true)}>Delete my account</button>
{:else}
<p class="warn">This permanently removes your account, saved articles, and history. This can't be undone.</p>
<div class="actions">
<button class="btn danger-btn" onclick={deleteAccount} disabled={busy === 'delete'}>
{busy === 'delete' ? 'Deleting…' : 'Yes, delete everything'}
</button>
<button class="btn" onclick={() => (confirmingDelete = false)}>Cancel</button>
</div>
{/if}
</div>
<p class="fine">Browsing without an account still works — signing out keeps your saved items safe on the server for next time.</p>
{:else if !error}
<p class="muted">Loading…</p>
{/if}
</section>
<style>
.row {
display: flex; justify-content: space-between; gap: 16px;
padding: 10px 2px; border-bottom: 1px solid var(--line); font-size: 0.92rem;
}
.row .k { color: var(--muted); }
.row .v { color: var(--ink); text-align: right; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 6px; }
.btn {
font: inherit; font-size: 0.86rem; font-weight: 600;
border: 1px solid var(--line); border-radius: 999px; padding: 8px 14px;
background: var(--surface); color: var(--accent-deep); cursor: pointer; text-decoration: none;
}
.btn:hover { background: var(--accent-soft); }
.btn:disabled { opacity: 0.6; cursor: default; }
.danger { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
.link-danger {
background: none; border: none; color: #9a3b3b; font-size: 0.86rem;
text-decoration: underline; cursor: pointer; padding: 0;
}
.warn { color: #9a3b3b; font-size: 0.88rem; margin: 0 0 10px; }
.danger-btn { color: #fff; background: #9a3b3b; border-color: #9a3b3b; }
.danger-btn:hover { background: #843232; }
.err { color: #9a3b3b; font-size: 0.86rem; }
.fine { margin-top: 16px; color: var(--muted); font-size: 0.8rem; }
.muted { color: var(--muted); }
</style>
+40 -12
View File
@@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { getJSON, postJSON, delJSON } from '$lib/api.js';
import { onMount, untrack } from 'svelte';
import { getJSON, postJSON, putJSON, delJSON } from '$lib/api.js';
import * as P from '$lib/prefs.js';
import Header from '$lib/components/Header.svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
@@ -8,6 +8,7 @@
import ArticleCard from '$lib/components/ArticleCard.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import AccountPanel from '$lib/components/AccountPanel.svelte';
import { auth, savedIds, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
let moods = $state([]);
@@ -22,6 +23,7 @@
let showHistory = $state(false);
let showYou = $state(false); // mobile "You" sheet
let showSignIn = $state(false);
let showAccount = $state(false);
function openAccount() {
showYou = false;
@@ -38,19 +40,39 @@
loadServerHistory();
}
// On first sign-in (per account, per device), fold this device's anonymous
// history + saved into the account so nothing's lost.
// React only to sign-in (auth.user); untrack so reading history/prefs inside
// doesn't make this re-run on every browse.
$effect(() => {
const u = auth.user;
if (!u || typeof window === 'undefined') return;
const key = 'goodnews:imported:' + u.id;
if (localStorage.getItem(key)) return;
// Fold in this device's MEANINGFUL history (opened/replaced), not everything shown.
const seen = history.map((a) => a.id);
postJSON('/api/import', { seen, saved: [] })
.then(() => { localStorage.setItem(key, '1'); loadServerHistory(); })
.catch(() => {});
if (u && typeof window !== 'undefined') untrack(() => onLogin(u));
});
async function onLogin(u) {
// One-time per account/device: fold this device's MEANINGFUL history
// (opened/replaced — not everything shown) into the account.
const key = 'goodnews:imported:' + u.id;
if (!localStorage.getItem(key)) {
try {
await postJSON('/api/import', { seen: history.map((a) => a.id), saved: [] });
localStorage.setItem(key, '1');
} catch { /* best-effort */ }
}
loadServerHistory();
// Prefs: adopt the account's saved prefs if any; otherwise seed from this device.
try {
const res = await getJSON('/api/prefs');
if (res && res.prefs) {
const incoming = Object.assign(P.blank(), res.prefs);
if (JSON.stringify(incoming) !== JSON.stringify(userPrefs)) {
userPrefs = incoming;
P.save(userPrefs);
select(selected, true);
}
} else {
await putJSON('/api/prefs', { prefs: userPrefs });
}
} catch { /* best-effort */ }
}
let loading = $state(true);
let error = $state('');
@@ -223,6 +245,7 @@
function refreshPrefs() {
userPrefs = { ...userPrefs };
P.save(userPrefs);
if (auth.user) putJSON('/api/prefs', { prefs: userPrefs }).catch(() => {}); // sync to the account
select(selected, true);
}
function applyAction(kind, value) {
@@ -369,6 +392,7 @@
<span>History</span>{#if historyItems.length}<span class="dot">{historyItems.length}</span>{/if}
</button>
{#if auth.user}
<button class="yourow" onclick={() => { showYou = false; showAccount = true; }}><span>Account</span></button>
<button class="yourow" onclick={signOut}><span>Sign out</span></button>
{:else}
<button class="yourow" onclick={() => { showYou = false; showSignIn = true; }}>
@@ -378,6 +402,10 @@
</section>
{/if}
{#if showAccount}
<AccountPanel onclose={() => (showAccount = false)} />
{/if}
{#if notice}<p class="notice rise">{notice}</p>{/if}
{#if loading}