diff --git a/frontend/src/lib/components/AccountPanel.svelte b/frontend/src/lib/components/AccountPanel.svelte index 6b2e2dd..cfcef76 100644 --- a/frontend/src/lib/components/AccountPanel.svelte +++ b/frontend/src/lib/components/AccountPanel.svelte @@ -1,7 +1,7 @@ + +{#if user?.avatar_url && !failed} + (failed = true)} + /> +{:else} + {initial} +{/if} + + diff --git a/frontend/src/lib/components/BottomNav.svelte b/frontend/src/lib/components/BottomNav.svelte index 8b5dc57..dd65fd8 100644 --- a/frontend/src/lib/components/BottomNav.svelte +++ b/frontend/src/lib/components/BottomNav.svelte @@ -1,7 +1,8 @@ diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte index 256c86b..37dd331 100644 --- a/frontend/src/lib/components/Header.svelte +++ b/frontend/src/lib/components/Header.svelte @@ -1,6 +1,6 @@
@@ -23,7 +23,7 @@ {#if user} {:else} @@ -59,13 +59,7 @@ .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); - } + .acct { padding: 4px; display: inline-flex; } /* On phones the utilities live in the bottom tab bar ("You") instead. */ @media (max-width: 720px) { diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 4f46f01..430ee0e 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,5 +1,6 @@ + +
+ +
+ +
+ {#if auth.user} +

You

+ + goto('/')} /> + {/if} +
+ + diff --git a/goodnews/api.py b/goodnews/api.py index 449cad8..1544396 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -104,6 +104,15 @@ def _require_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row: 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: """Send the magic link, swallowing failures (runs off the request path).""" try: @@ -284,6 +293,7 @@ class UserOut(BaseModel): id: int email: str display_name: str | None = None + avatar_url: str | None = None class SessionOut(BaseModel): @@ -370,18 +380,13 @@ def create_app() -> FastAPI: conn.commit() user = auth.get_user(conn, user_id) _set_session_cookie(response, token) - return SessionOut( - user=UserOut(id=user["id"], email=user["email"], display_name=user["display_name"]), - token=token, - ) + return SessionOut(user=UserOut(**_user_out(user)), token=token) @app.get("/api/auth/me", response_model=UserOut | None) def auth_me(request: Request) -> UserOut | None: with get_conn() as conn: user = _current_user(conn, request) - if not user: - return None - return UserOut(id=user["id"], email=user["email"], display_name=user["display_name"]) + return UserOut(**_user_out(user)) if user else None @app.post("/api/auth/logout") def auth_logout(request: Request, response: Response) -> dict: @@ -431,7 +436,8 @@ def create_app() -> FastAPI: return fail with get_conn() as conn: 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")) conn.commit() diff --git a/goodnews/auth.py b/goodnews/auth.py index 3ee5d76..d3032e8 100644 --- a/goodnews/auth.py +++ b/goodnews/auth.py @@ -46,7 +46,7 @@ def normalize_email(email: str) -> str: def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None: 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() @@ -56,6 +56,7 @@ def find_or_create_user( provider: str, provider_subject: str, display_name: str | None = None, + avatar_url: str | None = None, ) -> int: """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() if user: user_id = user["id"] - if display_name: - conn.execute( - "UPDATE users SET display_name = COALESCE(display_name, ?), " - "updated_at = CURRENT_TIMESTAMP WHERE id = ?", - (display_name, user_id), - ) + # Fill display name if missing; refresh avatar whenever the provider gives one. + conn.execute( + "UPDATE users SET display_name = COALESCE(display_name, ?), " + "avatar_url = COALESCE(?, avatar_url), updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (display_name, avatar_url, user_id), + ) else: 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 conn.execute( diff --git a/goodnews/db.py b/goodnews/db.py index 97634b6..41fd957 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -136,6 +136,7 @@ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, display_name TEXT, + avatar_url TEXT, created_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: 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)")} if "duplicate_of" not in article_cols: conn.execute( diff --git a/goodnews/oauth_google.py b/goodnews/oauth_google.py index cb9f666..a0fdc24 100644 --- a/goodnews/oauth_google.py +++ b/goodnews/oauth_google.py @@ -98,4 +98,9 @@ def verify_id_token(id_token: str) -> dict: raise ValueError("id_token expired") if not claims.get("email") or claims.get("email_verified") not in (True, "true"): 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"), + } diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 0676def..e2c9e28 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -32,9 +32,10 @@ def test_auth_url_has_required_params(monkeypatch): def test_verify_id_token_happy(monkeypatch): monkeypatch.setenv("GOODNEWS_GOOGLE_CLIENT_ID", "cid") 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) - 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):