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):