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:
jay
2026-06-03 14:41:43 +00:00
parent bb008cfaa5
commit 15728c3bcb
11 changed files with 168 additions and 87 deletions
+14 -8
View File
@@ -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()
+10 -8
View File
@@ -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(
+6
View File
@@ -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(
+6 -1
View File
@@ -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"),
}