From 4a80ea5aa5ddb0e1cb424d59840d18cdb47b611a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 00:42:23 +0200 Subject: [PATCH 01/14] =?UTF-8?q?feat(nav):=20N1=20=E2=80=94=20collapsible?= =?UTF-8?q?=20left=20nav=20sidebar=20(Design=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the modules (Feed/Channels/Playlists/Stats) out of the avatar dropdown into a persistent left sidebar with icon+label entries; collapses to a thin icon rail (persisted in localStorage). Account actions (About, Sign out, admin badge) move to a popover at the sidebar bottom; Settings is a rail entry. Header is now a contextual top bar (sync status, feed scope + search or page title, language, notifications) — logo and account menu removed from it. New nav.json strings (HU/EN/DE). First step of Epic N. --- frontend/src/App.tsx | 49 ++++--- frontend/src/components/Header.tsx | 149 +------------------- frontend/src/components/NavSidebar.tsx | 187 +++++++++++++++++++++++++ frontend/src/i18n/locales/de/nav.json | 5 + frontend/src/i18n/locales/en/nav.json | 5 + frontend/src/i18n/locales/hu/nav.json | 5 + 6 files changed, 234 insertions(+), 166 deletions(-) create mode 100644 frontend/src/components/NavSidebar.tsx create mode 100644 frontend/src/i18n/locales/de/nav.json create mode 100644 frontend/src/i18n/locales/en/nav.json create mode 100644 frontend/src/i18n/locales/hu/nav.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 21b27e7..9b12ad3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { configureNotifications, notify } from "./lib/notifications"; import { setHintsEnabled } from "./lib/hints"; import Login from "./components/Login"; import Header from "./components/Header"; +import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; import Channels, { type ChannelStatusFilter } from "./components/Channels"; @@ -192,32 +193,37 @@ export default function App() { ); return ( -
-
+ setSettingsOpen(true)} onOpenAbout={() => setAboutOpen(true)} - onChangeLanguage={changeLanguage} - onGoToFullHistory={() => { - setChannelFilter("needs_full"); - setPage("channels"); - }} /> - openReleaseNotes(CURRENT_VERSION)} /> -
- {page === "feed" && ( - - )} -
+
+
{ + setChannelFilter("needs_full"); + setPage("channels"); + }} + /> + openReleaseNotes(CURRENT_VERSION)} /> +
+ {page === "feed" && ( + + )} +
{page === "channels" ? ( setWizardOpen(true)} /> )} -
+
+
{settingsOpen && ( void; page: Page; - setPage: (p: Page) => void; - onOpenSettings: () => void; - onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; onGoToFullHistory: () => void; }) { const { t, i18n } = useTranslation(); - async function logout() { - await fetch("/auth/logout", { method: "POST", credentials: "include" }); - location.reload(); - } - return (
- - {page === "feed" && ( @@ -101,130 +83,7 @@ export default function Header({
-
- -
); } - -function Avatar({ me, className = "" }: { me: Me; className?: string }) { - return ( - - ); -} - -function AccountMenu({ - me, - logout, - page, - setPage, - onOpenSettings, - onOpenAbout, -}: { - me: Me; - logout: () => void; - page: Page; - setPage: (p: Page) => void; - onOpenSettings: () => void; - onOpenAbout: () => void; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const closeTimer = useRef | null>(null); - - function openNow() { - if (closeTimer.current) clearTimeout(closeTimer.current); - setOpen(true); - } - function closeSoon() { - closeTimer.current = setTimeout(() => setOpen(false), 220); - } - - const itemClass = - "w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"; - - return ( -
- - - {open && ( -
-
- -
-
- {me.display_name ?? me.email.split("@")[0]} -
-
{me.email}
-
-
- - {me.role === "admin" && ( -
- - {t("header.account.admin")} -
- )} - -
- {page !== "feed" && ( - - )} - {page !== "channels" && ( - - )} - {page !== "playlists" && ( - - )} - {me.role === "admin" && page !== "stats" && ( - - )} - - - -
-
- )} -
- ); -} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx new file mode 100644 index 0000000..1ea7c09 --- /dev/null +++ b/frontend/src/components/NavSidebar.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + BarChart3, + ChevronLeft, + ChevronRight, + Home, + Info, + ListVideo, + LogOut, + Settings, + Shield, + Tv, +} from "lucide-react"; +import type { Me } from "../lib/api"; +import type { Page } from "../lib/urlState"; +import AvatarImg from "./Avatar"; + +// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The +// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes +// a thin icon-only rail. Account actions sit at the bottom in a popover. +export default function NavSidebar({ + me, + page, + setPage, + onOpenSettings, + onOpenAbout, +}: { + me: Me; + page: Page; + setPage: (p: Page) => void; + onOpenSettings: () => void; + onOpenAbout: () => void; +}) { + const { t } = useTranslation(); + const [collapsed, setCollapsed] = useState( + () => localStorage.getItem("siftlode.navCollapsed") === "1" + ); + const [acctOpen, setAcctOpen] = useState(false); + + function toggleCollapsed() { + setCollapsed((c) => { + const next = !c; + localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0"); + return next; + }); + } + + async function logout() { + await fetch("/auth/logout", { method: "POST", credentials: "include" }); + location.reload(); + } + + 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") }, + { page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, + ]; + if (me.role === "admin") + items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") }); + + const rowBase = + "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; + const name = me.display_name ?? me.email.split("@")[0]; + + return ( + + ); +} diff --git a/frontend/src/i18n/locales/de/nav.json b/frontend/src/i18n/locales/de/nav.json new file mode 100644 index 0000000..5892e3f --- /dev/null +++ b/frontend/src/i18n/locales/de/nav.json @@ -0,0 +1,5 @@ +{ + "primary": "Hauptnavigation", + "collapse": "Seitenleiste einklappen", + "expand": "Seitenleiste ausklappen" +} diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json new file mode 100644 index 0000000..621159b --- /dev/null +++ b/frontend/src/i18n/locales/en/nav.json @@ -0,0 +1,5 @@ +{ + "primary": "Primary navigation", + "collapse": "Collapse sidebar", + "expand": "Expand sidebar" +} diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json new file mode 100644 index 0000000..5c18135 --- /dev/null +++ b/frontend/src/i18n/locales/hu/nav.json @@ -0,0 +1,5 @@ +{ + "primary": "Fő navigáció", + "collapse": "Oldalsáv összecsukása", + "expand": "Oldalsáv kinyitása" +} From d30dc7f7608294325e71e4b886cc80132a5c6e3e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 01:05:05 +0200 Subject: [PATCH 02/14] feat(nav): Settings as a page module (Design B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Settings out of the right-side overlay into a left-nav page (page='settings'), so it opens where you're already looking — no cross-screen mouse travel. The Settings rail item now sets the page (with active highlight) instead of opening a dialog; the panel is refactored to an in-flow .glass card (keeps the frosted look over the ambient backdrop), with the page title shown in the header. Removed the overlay + Esc/backdrop close path. --- frontend/src/App.tsx | 33 +++--- frontend/src/components/Header.tsx | 4 +- frontend/src/components/NavSidebar.tsx | 11 +- frontend/src/components/SettingsPanel.tsx | 119 ++++++++-------------- frontend/src/lib/urlState.ts | 6 +- 5 files changed, 71 insertions(+), 102 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b12ad3..8bee085 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,7 +57,13 @@ function loadInitialPage(): Page { const params = new URLSearchParams(window.location.search); if (params.has("page")) return readPage(); const stored = localStorage.getItem(PAGE_KEY); - if (stored === "channels" || stored === "stats" || stored === "playlists") return stored; + if ( + stored === "channels" || + stored === "stats" || + stored === "playlists" || + stored === "settings" + ) + return stored; return "feed"; } @@ -83,7 +89,6 @@ export default function App() { const [view, setView] = useState<"grid" | "list">("grid"); const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(loadInitialPage); - const [settingsOpen, setSettingsOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); const [channelFilter, setChannelFilter] = useState("all"); const [aboutOpen, setAboutOpen] = useState(false); @@ -198,7 +203,6 @@ export default function App() { me={meQuery.data!} page={page} setPage={setPage} - onOpenSettings={() => setSettingsOpen(true)} onOpenAbout={() => setAboutOpen(true)} />
@@ -239,6 +243,15 @@ export default function App() { ) : page === "playlists" ? ( + ) : page === "settings" ? ( + setWizardOpen(true)} + /> ) : (
- {settingsOpen && ( - setSettingsOpen(false)} - onOpenWizard={() => { - setSettingsOpen(false); - setWizardOpen(true); - }} - /> - )} {wizardOpen && meQuery.data && ( setWizardOpen(false)} /> )} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 8bf9752..840e567 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -76,7 +76,9 @@ export default function Header({ ? t("header.usageStats") : page === "playlists" ? t("header.account.playlists") - : t("header.channelManager")} + : page === "settings" + ? t("settings.title") + : t("header.channelManager")} )} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 1ea7c09..827ce62 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -23,13 +23,11 @@ export default function NavSidebar({ me, page, setPage, - onOpenSettings, onOpenAbout, }: { me: Me; page: Page; setPage: (p: Page) => void; - onOpenSettings: () => void; onOpenAbout: () => void; }) { const { t } = useTranslation(); @@ -115,9 +113,14 @@ export default function NavSidebar({
-
- -
- {/* Vertical tab rail — never wraps; active is a clear accent pill. */} - - - {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab - (stable height, no jump on switch); the active one is shown on top. */} -
- {TABS.map((tabItem) => ( -
+
+ {/* Vertical tab rail — never wraps; active is a clear accent pill. */} +
- ))} -
+ + {t(`settings.tabs.${tabItem.id}`)} + + ); + })} + + + {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab + (stable height, no jump on switch); the active one is shown on top. */} +
+ {TABS.map((tabItem) => ( +
+ {tabItem.id === "appearance" && ( + + )} + {tabItem.id === "notifications" && } + {tabItem.id === "sync" && } + {tabItem.id === "account" && } +
+ ))}
diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 325f742..a0ad9d2 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -78,11 +78,13 @@ export function hasFilterParams(params: URLSearchParams): boolean { return KEYS.some((k) => params.has(k)); } -export type Page = "feed" | "channels" | "stats" | "playlists"; +export type Page = "feed" | "channels" | "stats" | "playlists" | "settings"; export function readPage(): Page { const p = new URLSearchParams(window.location.search).get("page"); - return p === "channels" || p === "stats" || p === "playlists" ? p : "feed"; + return p === "channels" || p === "stats" || p === "playlists" || p === "settings" + ? p + : "feed"; } /** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the From d46be00d47e06e8deff7186d0b1f89d27c9e4948 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 01:11:50 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat(ui):=20glassmorphism=20phase=201=20?= =?UTF-8?q?=E2=80=94=20frost=20the=20app=20chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the real frosted .glass surface (blur) to the few large chrome surfaces — the nav sidebar, the header and all Modal-based dialogs (was glass-card / plain surfaces) — and enrich the ambient backdrop a touch (three soft accent pools) so the glass has more to refract app-wide. Popovers (notifications, language, account, add-to-playlist) were already glass. Bulk feed cards stay glass-card (no per-card blur) for performance; the existing perf-mode still disables blur. Phase 2 (ambient thumbnail mosaic / bg image + toggle) deferred to end-of-project polish. --- frontend/src/components/Header.tsx | 2 +- frontend/src/components/Modal.tsx | 2 +- frontend/src/components/NavSidebar.tsx | 2 +- frontend/src/index.css | 9 ++++++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 840e567..f5c3bdc 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -28,7 +28,7 @@ export default function Header({ const { t, i18n } = useTranslation(); return ( -
+
{page === "feed" && ( diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index ff5d383..ba4fff7 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -35,7 +35,7 @@ export default function Modal({ aria-modal="true" >
e.stopPropagation()} >
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 827ce62..9b13173 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -63,7 +63,7 @@ export default function NavSidebar({ return ( From a18924bf97ae13fa6299411e4088b83de75bf144 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 01:50:01 +0200 Subject: [PATCH 09/14] fix(nav): restore frosted glass on the portaled account popover Now that the popover is portaled to body its backdrop-filter actually samples the content behind it, so the near-opaque glass-menu (needed only as a workaround while blur was trapped) made it look solid. Switch it back to the frosty .glass (78%); the working blur softens the background so text stays readable without the bleed-through. Trapped header menus (language/notifications) keep glass-menu. --- frontend/src/components/NavSidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 3bffe2a..5a10f90 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -161,7 +161,7 @@ export default function NavSidebar({
setAcctOpen(false)} />
Date: Tue, 16 Jun 2026 01:54:01 +0200 Subject: [PATCH 10/14] fix(nav): remove backdrop div so the account popover frosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-screen transparent dismiss backdrop sat directly behind the popover and acted as a compositing layer, so the popover's backdrop-filter sampled it (empty) instead of the page — making the glass look fully solid. Drop the backdrop and dismiss via document mousedown/Escape listeners (like the other menus). Now backdrop-filter samples real content and the frost shows. --- frontend/src/components/NavSidebar.tsx | 38 ++++++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 5a10f90..533f100 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { @@ -37,6 +37,7 @@ export default function NavSidebar({ ); const [acctOpen, setAcctOpen] = useState(false); const acctBtnRef = useRef(null); + const acctPanelRef = useRef(null); // Popover position (fixed, viewport coords). It's portaled to so it escapes the // nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let // clicks fall through to the controls behind it. @@ -50,6 +51,27 @@ export default function NavSidebar({ setAcctOpen((o) => !o); } + // Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop + // div, which (sitting between the popover and the page) would break the popover's + // backdrop-filter and make it look solid. + useEffect(() => { + if (!acctOpen) return; + function onDoc(e: MouseEvent) { + const t = e.target as Node; + if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return; + setAcctOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setAcctOpen(false); + } + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [acctOpen]); + function toggleCollapsed() { setCollapsed((c) => { const next = !c; @@ -157,12 +179,11 @@ export default function NavSidebar({ {acctOpen && createPortal( - <> -
setAcctOpen(false)} /> -
+
-
- , +
, document.body )}
From 8c076f7a7502b9710d948eb329e31fc822ba9253 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 01:57:41 +0200 Subject: [PATCH 11/14] feat(ui): brighten dark-mode glass so the frost is visible over dark UI Dark glass looked nearly flat because the blurred backdrop is itself dark. Add a brightness lift to backdrop-filter in dark mode for .glass/.glass-menu so the frosted sheen reads even over dark surfaces (over colourful content it just looks richer). Perf-mode still disables the blur (later rule wins). --- frontend/src/index.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/index.css b/frontend/src/index.css index ecee970..6225ce3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -86,6 +86,13 @@ html[data-theme="dark"] body { var(--bg); background-attachment: fixed; } +/* Dark glass reads faint because the blurred backdrop is itself dark; lift its brightness so + the frosted sheen shows even over dark UI (over colourful content it just looks richer). */ +html[data-theme="dark"] .glass, +html[data-theme="dark"] .glass-menu { + backdrop-filter: blur(30px) saturate(1.7) brightness(1.4); + -webkit-backdrop-filter: blur(30px) saturate(1.7) brightness(1.4); +} /* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */ html[data-perf="1"] .glass, From 583e003c23b0d6605d064e90317a0a39af94db3d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 02:05:38 +0200 Subject: [PATCH 12/14] =?UTF-8?q?feat(auth):=20N3=20=E2=80=94=20server-sid?= =?UTF-8?q?e=20multi-session=20account=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track every account that completes OAuth in a browser session (session 'account_ids', ~14-day cookie), so the user can switch between them without another Google round-trip. New GET /api/me/accounts (switchable accounts, active flagged) and POST /api/me/switch (guarded: target must be in the session list — proof it signed in here — and re-checked against is_allowed in case the invite was revoked). Logout now signs out the active account and falls back to the most recent remaining one, else clears the session. UI: the account popover lists the other signed-in accounts (click to switch, reloads) plus an 'Add another account' action (-> /auth/login, which uses prompt=select_account). Trilingual. Third step of Epic N. --- backend/app/auth.py | 19 +++++++- backend/app/routes/me.py | 52 +++++++++++++++++++++- frontend/src/components/NavSidebar.tsx | 56 +++++++++++++++++++++++- frontend/src/i18n/locales/de/header.json | 4 +- frontend/src/i18n/locales/en/header.json | 4 +- frontend/src/i18n/locales/hu/header.json | 4 +- frontend/src/lib/api.ts | 11 +++++ 7 files changed, 142 insertions(+), 8 deletions(-) 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")}
)} -
+ {otherAccounts.length > 0 && ( +
+ {otherAccounts.map((a) => ( + + ))} +
+ )} +
+