User avatar (Google picture), avatar in mobile You tab, /account page
- Capture the Google profile picture (picture claim) into users.avatar_url; an Avatar component shows it, falling back to the initial. Used in the desktop header and the mobile "You" tab (which now shows the user when signed in). - Move account/settings to its own route /account (robust + scrolls to top), reached by the desktop avatar and the mobile You tab; drop the inline "You" sheet. AccountPanel gains a Sign out action; the page links to Saved/History/ Boundaries via home intent params (?view= / ?open=). - db: users.avatar_url (schema + idempotent migration). 118 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
||||||
import { clearLocal } from '$lib/auth.svelte.js';
|
import { clearLocal, logout as authLogout } from '$lib/auth.svelte.js';
|
||||||
|
|
||||||
let { onclose } = $props();
|
let { onclose } = $props();
|
||||||
let info = $state(null);
|
let info = $state(null);
|
||||||
@@ -19,6 +19,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await authLogout();
|
||||||
|
onclose?.();
|
||||||
|
}
|
||||||
|
|
||||||
async function logoutEverywhere() {
|
async function logoutEverywhere() {
|
||||||
busy = 'logout';
|
busy = 'logout';
|
||||||
try {
|
try {
|
||||||
@@ -64,6 +69,7 @@
|
|||||||
<div class="row"><span class="k">Active sessions</span><span class="v">{info.sessions}</span></div>
|
<div class="row"><span class="k">Active sessions</span><span class="v">{info.sessions}</span></div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<button class="btn" onclick={signOut}>Sign out</button>
|
||||||
<a class="btn" href="/api/account/export">Export my data</a>
|
<a class="btn" href="/api/account/export">Export my data</a>
|
||||||
<button class="btn" onclick={logoutEverywhere} disabled={busy === 'logout'}>
|
<button class="btn" onclick={logoutEverywhere} disabled={busy === 'logout'}>
|
||||||
{busy === 'logout' ? 'Signing out…' : 'Sign out everywhere'}
|
{busy === 'logout' ? 'Signing out…' : 'Sign out everywhere'}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script>
|
||||||
|
let { user, size = 30 } = $props();
|
||||||
|
let initial = $derived(((user?.display_name || user?.email || '?').trim()[0] || '?').toUpperCase());
|
||||||
|
let failed = $state(false);
|
||||||
|
// Reset if the picture URL changes (or a different user signs in).
|
||||||
|
$effect(() => {
|
||||||
|
void user?.avatar_url;
|
||||||
|
failed = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if user?.avatar_url && !failed}
|
||||||
|
<img
|
||||||
|
class="av"
|
||||||
|
src={user.avatar_url}
|
||||||
|
alt=""
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
style="width:{size}px;height:{size}px"
|
||||||
|
onerror={() => (failed = true)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="av init" style="width:{size}px;height:{size}px;font-size:{size * 0.42}px">{initial}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.av {
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.init {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--label);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
// Mobile-only primary navigation. Today = the brief, Browse = mood/topic
|
// Mobile-only primary navigation. Today = the brief, Browse = mood/topic
|
||||||
// discovery, You = personal controls (Boundaries, History).
|
// discovery, You = account + personal controls (shows the user's avatar in).
|
||||||
let { active = 'today', onToday, onBrowse, onYou } = $props();
|
import Avatar from './Avatar.svelte';
|
||||||
|
let { active = 'today', onToday, onBrowse, onYou, user = null } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="bottomnav" aria-label="Primary">
|
<nav class="bottomnav" aria-label="Primary">
|
||||||
@@ -14,7 +15,11 @@
|
|||||||
<span>Browse</span>
|
<span>Browse</span>
|
||||||
</button>
|
</button>
|
||||||
<button class:active={active === 'you'} onclick={onYou}>
|
<button class:active={active === 'you'} onclick={onYou}>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.5" r="3.6" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M5 20c0-3.6 3.1-5.5 7-5.5s7 1.9 7 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
{#if user}
|
||||||
|
<Avatar {user} size={23} />
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.5" r="3.6" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M5 20c0-3.6 3.1-5.5 7-5.5s7 1.9 7 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||||
|
{/if}
|
||||||
<span>You</span>
|
<span>You</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
let { onBoundaries, onHistory, onaccount, user = null, 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">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if user}
|
{#if user}
|
||||||
<button class="acct" onclick={onaccount} title={user.email} aria-label="Your account">
|
<button class="acct" onclick={onaccount} title={user.email} aria-label="Your account">
|
||||||
<span class="avatar">{initial}</span>
|
<Avatar {user} size={30} />
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="signin" onclick={onaccount}>Sign in</button>
|
<button class="signin" onclick={onaccount}>Sign in</button>
|
||||||
@@ -59,13 +59,7 @@
|
|||||||
.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 { border-color: var(--line); color: var(--accent-deep); }
|
||||||
.utils .signin:hover { background: var(--accent-soft); }
|
.utils .signin:hover { background: var(--accent-soft); }
|
||||||
.acct { padding: 4px; }
|
.acct { padding: 4px; display: inline-flex; }
|
||||||
.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) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { getJSON, postJSON, putJSON, delJSON } from '$lib/api.js';
|
import { getJSON, postJSON, putJSON, delJSON } from '$lib/api.js';
|
||||||
import * as P from '$lib/prefs.js';
|
import * as P from '$lib/prefs.js';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
@@ -8,8 +9,7 @@
|
|||||||
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 SignIn from '$lib/components/SignIn.svelte';
|
||||||
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
import { auth, savedIds, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||||
import { auth, savedIds, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
|
|
||||||
|
|
||||||
let moods = $state([]);
|
let moods = $state([]);
|
||||||
let topics = $state([]);
|
let topics = $state([]);
|
||||||
@@ -21,21 +21,14 @@
|
|||||||
let userPrefs = $state(P.blank());
|
let userPrefs = $state(P.blank());
|
||||||
let showBoundaries = $state(false);
|
let showBoundaries = $state(false);
|
||||||
let showHistory = $state(false);
|
let showHistory = $state(false);
|
||||||
let showYou = $state(false); // mobile "You" sheet
|
|
||||||
let showSignIn = $state(false);
|
let showSignIn = $state(false);
|
||||||
let showAccount = $state(false);
|
|
||||||
|
|
||||||
|
// Account/settings is its own page (/account) now — robust + scrolls to top.
|
||||||
function openAccount() {
|
function openAccount() {
|
||||||
showYou = false;
|
if (auth.user) goto('/account');
|
||||||
if (auth.user) showYou = true; // account lives in the You sheet
|
|
||||||
else showSignIn = true;
|
else showSignIn = true;
|
||||||
}
|
}
|
||||||
async function signOut() {
|
|
||||||
await authLogout();
|
|
||||||
showYou = false;
|
|
||||||
}
|
|
||||||
function openHistory() {
|
function openHistory() {
|
||||||
showYou = false;
|
|
||||||
showHistory = true;
|
showHistory = true;
|
||||||
loadServerHistory();
|
loadServerHistory();
|
||||||
}
|
}
|
||||||
@@ -164,7 +157,7 @@
|
|||||||
? (tagFamily?.description ?? '')
|
? (tagFamily?.description ?? '')
|
||||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||||
);
|
);
|
||||||
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
|
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
|
||||||
|
|
||||||
// The hero is the only image slot. Some sources hotlink-protect their images
|
// The hero is the only image slot. Some sources hotlink-protect their images
|
||||||
// (e.g. Guardian → 401), so if the lead's image won't load, promote the next
|
// (e.g. Guardian → 401), so if the lead's image won't load, promote the next
|
||||||
@@ -216,7 +209,6 @@
|
|||||||
|
|
||||||
async function select(key, fresh = false) {
|
async function select(key, fresh = false) {
|
||||||
selected = key;
|
selected = key;
|
||||||
showYou = false;
|
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
if (key === 'today') {
|
if (key === 'today') {
|
||||||
@@ -299,7 +291,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function browse() {
|
function browse() {
|
||||||
showYou = false;
|
|
||||||
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
|
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
if (selected !== 'today') select('today').then(go);
|
if (selected !== 'today') select('today').then(go);
|
||||||
else go();
|
else go();
|
||||||
@@ -318,7 +309,14 @@
|
|||||||
// isn't, the Explore-by-family section simply stays hidden and cards fall
|
// isn't, the Explore-by-family section simply stays hidden and cards fall
|
||||||
// back to the topic pill — the rest of the page works unchanged.
|
// back to the topic pill — the rest of the page works unchanged.
|
||||||
try { families = await getJSON('/api/families'); } catch { families = []; }
|
try { families = await getJSON('/api/families'); } catch { families = []; }
|
||||||
await select('today');
|
// Intent from the /account quick links (Saved / History / Boundaries).
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const view = params.get('view');
|
||||||
|
const open = params.get('open');
|
||||||
|
await select(view === 'saved' ? 'saved' : 'today');
|
||||||
|
if (open === 'history') openHistory();
|
||||||
|
if (open === 'boundaries') showBoundaries = true;
|
||||||
|
if (view || open) window.history.replaceState({}, '', '/'); // tidy the URL
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Could not reach Upbeat Bytes.';
|
error = 'Could not reach Upbeat Bytes.';
|
||||||
}
|
}
|
||||||
@@ -376,36 +374,6 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showYou}
|
|
||||||
<section class="panel rise youmenu">
|
|
||||||
<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>
|
|
||||||
<button class="yourow" onclick={() => { showYou = false; select('saved'); }}>
|
|
||||||
<span>Saved</span>{#if savedIds.size}<span class="dot">{savedIds.size}</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
|
|
||||||
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
|
|
||||||
</button>
|
|
||||||
<button class="yourow" onclick={openHistory}>
|
|
||||||
<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; }}>
|
|
||||||
<span>Sign in</span><span class="dot">save & sync</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showAccount}
|
|
||||||
<AccountPanel onclose={() => (showAccount = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -478,7 +446,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={() => (showYou = !showYou)} />
|
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={openAccount} user={auth.user} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
||||||
@@ -531,15 +499,6 @@
|
|||||||
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; margin-top: 12px; }
|
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; margin-top: 12px; }
|
||||||
.reset:hover { color: var(--accent-deep); }
|
.reset:hover { color: var(--accent-deep); }
|
||||||
|
|
||||||
.youmenu .yourow {
|
|
||||||
width: 100%; display: flex; align-items: center; justify-content: space-between;
|
|
||||||
background: none; border: none; border-bottom: 1px solid var(--line);
|
|
||||||
padding: 14px 2px; font-size: 1rem; color: var(--ink); cursor: pointer; text-align: left;
|
|
||||||
}
|
|
||||||
.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 .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);
|
||||||
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content; font-size: 0.86rem;
|
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content; font-size: 0.86rem;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
||||||
|
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!auth.ready) refresh();
|
||||||
|
});
|
||||||
|
// Once auth resolves, send anonymous visitors back home.
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.ready && !auth.user) goto('/', { replaceState: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="bar">
|
||||||
|
<div class="container inner">
|
||||||
|
<a class="brand" href="/" aria-label="Upbeat Bytes — home">
|
||||||
|
<img class="logo" src="/logo.svg" alt="Upbeat Bytes" />
|
||||||
|
</a>
|
||||||
|
<a class="back" href="/">← Back</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container page">
|
||||||
|
{#if auth.user}
|
||||||
|
<h1>You</h1>
|
||||||
|
<nav class="quick">
|
||||||
|
<a href="/?view=saved">Saved</a>
|
||||||
|
<a href="/?open=history">History</a>
|
||||||
|
<a href="/?open=boundaries">Boundaries</a>
|
||||||
|
</nav>
|
||||||
|
<AccountPanel onclose={() => goto('/')} />
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||||||
|
.logo { height: 40px; width: auto; display: block; }
|
||||||
|
.back { color: var(--accent-deep); font-size: 0.9rem; }
|
||||||
|
.page { padding: 22px 20px 60px; }
|
||||||
|
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 8px 0 14px; }
|
||||||
|
.quick { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||||||
|
.quick a {
|
||||||
|
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||||||
|
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.quick a:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||||
|
</style>
|
||||||
+14
-8
@@ -104,6 +104,15 @@ def _require_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _user_out(user: sqlite3.Row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": user["id"],
|
||||||
|
"email": user["email"],
|
||||||
|
"display_name": user["display_name"],
|
||||||
|
"avatar_url": user["avatar_url"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _send_link_safe(email: str, link: str) -> None:
|
def _send_link_safe(email: str, link: str) -> None:
|
||||||
"""Send the magic link, swallowing failures (runs off the request path)."""
|
"""Send the magic link, swallowing failures (runs off the request path)."""
|
||||||
try:
|
try:
|
||||||
@@ -284,6 +293,7 @@ class UserOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
email: str
|
email: str
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SessionOut(BaseModel):
|
class SessionOut(BaseModel):
|
||||||
@@ -370,18 +380,13 @@ def create_app() -> FastAPI:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
user = auth.get_user(conn, user_id)
|
user = auth.get_user(conn, user_id)
|
||||||
_set_session_cookie(response, token)
|
_set_session_cookie(response, token)
|
||||||
return SessionOut(
|
return SessionOut(user=UserOut(**_user_out(user)), token=token)
|
||||||
user=UserOut(id=user["id"], email=user["email"], display_name=user["display_name"]),
|
|
||||||
token=token,
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/api/auth/me", response_model=UserOut | None)
|
@app.get("/api/auth/me", response_model=UserOut | None)
|
||||||
def auth_me(request: Request) -> UserOut | None:
|
def auth_me(request: Request) -> UserOut | None:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
user = _current_user(conn, request)
|
user = _current_user(conn, request)
|
||||||
if not user:
|
return UserOut(**_user_out(user)) if user else None
|
||||||
return None
|
|
||||||
return UserOut(id=user["id"], email=user["email"], display_name=user["display_name"])
|
|
||||||
|
|
||||||
@app.post("/api/auth/logout")
|
@app.post("/api/auth/logout")
|
||||||
def auth_logout(request: Request, response: Response) -> dict:
|
def auth_logout(request: Request, response: Response) -> dict:
|
||||||
@@ -431,7 +436,8 @@ def create_app() -> FastAPI:
|
|||||||
return fail
|
return fail
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
user_id = auth.find_or_create_user(
|
user_id = auth.find_or_create_user(
|
||||||
conn, info["email"], "google", info["sub"], display_name=info.get("name")
|
conn, info["email"], "google", info["sub"],
|
||||||
|
display_name=info.get("name"), avatar_url=info.get("picture"),
|
||||||
)
|
)
|
||||||
token = auth.create_session(conn, user_id, user_agent=request.headers.get("User-Agent"))
|
token = auth.create_session(conn, user_id, user_agent=request.headers.get("User-Agent"))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
+10
-8
@@ -46,7 +46,7 @@ def normalize_email(email: str) -> str:
|
|||||||
|
|
||||||
def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
|
def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"SELECT id, email, display_name, created_at FROM users WHERE id = ?", (user_id,)
|
"SELECT id, email, display_name, avatar_url, created_at FROM users WHERE id = ?", (user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ def find_or_create_user(
|
|||||||
provider: str,
|
provider: str,
|
||||||
provider_subject: str,
|
provider_subject: str,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
|
avatar_url: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Resolve (or create) the user for a verified sign-in, linking the identity.
|
"""Resolve (or create) the user for a verified sign-in, linking the identity.
|
||||||
|
|
||||||
@@ -73,15 +74,16 @@ def find_or_create_user(
|
|||||||
user = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
|
user = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
|
||||||
if user:
|
if user:
|
||||||
user_id = user["id"]
|
user_id = user["id"]
|
||||||
if display_name:
|
# Fill display name if missing; refresh avatar whenever the provider gives one.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET display_name = COALESCE(display_name, ?), "
|
"UPDATE users SET display_name = COALESCE(display_name, ?), "
|
||||||
"updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
"avatar_url = COALESCE(?, avatar_url), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
(display_name, user_id),
|
(display_name, avatar_url, user_id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
user_id = conn.execute(
|
user_id = conn.execute(
|
||||||
"INSERT INTO users (email, display_name) VALUES (?, ?)", (email, display_name)
|
"INSERT INTO users (email, display_name, avatar_url) VALUES (?, ?, ?)",
|
||||||
|
(email, display_name, avatar_url),
|
||||||
).lastrowid
|
).lastrowid
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -231,6 +232,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
if column not in score_cols:
|
if column not in score_cols:
|
||||||
conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT")
|
conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT")
|
||||||
|
|
||||||
|
# users.avatar_url added for Google profile pictures.
|
||||||
|
user_tbl = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
|
||||||
|
if user_tbl and "avatar_url" not in user_tbl:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN avatar_url TEXT")
|
||||||
|
|
||||||
article_cols = {row["name"] for row in conn.execute("PRAGMA table_info(articles)")}
|
article_cols = {row["name"] for row in conn.execute("PRAGMA table_info(articles)")}
|
||||||
if "duplicate_of" not in article_cols:
|
if "duplicate_of" not in article_cols:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -98,4 +98,9 @@ def verify_id_token(id_token: str) -> dict:
|
|||||||
raise ValueError("id_token expired")
|
raise ValueError("id_token expired")
|
||||||
if not claims.get("email") or claims.get("email_verified") not in (True, "true"):
|
if not claims.get("email") or claims.get("email_verified") not in (True, "true"):
|
||||||
raise ValueError("email not verified")
|
raise ValueError("email not verified")
|
||||||
return {"sub": str(claims["sub"]), "email": claims["email"], "name": claims.get("name")}
|
return {
|
||||||
|
"sub": str(claims["sub"]),
|
||||||
|
"email": claims["email"],
|
||||||
|
"name": claims.get("name"),
|
||||||
|
"picture": claims.get("picture"),
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -32,9 +32,10 @@ def test_auth_url_has_required_params(monkeypatch):
|
|||||||
def test_verify_id_token_happy(monkeypatch):
|
def test_verify_id_token_happy(monkeypatch):
|
||||||
monkeypatch.setenv("GOODNEWS_GOOGLE_CLIENT_ID", "cid")
|
monkeypatch.setenv("GOODNEWS_GOOGLE_CLIENT_ID", "cid")
|
||||||
tok = _token({"iss": "https://accounts.google.com", "aud": "cid", "exp": 9999999999,
|
tok = _token({"iss": "https://accounts.google.com", "aud": "cid", "exp": 9999999999,
|
||||||
"sub": "g-123", "email": "a@b.com", "email_verified": True, "name": "A"})
|
"sub": "g-123", "email": "a@b.com", "email_verified": True, "name": "A",
|
||||||
|
"picture": "https://x/p.jpg"})
|
||||||
info = g.verify_id_token(tok)
|
info = g.verify_id_token(tok)
|
||||||
assert info == {"sub": "g-123", "email": "a@b.com", "name": "A"}
|
assert info == {"sub": "g-123", "email": "a@b.com", "name": "A", "picture": "https://x/p.jpg"}
|
||||||
|
|
||||||
|
|
||||||
def test_verify_id_token_rejects(monkeypatch):
|
def test_verify_id_token_rejects(monkeypatch):
|
||||||
|
|||||||
Reference in New Issue
Block a user