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:
+14
-8
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user