Merge feature/n3-multi-session: account switch without re-auth (Epic N, N3)
This commit is contained in:
commit
2d8c47b04e
7 changed files with 142 additions and 8 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue