feat(auth): N3 — server-side multi-session account switch

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.
This commit is contained in:
npeter83 2026-06-16 02:05:38 +02:00
parent 8c076f7a75
commit 583e003c23
7 changed files with 142 additions and 8 deletions

View file

@ -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")}
</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
onClick={() => {
onOpenAbout();

View file

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

View file

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

View file

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

View file

@ -294,8 +294,19 @@ export interface ManagedChannel {
backfill_done: boolean;
}
export interface Account {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
active: boolean;
}
export const api = {
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"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),