diff --git a/backend/app/auth.py b/backend/app/auth.py index 53fa444..ac00fca 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -16,6 +16,9 @@ from app.security import encrypt _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +# How many recently-used accounts to keep switchable in one browser session. +MAX_SESSION_ACCOUNTS = 6 + # Base sign-in requests only the non-sensitive OpenID scopes (profile/email). These do # NOT trigger Google's "unverified app" warning and do NOT expire after 7 days, so a new # user gets a clean, familiar consent. YouTube access is granted later, one step at a @@ -175,6 +178,12 @@ async def callback( db.add(tok) db.commit() + # Multi-session: remember every account that has authenticated in this browser so the + # user can switch between them without going through Google again. Only accounts that + # actually completed OAuth here land in this list (it's the proof they may be switched to). + accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id] + accounts.append(user.id) + request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) return RedirectResponse(url="/") @@ -182,8 +191,16 @@ async def callback( @router.post("/logout") async def logout(request: Request): + """Sign out the active account. If other accounts have authenticated in this browser, + switch to the most recent one instead of fully logging out.""" + active = request.session.get("user_id") + remaining = [a for a in (request.session.get("account_ids") or []) if a != active] + if remaining: + request.session["account_ids"] = remaining + request.session["user_id"] = remaining[-1] + return JSONResponse({"ok": True, "switched": True}) request.session.clear() - return JSONResponse({"ok": True}) + return JSONResponse({"ok": True, "switched": False}) @router.post("/request-access") diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 28312e2..40fc3f3 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -1,14 +1,62 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import func, select from sqlalchemy.orm import Session -from app.auth import current_user, has_read_scope, has_write_scope +from app.auth import current_user, has_read_scope, has_write_scope, is_allowed from app.db import get_db from app.models import Invite, User router = APIRouter(prefix="/api/me", tags=["me"]) +@router.get("/accounts") +def my_accounts( + request: Request, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> list[dict]: + """The accounts that have authenticated in this browser session (switchable without a + new Google sign-in). The active one is flagged.""" + ids = request.session.get("account_ids") or [user.id] + rows = {u.id: u for u in db.query(User).filter(User.id.in_(ids)).all()} + out = [] + for uid in ids: # preserve recency order from the session + u = rows.get(uid) + if u is not None: + out.append( + { + "id": u.id, + "email": u.email, + "display_name": u.display_name, + "avatar_url": u.avatar_url, + "active": u.id == user.id, + } + ) + return out + + +@router.post("/switch") +def switch_account( + payload: dict, + request: Request, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Switch the active account to another one already authenticated in this browser. No + Google round-trip — but only accounts present in the session list (proof they signed in + here) are allowed, and access is re-checked in case the invite was revoked since.""" + target = payload.get("user_id") + if target not in (request.session.get("account_ids") or []): + raise HTTPException(status_code=403, detail="Not an account you've signed into here.") + u = db.get(User, target) + if u is None: + raise HTTPException(status_code=404, detail="That account no longer exists.") + if not is_allowed(db, u.email): + raise HTTPException(status_code=403, detail="That account no longer has access.") + request.session["user_id"] = target + return {"ok": True, "user_id": target} + + @router.get("") def get_me( user: User = Depends(current_user), db: Session = Depends(get_db) diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 533f100..0d8c75b 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; import { BarChart3, ChevronLeft, @@ -12,8 +13,9 @@ import { Settings, Shield, Tv, + UserPlus, } from "lucide-react"; -import type { Me } from "../lib/api"; +import { api, type Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import AvatarImg from "./Avatar"; @@ -85,6 +87,23 @@ export default function NavSidebar({ location.reload(); } + // Accounts that have signed in on this browser (loaded only while the popover is open). + const accountsQuery = useQuery({ + queryKey: ["accounts"], + queryFn: api.accounts, + enabled: acctOpen, + }); + const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active); + + async function switchTo(id: number) { + try { + await api.switchAccount(id); + location.reload(); // cleanest way to reload all per-user state for the new account + } catch { + /* a revoked/removed account — leave the popover open */ + } + } + const items: { page: Page; icon: typeof Home; label: string }[] = [ { page: "feed", icon: Home, label: t("header.account.feed") }, { page: "channels", icon: Tv, label: t("header.account.channels") }, @@ -201,7 +220,40 @@ export default function NavSidebar({ {t("header.account.admin")} )} -