fix(auth): /api/me answers 200 when logged out (no console error)

The logged-out landing probed /api/me, which 401'd, and the browser logs every
non-2xx fetch as a console error regardless of how the app handles it. The
bootstrap probe now returns 200 with {authenticated:false} via a new
optional_current_user dependency; api.me() maps that to null. The render gate
treats no-data as 'signed out' -> the landing, and a thrown error as a real
network/5xx failure. Other protected endpoints still 401, so mid-session expiry
is still caught by the global handler. Lighthouse (dev): Best Practices 96->100.
This commit is contained in:
npeter83 2026-07-04 18:17:24 +02:00
parent 2a8d5c0a1e
commit 603cc9854c
4 changed files with 36 additions and 9 deletions

View file

@ -719,6 +719,18 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
return user return user
def optional_current_user(
request: Request, db: Session = Depends(get_db)
) -> User | None:
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
try:
return current_user(request, db)
except HTTPException:
return None
def require_human(user: User = Depends(current_user)) -> User: def require_human(user: User = Depends(current_user)) -> User:
"""Reject the shared demo account from actions that need a real Google/YouTube identity """Reject the shared demo account from actions that need a real Google/YouTube identity
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has

View file

@ -9,6 +9,7 @@ from app.auth import (
has_read_scope, has_read_scope,
has_write_scope, has_write_scope,
is_allowed, is_allowed,
optional_current_user,
purge_user, purge_user,
) )
from app.db import get_db from app.db import get_db
@ -67,8 +68,13 @@ def switch_account(
@router.get("") @router.get("")
def get_me( def get_me(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User | None = Depends(optional_current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
# The app's bootstrap probe: return 200 with `authenticated: False` when logged out (rather
# than 401) so the public landing never logs a failed /api/me to the browser console. Other
# protected endpoints still 401, so mid-session expiry is still caught by the global handler.
if user is None:
return {"authenticated": False}
pending_invites = 0 pending_invites = 0
if user.role == "admin": if user.role == "admin":
pending_invites = ( pending_invites = (
@ -80,6 +86,7 @@ def get_me(
or 0 or 0
) )
return { return {
"authenticated": True,
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,
"display_name": user.display_name, "display_name": user.display_name,

View file

@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
api, api,
getActiveAccount, getActiveAccount,
HttpError,
setActiveAccount, setActiveAccount,
setUnauthorizedHandler, setUnauthorizedHandler,
type FeedFilters, type FeedFilters,
@ -451,8 +450,9 @@ export default function App() {
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Any request that 401s means the session ended server-side. If we were signed in (cached // Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` exists), reload to the login page at once (option 2 — also covers any click that hits // `me` is a user object), reload to the login page at once (also covers any click that hits
// the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop. // the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
// can't loop the public landing.
useEffect(() => { useEffect(() => {
setUnauthorizedHandler(() => { setUnauthorizedHandler(() => {
if (queryClient.getQueryData(["me"])) { if (queryClient.getQueryData(["me"])) {
@ -646,14 +646,15 @@ export default function App() {
return ( return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div> <div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
); );
if (meQuery.error instanceof HttpError && meQuery.error.status === 401) // /api/me answers 200 always now (null body = logged out), so a thrown error here is a real
return <Welcome />; // network/5xx failure, and no data means "not signed in" → the public landing.
if (meQuery.error) if (meQuery.error)
return ( return (
<div className="min-h-screen grid place-items-center text-muted"> <div className="min-h-screen grid place-items-center text-muted">
{t("common.somethingWrong")} {t("common.somethingWrong")}
</div> </div>
); );
if (!meQuery.data) return <Welcome />;
return ( return (
<div className="h-screen flex bg-bg text-fg"> <div className="h-screen flex bg-bg text-fg">

View file

@ -253,8 +253,9 @@ interface ReqConfig {
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended // Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login // server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in (public pages // page. The handler itself guards against firing when we were never signed in, so it's safe to
// legitimately 401 on /api/me), so it's safe to call for every 401. // call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
// user when logged out — but other protected endpoints still do.)
let onUnauthorized: (() => void) | null = null; let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void { export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn; onUnauthorized = fn;
@ -755,7 +756,13 @@ export interface AdminDownloadQuota {
} }
export const api = { export const api = {
me: (): Promise<Me> => req("/api/me"), // Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out
// (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
// logs a failed request to the console). React Query treats the null as data, not an error.
me: async (): Promise<Me | null> => {
const r = await req("/api/me");
return r && r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"), accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> => deleteAccount: (): Promise<{ deleted: boolean }> =>
req("/api/me/account", { method: "DELETE" }), req("/api/me/account", { method: "DELETE" }),