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();