Merge feature/n3-multi-session: account switch without re-auth (Epic N, N3)

This commit is contained in:
npeter83 2026-06-16 02:05:38 +02:00
commit 6fd46a8541
7 changed files with 142 additions and 8 deletions

View file

@ -16,6 +16,9 @@ from app.security import encrypt
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _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 # 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 # 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 # 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.add(tok)
db.commit() 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 request.session["user_id"] = user.id
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
return RedirectResponse(url="/") return RedirectResponse(url="/")
@ -182,8 +191,16 @@ async def callback(
@router.post("/logout") @router.post("/logout")
async def logout(request: Request): 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() request.session.clear()
return JSONResponse({"ok": True}) return JSONResponse({"ok": True, "switched": False})
@router.post("/request-access") @router.post("/request-access")

View file

@ -1,14 +1,62 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session 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.db import get_db
from app.models import Invite, User from app.models import Invite, User
router = APIRouter(prefix="/api/me", tags=["me"]) 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("") @router.get("")
def get_me( def get_me(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User = Depends(current_user), db: Session = Depends(get_db)

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { import {
BarChart3, BarChart3,
ChevronLeft, ChevronLeft,
@ -12,8 +13,9 @@ import {
Settings, Settings,
Shield, Shield,
Tv, Tv,
UserPlus,
} from "lucide-react"; } from "lucide-react";
import type { Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import AvatarImg from "./Avatar"; import AvatarImg from "./Avatar";
@ -85,6 +87,23 @@ export default function NavSidebar({
location.reload(); 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 }[] = [ const items: { page: Page; icon: typeof Home; label: string }[] = [
{ page: "feed", icon: Home, label: t("header.account.feed") }, { page: "feed", icon: Home, label: t("header.account.feed") },
{ page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "channels", icon: Tv, label: t("header.account.channels") },
@ -201,7 +220,40 @@ export default function NavSidebar({
{t("header.account.admin")} {t("header.account.admin")}
</div> </div>
)} )}
<div className="mt-2 flex flex-col"> {otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-[10px] shrink-0"
/>
<span className="min-w-0 text-left">
<span className="block text-[13px] text-fg truncate">
{a.display_name ?? a.email.split("@")[0]}
</span>
<span className="block text-[11px] text-muted truncate">{a.email}</span>
</span>
</button>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
onClick={() => {
window.location.href = "/auth/login";
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button <button
onClick={() => { onClick={() => {
onOpenAbout(); onOpenAbout();

View file

@ -18,7 +18,9 @@
"stats": "Statistik", "stats": "Statistik",
"settings": "Einstellungen", "settings": "Einstellungen",
"about": "Über", "about": "Über",
"signOut": "Abmelden" "signOut": "Abmelden",
"addAccount": "Weiteres Konto hinzufügen",
"switchTo": "Zu {{name}} wechseln"
}, },
"sync": { "sync": {
"yours": "deine", "yours": "deine",

View file

@ -18,7 +18,9 @@
"stats": "Stats", "stats": "Stats",
"settings": "Settings", "settings": "Settings",
"about": "About", "about": "About",
"signOut": "Sign out" "signOut": "Sign out",
"addAccount": "Add another account",
"switchTo": "Switch to {{name}}"
}, },
"sync": { "sync": {
"yours": "yours", "yours": "yours",

View file

@ -18,7 +18,9 @@
"stats": "Statisztika", "stats": "Statisztika",
"settings": "Beállítások", "settings": "Beállítások",
"about": "Névjegy", "about": "Névjegy",
"signOut": "Kijelentkezés" "signOut": "Kijelentkezés",
"addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}"
}, },
"sync": { "sync": {
"yours": "tiéd", "yours": "tiéd",

View file

@ -294,8 +294,19 @@ export interface ManagedChannel {
backfill_done: boolean; backfill_done: boolean;
} }
export interface Account {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
active: boolean;
}
export const api = { export const api = {
me: (): Promise<Me> => req("/api/me"), me: (): Promise<Me> => req("/api/me"),
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
version: (): Promise<VersionInfo> => req("/api/version"), version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"), tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"), status: (): Promise<SyncStatus> => req("/api/sync/status"),