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:
parent
2a8d5c0a1e
commit
603cc9854c
4 changed files with 36 additions and 9 deletions
|
|
@ -719,6 +719,18 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> 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:
|
||||
"""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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from app.auth import (
|
|||
has_read_scope,
|
||||
has_write_scope,
|
||||
is_allowed,
|
||||
optional_current_user,
|
||||
purge_user,
|
||||
)
|
||||
from app.db import get_db
|
||||
|
|
@ -67,8 +68,13 @@ def switch_account(
|
|||
|
||||
@router.get("")
|
||||
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:
|
||||
# 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
|
||||
if user.role == "admin":
|
||||
pending_invites = (
|
||||
|
|
@ -80,6 +86,7 @@ def get_me(
|
|||
or 0
|
||||
)
|
||||
return {
|
||||
"authenticated": True,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import {
|
||||
api,
|
||||
getActiveAccount,
|
||||
HttpError,
|
||||
setActiveAccount,
|
||||
setUnauthorizedHandler,
|
||||
type FeedFilters,
|
||||
|
|
@ -451,8 +450,9 @@ export default function App() {
|
|||
}, [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
|
||||
// `me` exists), reload to the login page at once (option 2 — 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.
|
||||
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
||||
// 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(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
if (queryClient.getQueryData(["me"])) {
|
||||
|
|
@ -646,14 +646,15 @@ export default function App() {
|
|||
return (
|
||||
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
||||
);
|
||||
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
||||
return <Welcome />;
|
||||
// /api/me answers 200 always now (null body = logged out), so a thrown error here is a real
|
||||
// network/5xx failure, and no data means "not signed in" → the public landing.
|
||||
if (meQuery.error)
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center text-muted">
|
||||
{t("common.somethingWrong")}
|
||||
</div>
|
||||
);
|
||||
if (!meQuery.data) return <Welcome />;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-bg text-fg">
|
||||
|
|
|
|||
|
|
@ -253,8 +253,9 @@ interface ReqConfig {
|
|||
|
||||
// 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
|
||||
// page. The handler itself guards against firing when we were never signed in (public pages
|
||||
// legitimately 401 on /api/me), so it's safe to call for every 401.
|
||||
// page. The handler itself guards against firing when we were never signed in, so it's safe to
|
||||
// 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;
|
||||
export function setUnauthorizedHandler(fn: (() => void) | null): void {
|
||||
onUnauthorized = fn;
|
||||
|
|
@ -755,7 +756,13 @@ export interface AdminDownloadQuota {
|
|||
}
|
||||
|
||||
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"),
|
||||
deleteAccount: (): Promise<{ deleted: boolean }> =>
|
||||
req("/api/me/account", { method: "DELETE" }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue