diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx
index e9f1b8da..3bc12379 100644
--- a/app/[locale]/auth/callback/page.tsx
+++ b/app/[locale]/auth/callback/page.tsx
@@ -53,6 +53,7 @@ function OAuthCallbackInner() {
sessionStorage.removeItem("oauth_state");
sessionStorage.removeItem("oauth_code_verifier");
sessionStorage.removeItem("oauth_server_url");
+ sessionStorage.removeItem("oauth_add_account_mode");
let redirectTo = `/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx
index 15870554..12722d99 100644
--- a/app/[locale]/calendar/page.tsx
+++ b/app/[locale]/calendar/page.tsx
@@ -712,7 +712,7 @@ export default function CalendarPage() {
collapsed
quota={quota}
isPushConnected={isPushConnected}
- onLogout={() => { logout(); router.push('/login'); }}
+ onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx
index 2b9c32df..fbf631dd 100644
--- a/app/[locale]/contacts/page.tsx
+++ b/app/[locale]/contacts/page.tsx
@@ -552,7 +552,7 @@ export default function ContactsPage() {
collapsed
quota={quota}
isPushConnected={isPushConnected}
- onLogout={() => { logout(); router.push('/login'); }}
+ onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx
index c35cb02d..e7b45c82 100644
--- a/app/[locale]/files/page.tsx
+++ b/app/[locale]/files/page.tsx
@@ -357,7 +357,7 @@ export default function FilesPage() {
collapsed
quota={quota}
isPushConnected={isPushConnected}
- onLogout={() => { logout(); router.push('/login'); }}
+ onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx
index 2ccf540e..6b07e17c 100644
--- a/app/[locale]/login/page.tsx
+++ b/app/[locale]/login/page.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "@/i18n/navigation";
-import { useParams } from "next/navigation";
+import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -28,6 +28,8 @@ export default function LoginPage() {
const router = useRouter();
const t = useTranslations("login");
const params = useParams();
+ const searchParams = useSearchParams();
+ const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
@@ -102,7 +104,7 @@ export default function LoginPage() {
}, [serverUrl]);
useEffect(() => {
- if (isAuthenticated) {
+ if (isAuthenticated && !isAddAccountMode) {
let redirectTo = '/';
try {
const saved = sessionStorage.getItem('redirect_after_login');
@@ -113,7 +115,7 @@ export default function LoginPage() {
} catch { /* ignore */ }
router.push(redirectTo);
}
- }, [isAuthenticated, router]);
+ }, [isAuthenticated, router, isAddAccountMode]);
useEffect(() => {
clearError();
@@ -303,6 +305,9 @@ export default function LoginPage() {
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", serverUrl!);
+ if (isAddAccountMode) {
+ sessionStorage.setItem("oauth_add_account_mode", "true");
+ }
const authUrl = new URL(oauthMetadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
@@ -329,15 +334,7 @@ export default function LoginPage() {
if (success) {
saveUsername(formData.username);
- let redirectTo = '/';
- try {
- const saved = sessionStorage.getItem('redirect_after_login');
- if (saved) {
- sessionStorage.removeItem('redirect_after_login');
- redirectTo = saved;
- }
- } catch { /* ignore */ }
- router.push(redirectTo);
+ router.push('/');
}
};
@@ -426,10 +423,10 @@ export default function LoginPage() {
/>
- {appName}
+ {isAddAccountMode ? t("add_account_title") : appName}
- {t("title") !== appName ? t("title") : "Sign in to your account"}
+ {isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
@@ -737,6 +734,19 @@ export default function LoginPage() {
)}
)}
+
+ {isAddAccountMode && (
+
+ router.push('/')}
+ >
+ {t("cancel")}
+
+
+ )}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 90be1459..6b2cbd20 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -769,7 +769,9 @@ export default function Home() {
const handleLogout = () => {
logout();
- router.push('/login');
+ if (!useAuthStore.getState().isAuthenticated) {
+ router.push('/login');
+ }
};
const handleSearch = async (query: string) => {
diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx
index 66a3e9a0..0f5b576c 100644
--- a/app/[locale]/settings/page.tsx
+++ b/app/[locale]/settings/page.tsx
@@ -286,7 +286,7 @@ export default function SettingsPage() {
{/* Logout */}
{ logout(); router.push('/login'); }}
+ onClick={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
>
@@ -317,7 +317,7 @@ export default function SettingsPage() {
collapsed
quota={quota}
isPushConnected={isPushConnected}
- onLogout={() => { logout(); router.push('/login'); }}
+ onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 92af2d3d..a205fa8f 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
-import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
+import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
const COOKIE_OPTIONS = {
httpOnly: true,
@@ -12,20 +12,30 @@ const COOKIE_OPTIONS = {
maxAge: SESSION_COOKIE_MAX_AGE,
};
+function getSlot(request: NextRequest): number {
+ const raw = request.nextUrl.searchParams.get('slot');
+ if (raw === null) return 0;
+ const slot = parseInt(raw, 10);
+ if (isNaN(slot) || slot < 0 || slot > 4) return 0;
+ return slot;
+}
+
export async function POST(request: NextRequest) {
try {
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
}
- const { serverUrl, username, password } = await request.json();
+ const { serverUrl, username, password, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
+ const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
+ const cookieName = sessionCookieName(slot);
const token = encryptSession(serverUrl, username, password);
const cookieStore = await cookies();
- cookieStore.set(SESSION_COOKIE, token, COOKIE_OPTIONS);
+ cookieStore.set(cookieName, token, COOKIE_OPTIONS);
return NextResponse.json({ ok: true });
} catch (error) {
@@ -34,10 +44,12 @@ export async function POST(request: NextRequest) {
}
}
-export async function GET() {
+export async function GET(request: NextRequest) {
try {
+ const slot = getSlot(request);
+ const cookieName = sessionCookieName(slot);
const cookieStore = await cookies();
- const token = cookieStore.get(SESSION_COOKIE)?.value;
+ const token = cookieStore.get(cookieName)?.value;
if (!token) {
return NextResponse.json({ error: 'No session' }, { status: 401 });
@@ -45,7 +57,7 @@ export async function GET() {
const credentials = decryptSession(token);
if (!credentials) {
- cookieStore.delete(SESSION_COOKIE);
+ cookieStore.delete(cookieName);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
@@ -58,10 +70,21 @@ export async function GET() {
}
}
-export async function DELETE() {
+export async function DELETE(request: NextRequest) {
try {
const cookieStore = await cookies();
- cookieStore.delete(SESSION_COOKIE);
+ const all = request.nextUrl.searchParams.get('all') === 'true';
+
+ if (all) {
+ // Delete all session cookies (slots 0-4)
+ for (let i = 0; i <= 4; i++) {
+ cookieStore.delete(sessionCookieName(i));
+ }
+ } else {
+ const slot = getSlot(request);
+ cookieStore.delete(sessionCookieName(slot));
+ }
+
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts
index f67a536a..b89ece03 100644
--- a/app/api/auth/token/route.ts
+++ b/app/api/auth/token/route.ts
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
-import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens';
+import { refreshTokenCookieName } from '@/lib/oauth/tokens';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
@@ -14,6 +14,15 @@ const COOKIE_OPTIONS = {
maxAge: 30 * 24 * 60 * 60,
};
+function getSlot(request: NextRequest): number {
+ const raw = request.nextUrl.searchParams.get('slot');
+ if (raw === null) return 0;
+ const slot = parseInt(raw, 10);
+ if (isNaN(slot) || slot < 0 || slot > 4) return 0;
+ return slot;
+}
+
+
function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID;
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
@@ -53,12 +62,13 @@ function buildOAuthParams(base: Record): URLSearchParams {
export async function POST(request: NextRequest) {
try {
- const { code, code_verifier, redirect_uri } = await request.json();
+ const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
if (!code || !code_verifier || !redirect_uri) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
}
+ const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const tokenEndpoint = await getTokenEndpoint();
const params = buildOAuthParams({
@@ -93,8 +103,9 @@ export async function POST(request: NextRequest) {
});
if (tokens.refresh_token) {
+ const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
- cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
+ cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
}
return response;
@@ -104,10 +115,12 @@ export async function POST(request: NextRequest) {
}
}
-export async function PUT() {
+export async function PUT(request: NextRequest) {
try {
+ const slot = getSlot(request);
+ const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
- const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
+ const refreshToken = cookieStore.get(cookieName)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
@@ -129,7 +142,7 @@ export async function PUT() {
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
- cookieStore.delete(REFRESH_TOKEN_COOKIE);
+ cookieStore.delete(cookieName);
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
}
@@ -141,7 +154,7 @@ export async function PUT() {
}
if (tokens.refresh_token) {
- cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
+ cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
}
return NextResponse.json({
@@ -154,10 +167,39 @@ export async function PUT() {
}
}
-export async function DELETE() {
+export async function DELETE(request: NextRequest) {
try {
+ const all = request.nextUrl.searchParams.get('all') === 'true';
+
+ if (all) {
+ // Revoke and delete all refresh token cookies (slots 0-4)
+ const cookieStore = await cookies();
+ for (let i = 0; i <= 4; i++) {
+ const name = refreshTokenCookieName(i);
+ const token = cookieStore.get(name)?.value;
+ if (token) {
+ // Best-effort revocation
+ try {
+ const metadata = await getMetadata().catch(() => null);
+ if (metadata?.revocation_endpoint) {
+ const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
+ await fetch(metadata.revocation_endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: params.toString(),
+ }).catch(() => {});
+ }
+ } catch { /* best effort */ }
+ cookieStore.delete(name);
+ }
+ }
+ return NextResponse.json({ ok: true });
+ }
+
+ const slot = getSlot(request);
+ const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
- const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
+ const refreshToken = cookieStore.get(cookieName)?.value;
const metadata = await getMetadata().catch((err) => {
logger.warn('Failed to discover OAuth metadata during logout', {
error: err instanceof Error ? err.message : 'Unknown error',
@@ -186,7 +228,7 @@ export async function DELETE() {
}
}
- cookieStore.delete(REFRESH_TOKEN_COOKIE);
+ cookieStore.delete(cookieName);
}
let end_session_url: string | undefined;
diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx
new file mode 100644
index 00000000..2d958919
--- /dev/null
+++ b/components/layout/account-switcher.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback } from "react";
+import { createPortal } from "react-dom";
+import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useAccountStore, type AccountEntry } from "@/stores/account-store";
+import { useAuthStore } from "@/stores/auth-store";
+import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
+import { cn } from "@/lib/utils";
+import { useRouter } from "@/i18n/navigation";
+
+interface AccountSwitcherProps {
+ /** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
+ variant?: "rail" | "expanded";
+ className?: string;
+}
+
+function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
+ const initials = getInitials(account.displayName || account.label, account.email || account.username);
+ const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
+
+ return (
+
+ {initials}
+
+ );
+}
+
+export function AccountSwitcher({ variant = "rail", className }: AccountSwitcherProps) {
+ const t = useTranslations("sidebar");
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const buttonRef = useRef(null);
+ const popoverRef = useRef(null);
+ const [popoverStyle, setPopoverStyle] = useState({});
+
+ const accounts = useAccountStore((s) => s.accounts);
+ const activeAccountId = useAccountStore((s) => s.activeAccountId);
+ const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
+ const activeAccount = accounts.find((a) => a.id === activeAccountId);
+ const switchAccount = useAuthStore((s) => s.switchAccount);
+ const logout = useAuthStore((s) => s.logout);
+ const logoutAll = useAuthStore((s) => s.logoutAll);
+ const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
+
+ const updatePosition = useCallback(() => {
+ if (!buttonRef.current) return;
+ const rect = buttonRef.current.getBoundingClientRect();
+ if (variant === "rail") {
+ setPopoverStyle({
+ position: "fixed",
+ left: rect.right + 8,
+ bottom: Math.max(8, window.innerHeight - rect.bottom),
+ });
+ } else {
+ setPopoverStyle({
+ position: "fixed",
+ left: rect.left,
+ top: rect.bottom + 4,
+ });
+ }
+ }, [variant]);
+
+ useEffect(() => {
+ if (!open) return;
+ updatePosition();
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ buttonRef.current?.contains(e.target as Node) ||
+ popoverRef.current?.contains(e.target as Node)
+ ) return;
+ setOpen(false);
+ };
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("keydown", handleEscape);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("keydown", handleEscape);
+ };
+ }, [open, updatePosition]);
+
+ const handleSwitch = async (accountId: string) => {
+ if (accountId === activeAccountId) return;
+ setOpen(false);
+ await switchAccount(accountId);
+ };
+
+ const handleAddAccount = () => {
+ setOpen(false);
+ router.push(`/login?mode=add-account` as never);
+ };
+
+ const handleLogout = () => {
+ setOpen(false);
+ logout();
+ if (useAccountStore.getState().accounts.length === 0) {
+ router.push("/login" as never);
+ }
+ };
+
+ const handleLogoutAll = () => {
+ setOpen(false);
+ logoutAll();
+ router.push("/login" as never);
+ };
+
+ const handleSetDefault = (accountId: string) => {
+ setDefaultAccount(accountId);
+ };
+
+ // Display name for the active account
+ const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || "";
+ const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || "";
+
+ return (
+ <>
+ setOpen(!open)}
+ className={cn(
+ "flex items-center gap-2 rounded-md transition-colors",
+ variant === "rail"
+ ? "justify-center w-10 h-10 hover:bg-muted"
+ : "w-full px-2 py-1.5 hover:bg-muted text-left min-w-0",
+ className
+ )}
+ title={variant === "rail" ? (displayName || displayEmail) : undefined}
+ aria-expanded={open}
+ aria-haspopup="true"
+ >
+ {activeAccount ? (
+ <>
+
+ {variant === "expanded" && (
+ <>
+
+
{displayName}
+
{displayEmail}
+
+
+ >
+ )}
+ >
+ ) : (
+
+ ?
+
+ )}
+
+
+ {open && createPortal(
+
+ {/* Account List */}
+
+ {accounts.map((account) => {
+ const isActive = account.id === activeAccountId;
+ return (
+
handleSwitch(account.id)}
+ className={cn(
+ "w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors",
+ isActive ? "bg-accent/50" : "hover:bg-muted"
+ )}
+ role="menuitem"
+ disabled={isActive}
+ >
+
+
+ {isActive && (
+
+
+
+ )}
+
+
+
+
+ {account.displayName || account.label}
+
+ {account.isDefault && (
+
+ )}
+
+
+ {account.email || account.username}
+
+
+ {account.hasError ? (
+
+ ) : (
+
+ )}
+
+ {new URL(account.serverUrl).hostname}
+
+
+
+
+ );
+ })}
+
+
+ {/* Separator + Add Account */}
+ {accounts.length < MAX_ACCOUNTS && (
+
+
+
+ {t("add_account")}
+
+
+ )}
+
+ {/* Separator + Actions */}
+
+ {activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
+ handleSetDefault(activeAccount.id)}
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
+ role="menuitem"
+ >
+
+ {t("set_as_default")}
+
+ )}
+
+
+ {t("sign_out_of", { account: displayEmail })}
+
+ {accounts.length > 1 && (
+
+
+ {t("sign_out_all")}
+
+ )}
+
+
,
+ document.body
+ )}
+ >
+ );
+}
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index 13f7b933..aaa93e12 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -3,6 +3,7 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
+import { AccountSwitcher } from "./account-switcher";
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
@@ -432,13 +433,7 @@ export function NavigationRail({
)}
{onLogout && (
-
-
-
+
)}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index b8c7446f..9223f8c6 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -39,6 +39,7 @@ import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
+import { AccountSwitcher } from "./account-switcher";
interface SidebarProps {
mailboxes: Mailbox[];
@@ -485,15 +486,8 @@ export function Sidebar({
{isCollapsed ? : }
- {!isCollapsed && primaryIdentity && (
-
-
- {primaryIdentity.name}
-
-
- {primaryIdentity.email}
-
-
+ {!isCollapsed && (
+
)}
diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts
new file mode 100644
index 00000000..6c39606d
--- /dev/null
+++ b/lib/account-state-manager.ts
@@ -0,0 +1,116 @@
+/**
+ * Manages per-account state snapshots for fast switching.
+ * When user switches from Account A → B, we snapshot A's store state
+ * into memory, clear stores, then restore B's cached state.
+ */
+
+import { useEmailStore } from '@/stores/email-store';
+import { useContactStore } from '@/stores/contact-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useFilterStore } from '@/stores/filter-store';
+import { useIdentityStore } from '@/stores/identity-store';
+import { useVacationStore } from '@/stores/vacation-store';
+
+// Minimal snapshot shapes — we only capture what we need
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type StoreSnapshot = Record;
+
+interface AccountSnapshot {
+ email: StoreSnapshot;
+ contact: StoreSnapshot;
+ calendar: StoreSnapshot;
+ filter: StoreSnapshot;
+ identity: StoreSnapshot;
+ vacation: StoreSnapshot;
+}
+
+const cache = new Map();
+
+/** Capture current store states for the given account */
+export function snapshotAccount(accountId: string): void {
+ const emailState = useEmailStore.getState();
+ const contactState = useContactStore.getState();
+ const calendarState = useCalendarStore.getState();
+ const filterState = useFilterStore.getState();
+ const identityState = useIdentityStore.getState();
+ const vacationState = useVacationStore.getState();
+
+ cache.set(accountId, {
+ email: {
+ emails: emailState.emails,
+ mailboxes: emailState.mailboxes,
+ selectedEmail: emailState.selectedEmail,
+ selectedMailbox: emailState.selectedMailbox,
+ searchQuery: emailState.searchQuery,
+ quota: emailState.quota,
+ },
+ contact: {
+ contacts: contactState.contacts,
+ addressBooks: contactState.addressBooks,
+ supportsSync: contactState.supportsSync,
+ },
+ calendar: {
+ calendars: calendarState.calendars,
+ events: calendarState.events,
+ selectedCalendarIds: calendarState.selectedCalendarIds,
+ viewMode: calendarState.viewMode,
+ supportsCalendar: calendarState.supportsCalendar,
+ },
+ filter: {
+ rules: filterState.rules,
+ isSupported: filterState.isSupported,
+ },
+ identity: {
+ identities: identityState.identities,
+ preferredPrimaryId: identityState.preferredPrimaryId,
+ },
+ vacation: {
+ isEnabled: vacationState.isEnabled,
+ isSupported: vacationState.isSupported,
+ },
+ });
+}
+
+/** Restore cached store states for the given account. Returns false if no cache exists. */
+export function restoreAccount(accountId: string): boolean {
+ const snapshot = cache.get(accountId);
+ if (!snapshot) return false;
+
+ useEmailStore.setState(snapshot.email);
+ useContactStore.setState(snapshot.contact);
+ useCalendarStore.setState(snapshot.calendar);
+ useFilterStore.setState(snapshot.filter);
+ useIdentityStore.setState(snapshot.identity);
+ useVacationStore.setState(snapshot.vacation);
+
+ return true;
+}
+
+/** Clear all stores (used before restoring a different account) */
+export function clearAllStores(): void {
+ useEmailStore.setState({
+ emails: [],
+ mailboxes: [],
+ selectedEmail: null,
+ selectedMailbox: '',
+ isLoading: false,
+ error: null,
+ searchQuery: '',
+ quota: null,
+ });
+ useIdentityStore.getState().clearIdentities();
+ useContactStore.getState().clearContacts();
+ useVacationStore.getState().clearState();
+ useCalendarStore.getState().clearState();
+ useFilterStore.getState().clearState();
+}
+
+/** Evict cached state for one account */
+export function evictAccount(accountId: string): void {
+ cache.delete(accountId);
+}
+
+/** Evict all cached states */
+export function evictAll(): void {
+ cache.clear();
+}
diff --git a/lib/account-utils.ts b/lib/account-utils.ts
new file mode 100644
index 00000000..fa646969
--- /dev/null
+++ b/lib/account-utils.ts
@@ -0,0 +1,59 @@
+/**
+ * Utilities for multi-account support:
+ * - Account ID generation
+ * - Deterministic avatar colors
+ * - Account-scoped localStorage keys
+ */
+
+/** Generate a unique, deterministic account ID from username and server URL */
+export function generateAccountId(username: string, serverUrl: string): string {
+ const host = new URL(serverUrl).hostname;
+ return `${username}@${host}`;
+}
+
+/** Deterministic avatar/accent color from an email string */
+export function generateAvatarColor(email: string): string {
+ let hash = 0;
+ for (let i = 0; i < email.length; i++) {
+ hash = ((hash << 5) - hash + email.charCodeAt(i)) | 0;
+ }
+ // 12 distinct, accessible hues
+ const colors = [
+ '#2563eb', // blue
+ '#7c3aed', // violet
+ '#db2777', // pink
+ '#dc2626', // red
+ '#ea580c', // orange
+ '#d97706', // amber
+ '#65a30d', // lime
+ '#16a34a', // green
+ '#0d9488', // teal
+ '#0891b2', // cyan
+ '#6366f1', // indigo
+ '#9333ea', // purple
+ ];
+ return colors[Math.abs(hash) % colors.length];
+}
+
+/** Get initials for an avatar from a display name or email */
+export function getInitials(name: string, email?: string): string {
+ if (name) {
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+ }
+ return parts[0][0]?.toUpperCase() ?? '?';
+ }
+ if (email) {
+ return email[0]?.toUpperCase() ?? '?';
+ }
+ return '?';
+}
+
+/** Build an account-scoped localStorage key */
+export function getAccountScopedKey(baseKey: string, accountId: string): string {
+ return `${baseKey}::${accountId}`;
+}
+
+/** Maximum number of accounts allowed */
+export const MAX_ACCOUNTS = 5;
diff --git a/lib/auth/session-cookie.ts b/lib/auth/session-cookie.ts
index 86ae3cd9..f91ac781 100644
--- a/lib/auth/session-cookie.ts
+++ b/lib/auth/session-cookie.ts
@@ -1,2 +1,7 @@
export const SESSION_COOKIE = 'jmap_session';
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
+
+/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
+export function sessionCookieName(slot: number): string {
+ return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
+}
diff --git a/lib/oauth/tokens.ts b/lib/oauth/tokens.ts
index f91df7c9..5f05e9d3 100644
--- a/lib/oauth/tokens.ts
+++ b/lib/oauth/tokens.ts
@@ -1,2 +1,7 @@
export const OAUTH_SCOPES = 'openid email profile';
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
+
+/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
+export function refreshTokenCookieName(slot: number): string {
+ return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
+}
diff --git a/locales/de/common.json b/locales/de/common.json
index f66f385d..2d9785c9 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -34,6 +34,9 @@
"dismiss": "Schließen",
"or": "oder",
"sign_in_sso": "Mit SSO anmelden",
+ "add_account_title": "Konto hinzufügen",
+ "add_account_subtitle": "Mit einem anderen Konto anmelden",
+ "cancel": "Abbrechen",
"website": "Webseite",
"imprint": "Impressum",
"privacy_policy": "Datenschutz",
@@ -58,6 +61,11 @@
"storage_free": "Frei",
"storage_total": "Gesamt",
"sign_out": "Abmelden",
+ "sign_out_of": "Von {account} abmelden",
+ "sign_out_all": "Von allen Konten abmelden",
+ "add_account": "Konto hinzufügen",
+ "set_as_default": "Als Standard festlegen",
+ "switch_account": "Konto wechseln",
"contacts": "Kontakte",
"calendar": "Kalender",
"settings": "Einstellungen",
diff --git a/locales/en/common.json b/locales/en/common.json
index 8027b176..ac30c871 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -34,6 +34,9 @@
"dismiss": "Dismiss",
"or": "or",
"sign_in_sso": "Sign in with SSO",
+ "add_account_title": "Add Account",
+ "add_account_subtitle": "Sign in with another account",
+ "cancel": "Cancel",
"website": "Website",
"imprint": "Imprint",
"privacy_policy": "Privacy Policy",
@@ -58,6 +61,11 @@
"storage_free": "Free",
"storage_total": "Total",
"sign_out": "Sign out",
+ "sign_out_of": "Sign out of {account}",
+ "sign_out_all": "Sign out of all accounts",
+ "add_account": "Add account",
+ "set_as_default": "Set as default",
+ "switch_account": "Switch account",
"contacts": "Contacts",
"calendar": "Calendar",
"settings": "Settings",
diff --git a/locales/es/common.json b/locales/es/common.json
index 8bd161bb..84f1581d 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -34,6 +34,9 @@
"dismiss": "Cerrar",
"or": "o",
"sign_in_sso": "Iniciar sesión con SSO",
+ "add_account_title": "Agregar cuenta",
+ "add_account_subtitle": "Iniciar sesión con otra cuenta",
+ "cancel": "Cancelar",
"website": "Sitio web",
"imprint": "Aviso legal",
"privacy_policy": "Política de privacidad",
@@ -58,6 +61,11 @@
"storage_free": "Libre",
"storage_total": "Total",
"sign_out": "Cerrar sesión",
+ "sign_out_of": "Cerrar sesión de {account}",
+ "sign_out_all": "Cerrar sesión de todas las cuentas",
+ "add_account": "Agregar cuenta",
+ "set_as_default": "Establecer como predeterminada",
+ "switch_account": "Cambiar cuenta",
"contacts": "Contactos",
"calendar": "Calendario",
"settings": "Configuración",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index f4436cd3..e508e358 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -34,6 +34,9 @@
"dismiss": "Fermer",
"or": "ou",
"sign_in_sso": "Se connecter avec SSO",
+ "add_account_title": "Ajouter un compte",
+ "add_account_subtitle": "Se connecter avec un autre compte",
+ "cancel": "Annuler",
"website": "Site web",
"imprint": "Mentions légales",
"privacy_policy": "Politique de confidentialité",
@@ -58,6 +61,11 @@
"storage_free": "Libre",
"storage_total": "Total",
"sign_out": "Se déconnecter",
+ "sign_out_of": "Se déconnecter de {account}",
+ "sign_out_all": "Se déconnecter de tous les comptes",
+ "add_account": "Ajouter un compte",
+ "set_as_default": "Définir par défaut",
+ "switch_account": "Changer de compte",
"contacts": "Contacts",
"calendar": "Calendrier",
"settings": "Paramètres",
diff --git a/locales/it/common.json b/locales/it/common.json
index af38d806..05cb66db 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -34,6 +34,9 @@
"dismiss": "Chiudi",
"or": "o",
"sign_in_sso": "Accedi con SSO",
+ "add_account_title": "Aggiungi account",
+ "add_account_subtitle": "Accedi con un altro account",
+ "cancel": "Annulla",
"website": "Sito web",
"imprint": "Note legali",
"privacy_policy": "Informativa sulla privacy",
@@ -58,6 +61,11 @@
"storage_free": "Libero",
"storage_total": "Totale",
"sign_out": "Esci",
+ "sign_out_of": "Disconnetti da {account}",
+ "sign_out_all": "Disconnetti da tutti gli account",
+ "add_account": "Aggiungi account",
+ "set_as_default": "Imposta come predefinito",
+ "switch_account": "Cambia account",
"contacts": "Contatti",
"calendar": "Calendario",
"settings": "Impostazioni",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 4106fe78..d78b436b 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -34,6 +34,9 @@
"dismiss": "閉じる",
"or": "または",
"sign_in_sso": "SSOでサインイン",
+ "add_account_title": "アカウントを追加",
+ "add_account_subtitle": "別のアカウントでサインイン",
+ "cancel": "キャンセル",
"website": "ウェブサイト",
"imprint": "サイト運営者情報",
"privacy_policy": "プライバシーポリシー",
@@ -58,6 +61,11 @@
"storage_free": "空き",
"storage_total": "合計",
"sign_out": "サインアウト",
+ "sign_out_of": "{account} からサインアウト",
+ "sign_out_all": "すべてのアカウントからサインアウト",
+ "add_account": "アカウントを追加",
+ "set_as_default": "デフォルトに設定",
+ "switch_account": "アカウントを切り替え",
"contacts": "連絡先",
"calendar": "カレンダー",
"settings": "設定",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 796330a9..0fd3ae33 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -34,6 +34,9 @@
"dismiss": "Sluiten",
"or": "of",
"sign_in_sso": "Inloggen met SSO",
+ "add_account_title": "Account toevoegen",
+ "add_account_subtitle": "Inloggen met een ander account",
+ "cancel": "Annuleren",
"website": "Website",
"imprint": "Colofon",
"privacy_policy": "Privacybeleid",
@@ -58,6 +61,11 @@
"storage_free": "Vrij",
"storage_total": "Totaal",
"sign_out": "Afmelden",
+ "sign_out_of": "Uitloggen van {account}",
+ "sign_out_all": "Uitloggen van alle accounts",
+ "add_account": "Account toevoegen",
+ "set_as_default": "Als standaard instellen",
+ "switch_account": "Account wisselen",
"contacts": "Contacten",
"calendar": "Agenda",
"settings": "Instellingen",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index af31e184..386ad422 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -34,6 +34,9 @@
"dismiss": "Fechar",
"or": "ou",
"sign_in_sso": "Entrar com SSO",
+ "add_account_title": "Adicionar conta",
+ "add_account_subtitle": "Entrar com outra conta",
+ "cancel": "Cancelar",
"website": "Site",
"imprint": "Informações legais",
"privacy_policy": "Política de privacidade",
@@ -58,6 +61,11 @@
"storage_free": "Livre",
"storage_total": "Total",
"sign_out": "Sair",
+ "sign_out_of": "Sair de {account}",
+ "sign_out_all": "Sair de todas as contas",
+ "add_account": "Adicionar conta",
+ "set_as_default": "Definir como padrão",
+ "switch_account": "Trocar conta",
"contacts": "Contatos",
"calendar": "Calendário",
"settings": "Configurações",
diff --git a/stores/account-store.ts b/stores/account-store.ts
new file mode 100644
index 00000000..c92941d9
--- /dev/null
+++ b/stores/account-store.ts
@@ -0,0 +1,184 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { generateAccountId, generateAvatarColor, MAX_ACCOUNTS } from '@/lib/account-utils';
+
+export interface AccountEntry {
+ /** Unique key: `${username}@${serverHostname}` */
+ id: string;
+ /** Display label (defaults to email, user-editable) */
+ label: string;
+ /** Full server URL */
+ serverUrl: string;
+ /** Username / email used to authenticate */
+ username: string;
+ /** Authentication mode */
+ authMode: 'basic' | 'oauth';
+ /** Cookie slot index (0–4) for session/token cookies */
+ cookieSlot: number;
+ /** Whether "Remember Me" was checked (basic auth only) */
+ rememberMe: boolean;
+ /** Cached display info */
+ displayName: string;
+ email: string;
+ avatarColor: string;
+ /** Timestamp of last successful login */
+ lastLoginAt: number;
+ /** Whether this account is currently connected */
+ isConnected: boolean;
+ /** Whether this account had a connection error */
+ hasError: boolean;
+ errorMessage?: string;
+ /** Whether this is the default account (loaded on app start) */
+ isDefault: boolean;
+}
+
+interface AccountState {
+ accounts: AccountEntry[];
+ activeAccountId: string | null;
+ defaultAccountId: string | null;
+
+ addAccount: (entry: Omit) => string;
+ removeAccount: (accountId: string) => void;
+ setActiveAccount: (accountId: string) => void;
+ setDefaultAccount: (accountId: string) => void;
+ getDefaultAccount: () => AccountEntry | null;
+ updateAccount: (accountId: string, updates: Partial) => void;
+ getActiveAccount: () => AccountEntry | null;
+ getAccountById: (accountId: string) => AccountEntry | undefined;
+ getNextCookieSlot: () => number;
+ hasAccount: (username: string, serverUrl: string) => boolean;
+}
+
+export const useAccountStore = create()(
+ persist(
+ (set, get) => ({
+ accounts: [],
+ activeAccountId: null,
+ defaultAccountId: null,
+
+ addAccount: (entry) => {
+ const state = get();
+ if (state.accounts.length >= MAX_ACCOUNTS) {
+ throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
+ }
+
+ const id = generateAccountId(entry.username, entry.serverUrl);
+ if (state.accounts.some((a) => a.id === id)) {
+ return id; // already exists, return existing id
+ }
+
+ const cookieSlot = state.getNextCookieSlot();
+ const avatarColor = generateAvatarColor(entry.email || entry.username);
+ const isDefault = state.accounts.length === 0; // first account is default
+
+ const account: AccountEntry = {
+ ...entry,
+ id,
+ cookieSlot,
+ avatarColor,
+ isDefault,
+ };
+
+ set((s) => ({
+ accounts: [...s.accounts, account],
+ // If there is no active account, activate this one
+ activeAccountId: s.activeAccountId ?? id,
+ defaultAccountId: isDefault ? id : s.defaultAccountId,
+ }));
+
+ return id;
+ },
+
+ removeAccount: (accountId) => {
+ set((s) => {
+ const remaining = s.accounts.filter((a) => a.id !== accountId);
+ const wasDefault = s.defaultAccountId === accountId;
+ const wasActive = s.activeAccountId === accountId;
+
+ let newDefault = s.defaultAccountId;
+ if (wasDefault) {
+ newDefault = remaining[0]?.id ?? null;
+ // Mark new default
+ if (newDefault) {
+ const idx = remaining.findIndex((a) => a.id === newDefault);
+ if (idx >= 0) {
+ remaining[idx] = { ...remaining[idx], isDefault: true };
+ }
+ }
+ }
+
+ return {
+ accounts: remaining,
+ activeAccountId: wasActive ? (remaining[0]?.id ?? null) : s.activeAccountId,
+ defaultAccountId: newDefault,
+ };
+ });
+ },
+
+ setActiveAccount: (accountId) => {
+ const account = get().accounts.find((a) => a.id === accountId);
+ if (!account) return;
+ set({ activeAccountId: accountId });
+ },
+
+ setDefaultAccount: (accountId) => {
+ const account = get().accounts.find((a) => a.id === accountId);
+ if (!account) return;
+ set((s) => ({
+ defaultAccountId: accountId,
+ accounts: s.accounts.map((a) => ({
+ ...a,
+ isDefault: a.id === accountId,
+ })),
+ }));
+ },
+
+ getDefaultAccount: () => {
+ const state = get();
+ if (state.defaultAccountId) {
+ const account = state.accounts.find((a) => a.id === state.defaultAccountId);
+ if (account) return account;
+ }
+ return state.accounts[0] ?? null;
+ },
+
+ updateAccount: (accountId, updates) => {
+ set((s) => ({
+ accounts: s.accounts.map((a) =>
+ a.id === accountId ? { ...a, ...updates } : a
+ ),
+ }));
+ },
+
+ getActiveAccount: () => {
+ const state = get();
+ return state.accounts.find((a) => a.id === state.activeAccountId) ?? null;
+ },
+
+ getAccountById: (accountId) => {
+ return get().accounts.find((a) => a.id === accountId);
+ },
+
+ getNextCookieSlot: () => {
+ const used = new Set(get().accounts.map((a) => a.cookieSlot));
+ for (let i = 0; i < MAX_ACCOUNTS; i++) {
+ if (!used.has(i)) return i;
+ }
+ return 0; // fallback, shouldn't happen if max is enforced
+ },
+
+ hasAccount: (username, serverUrl) => {
+ const id = generateAccountId(username, serverUrl);
+ return get().accounts.some((a) => a.id === id);
+ },
+ }),
+ {
+ name: 'account-registry',
+ partialize: (state) => ({
+ accounts: state.accounts,
+ activeAccountId: state.activeAccountId,
+ defaultAccountId: state.defaultAccountId,
+ }),
+ }
+ )
+);
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index ec37bdfd..d9ba5702 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -1,15 +1,17 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
-import { useEmailStore } from './email-store';
import { useIdentityStore } from './identity-store';
import { useContactStore } from './contact-store';
import { useVacationStore } from './vacation-store';
import { useCalendarStore } from './calendar-store';
import { useFilterStore } from './filter-store';
import { useSettingsStore } from './settings-store';
+import { useAccountStore } from './account-store';
import { fetchConfig } from '@/hooks/use-config';
import { debug } from '@/lib/debug';
+import { generateAccountId } from '@/lib/account-utils';
+import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
import type { Identity } from '@/lib/jmap/types';
interface AuthState {
@@ -26,14 +28,18 @@ interface AuthState {
accessToken: string | null;
tokenExpiresAt: number | null;
connectionLost: boolean;
+ activeAccountId: string | null;
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise;
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise;
refreshAccessToken: () => Promise;
logout: () => void;
+ logoutAll: () => void;
+ switchAccount: (accountId: string) => Promise;
checkAuth: () => Promise;
clearError: () => void;
syncIdentities: () => void;
+ getClientForAccount: (accountId: string) => JMAPClient | undefined;
}
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
@@ -130,22 +136,55 @@ function initializeFeatureStores(client: JMAPClient): void {
let refreshTimer: ReturnType | null = null;
let refreshPromise: Promise | null = null;
-function scheduleRefresh(expiresIn: number, refreshFn: () => Promise): void {
- if (refreshTimer) clearTimeout(refreshTimer);
- const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
- refreshTimer = setTimeout(() => {
- refreshFn().catch((err) => {
- debug.error('Scheduled token refresh failed:', err);
- });
- }, refreshAt);
+// Multi-account state: per-account JMAP clients and refresh timers
+const clients = new Map();
+const refreshTimers = new Map>();
+const refreshPromises = new Map>();
+
+function scheduleRefresh(expiresIn: number, refreshFn: () => Promise, accountId?: string): void {
+ if (accountId) {
+ const existing = refreshTimers.get(accountId);
+ if (existing) clearTimeout(existing);
+ const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
+ refreshTimers.set(accountId, setTimeout(() => {
+ refreshFn().catch((err) => {
+ debug.error(`Scheduled token refresh failed for ${accountId}:`, err);
+ });
+ }, refreshAt));
+ } else {
+ if (refreshTimer) clearTimeout(refreshTimer);
+ const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
+ refreshTimer = setTimeout(() => {
+ refreshFn().catch((err) => {
+ debug.error('Scheduled token refresh failed:', err);
+ });
+ }, refreshAt);
+ }
}
-function clearRefreshTimer(): void {
- if (refreshTimer) {
- clearTimeout(refreshTimer);
- refreshTimer = null;
+function clearRefreshTimer(accountId?: string): void {
+ if (accountId) {
+ const timer = refreshTimers.get(accountId);
+ if (timer) {
+ clearTimeout(timer);
+ refreshTimers.delete(accountId);
+ }
+ refreshPromises.delete(accountId);
+ } else {
+ if (refreshTimer) {
+ clearTimeout(refreshTimer);
+ refreshTimer = null;
+ }
+ refreshPromise = null;
}
+}
+
+function clearAllRefreshTimers(): void {
+ if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; }
refreshPromise = null;
+ for (const timer of refreshTimers.values()) clearTimeout(timer);
+ refreshTimers.clear();
+ refreshPromises.clear();
}
export const useAuthStore = create()(
@@ -164,6 +203,7 @@ export const useAuthStore = create()(
accessToken: null,
tokenExpiresAt: null,
connectionLost: false,
+ activeAccountId: null,
login: async (serverUrl, username, password, totp, rememberMe) => {
const effectivePassword = totp ? `${password}$${totp}` : password;
@@ -179,6 +219,37 @@ export const useAuthStore = create()(
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
+ // Register in account store
+ const accountStore = useAccountStore.getState();
+ const accountId = generateAccountId(username, serverUrl);
+ const cookieSlot = accountStore.hasAccount(username, serverUrl)
+ ? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
+ : accountStore.getNextCookieSlot();
+
+ // Snapshot current account if switching away
+ const prevAccountId = get().activeAccountId;
+ if (prevAccountId && prevAccountId !== accountId) {
+ snapshotAccount(prevAccountId);
+ }
+
+ // Store client in multi-account map
+ clients.set(accountId, client);
+
+ accountStore.addAccount({
+ label: primaryIdentity?.name || username,
+ serverUrl,
+ username,
+ authMode: 'basic',
+ rememberMe: !!rememberMe,
+ displayName: primaryIdentity?.name || username,
+ email: primaryIdentity?.email || username,
+ lastLoginAt: Date.now(),
+ isConnected: true,
+ hasError: false,
+ isDefault: accountStore.accounts.length === 0,
+ });
+ accountStore.setActiveAccount(accountId);
+
set({
isAuthenticated: true,
isLoading: false,
@@ -192,6 +263,7 @@ export const useAuthStore = create()(
tokenExpiresAt: null,
connectionLost: false,
error: null,
+ activeAccountId: accountId,
});
// Sync settings from server (only if enabled)
@@ -204,10 +276,10 @@ export const useAuthStore = create()(
if (rememberMe) {
try {
- const res = await fetch('/api/auth/session', {
+ const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ serverUrl, username, password: effectivePassword }),
+ body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
});
if (res.ok) {
set({ rememberMe: true });
@@ -236,10 +308,17 @@ export const useAuthStore = create()(
set({ isLoading: true, error: null });
try {
- const tokenRes = await fetch('/api/auth/token', {
+ // Determine slot for this account (use slot from sessionStorage if re-adding)
+ const accountStore = useAccountStore.getState();
+ const pendingSlot = typeof window !== 'undefined'
+ ? parseInt(sessionStorage.getItem('oauth_cookie_slot') || '0', 10)
+ : 0;
+ const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot();
+
+ const tokenRes = await fetch(`/api/auth/token?slot=${slot}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri }),
+ body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri, slot }),
});
if (!tokenRes.ok) {
@@ -259,6 +338,32 @@ export const useAuthStore = create()(
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
+ // Register in account store
+ const accountId = generateAccountId(username, serverUrl);
+
+ // Snapshot current account if switching away
+ const prevAccountId = get().activeAccountId;
+ if (prevAccountId && prevAccountId !== accountId) {
+ snapshotAccount(prevAccountId);
+ }
+
+ clients.set(accountId, client);
+
+ accountStore.addAccount({
+ label: primaryIdentity?.name || username,
+ serverUrl,
+ username,
+ authMode: 'oauth',
+ rememberMe: true,
+ displayName: primaryIdentity?.name || username,
+ email: primaryIdentity?.email || username,
+ lastLoginAt: Date.now(),
+ isConnected: true,
+ hasError: false,
+ isDefault: accountStore.accounts.length === 0,
+ });
+ accountStore.setActiveAccount(accountId);
+
set({
isAuthenticated: true,
isLoading: false,
@@ -272,9 +377,10 @@ export const useAuthStore = create()(
tokenExpiresAt: Date.now() + expires_in * 1000,
connectionLost: false,
error: null,
+ activeAccountId: accountId,
});
- scheduleRefresh(expires_in, get().refreshAccessToken);
+ scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
// Sync settings from server (only if enabled)
fetchConfig().then(config => {
@@ -284,6 +390,11 @@ export const useAuthStore = create()(
});
}).catch(() => {});
+ // Clean up sessionStorage
+ if (typeof window !== 'undefined') {
+ sessionStorage.removeItem('oauth_cookie_slot');
+ }
+
return true;
} catch (error) {
debug.error('OAuth login error:', error);
@@ -300,9 +411,17 @@ export const useAuthStore = create()(
refreshAccessToken: async () => {
if (refreshPromise) return refreshPromise;
- refreshPromise = (async () => {
+ const accountId = get().activeAccountId;
+ if (accountId && refreshPromises.has(accountId)) {
+ return refreshPromises.get(accountId)!;
+ }
+
+ const account = accountId ? useAccountStore.getState().getAccountById(accountId) : null;
+ const slot = account?.cookieSlot ?? 0;
+
+ const promise = (async () => {
try {
- const res = await fetch('/api/auth/token', { method: 'PUT' });
+ const res = await fetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' });
if (!res.ok) {
markSessionExpired();
@@ -319,7 +438,7 @@ export const useAuthStore = create()(
tokenExpiresAt: Date.now() + expires_in * 1000,
});
- scheduleRefresh(expires_in, get().refreshAccessToken);
+ scheduleRefresh(expires_in, get().refreshAccessToken, accountId ?? undefined);
return access_token;
} catch (error) {
debug.error('Token refresh failed:', error);
@@ -328,21 +447,136 @@ export const useAuthStore = create()(
return null;
} finally {
refreshPromise = null;
+ if (accountId) refreshPromises.delete(accountId);
}
})();
- return refreshPromise;
+ refreshPromise = promise;
+ if (accountId) refreshPromises.set(accountId, promise);
+
+ return promise;
},
logout: () => {
const state = get();
const wasOAuth = state.authMode === 'oauth';
+ const accountId = state.activeAccountId;
+ const accountStore = useAccountStore.getState();
+ const account = accountId ? accountStore.getAccountById(accountId) : null;
+ const slot = account?.cookieSlot ?? 0;
- clearRefreshTimer();
+ clearRefreshTimer(accountId ?? undefined);
state.client?.disconnect();
+ // Remove client from multi-account map
+ if (accountId) {
+ clients.delete(accountId);
+ evictAccount(accountId);
+ accountStore.removeAccount(accountId);
+ }
+
useSettingsStore.getState().disableSync();
+ // Check if there are remaining accounts to switch to
+ const remainingAccounts = accountStore.accounts;
+ if (remainingAccounts.length > 0) {
+ // Switch to the next account
+ const nextAccount = remainingAccounts[0];
+ // Clean current stores, then switch
+ clearAllStores();
+
+ // Restore next account
+ const nextClient = clients.get(nextAccount.id);
+ if (nextClient) {
+ const restored = restoreAccount(nextAccount.id);
+ accountStore.setActiveAccount(nextAccount.id);
+
+ set({
+ isAuthenticated: true,
+ isLoading: false,
+ serverUrl: nextAccount.serverUrl,
+ username: nextAccount.username,
+ client: nextClient,
+ authMode: nextAccount.authMode,
+ connectionLost: false,
+ error: null,
+ activeAccountId: nextAccount.id,
+ });
+
+ if (!restored) {
+ initializeFeatureStores(nextClient);
+ nextClient.getIdentities().then((rawIds) => {
+ const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username);
+ set({ identities, primaryIdentity });
+ }).catch((err) => debug.error('Failed to load identities after switch:', err));
+ } else {
+ const identityState = useIdentityStore.getState();
+ set({
+ identities: identityState.identities,
+ primaryIdentity: identityState.identities[0] ?? null,
+ });
+ }
+ }
+ } else {
+ // No accounts remaining — full logout
+ set({
+ isAuthenticated: false,
+ serverUrl: null,
+ username: null,
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ authMode: 'basic',
+ rememberMe: false,
+ accessToken: null,
+ tokenExpiresAt: null,
+ connectionLost: false,
+ error: null,
+ activeAccountId: null,
+ });
+
+ localStorage.removeItem('auth-storage');
+ clearAllStores();
+ }
+
+ // Clean up cookies for the removed account
+ fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE' }).catch((err) => {
+ debug.error('Failed to clear session cookie:', err);
+ });
+
+ if (wasOAuth) {
+ fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE' })
+ .then((res) => {
+ if (!res.ok) throw new Error(`Revocation failed: ${res.status}`);
+ return res.json();
+ })
+ .then((data) => {
+ if (data.end_session_url && remainingAccounts.length === 0) {
+ const locale = window.location.pathname.split('/')[1] || 'en';
+ const redirectUri = `${window.location.origin}/${locale}/login`;
+ const url = new URL(data.end_session_url);
+ url.searchParams.set('post_logout_redirect_uri', redirectUri);
+ window.location.href = url.toString();
+ }
+ })
+ .catch((err) => {
+ debug.error('OAuth logout cleanup failed:', err);
+ });
+ }
+ },
+
+ logoutAll: () => {
+ // Disconnect all clients
+ for (const client of clients.values()) {
+ client.disconnect();
+ }
+ clients.clear();
+ clearAllRefreshTimers();
+ evictAll();
+
+ useSettingsStore.getState().disableSync();
+ useAccountStore.getState().accounts.forEach(() => {});
+
set({
isAuthenticated: false,
serverUrl: null,
@@ -356,55 +590,283 @@ export const useAuthStore = create()(
tokenExpiresAt: null,
connectionLost: false,
error: null,
+ activeAccountId: null,
});
localStorage.removeItem('auth-storage');
+ clearAllStores();
- useEmailStore.setState({
- emails: [],
- mailboxes: [],
- selectedEmail: null,
- selectedMailbox: "",
- isLoading: false,
- error: null,
- searchQuery: "",
- quota: null,
- });
-
- useIdentityStore.getState().clearIdentities();
- useContactStore.getState().clearContacts();
- useVacationStore.getState().clearState();
- useCalendarStore.getState().clearState();
- useFilterStore.getState().clearState();
-
- fetch('/api/auth/session', { method: 'DELETE' }).catch((err) => {
- debug.error('Failed to clear session cookie:', err);
- });
-
- if (wasOAuth) {
- fetch('/api/auth/token', { method: 'DELETE' })
- .then((res) => {
- if (!res.ok) throw new Error(`Revocation failed: ${res.status}`);
- return res.json();
- })
- .then((data) => {
- if (data.end_session_url) {
- const locale = window.location.pathname.split('/')[1] || 'en';
- const redirectUri = `${window.location.origin}/${locale}/login`;
- const url = new URL(data.end_session_url);
- url.searchParams.set('post_logout_redirect_uri', redirectUri);
- window.location.href = url.toString();
- }
- })
- .catch((err) => {
- debug.error('OAuth logout cleanup failed:', err);
- });
+ // Clear all accounts from registry
+ const accountStore = useAccountStore.getState();
+ const allAccounts = [...accountStore.accounts];
+ for (const account of allAccounts) {
+ accountStore.removeAccount(account.id);
}
+
+ // Delete all cookies
+ fetch('/api/auth/session?all=true', { method: 'DELETE' }).catch(() => {});
+ fetch('/api/auth/token?all=true', { method: 'DELETE' }).catch(() => {});
+ },
+
+ switchAccount: async (accountId: string) => {
+ const state = get();
+ if (state.activeAccountId === accountId) return;
+
+ const accountStore = useAccountStore.getState();
+ const targetAccount = accountStore.getAccountById(accountId);
+ if (!targetAccount) return;
+
+ set({ isLoading: true });
+
+ // Snapshot current account
+ if (state.activeAccountId) {
+ snapshotAccount(state.activeAccountId);
+ }
+
+ // Clear current stores
+ clearAllStores();
+ useSettingsStore.getState().disableSync();
+
+ // Get or create client for target account
+ let targetClient = clients.get(accountId);
+
+ if (!targetClient) {
+ // Client not connected — try to restore
+ try {
+ if (targetAccount.authMode === 'oauth') {
+ const res = await fetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
+ if (res.ok) {
+ const { access_token, expires_in } = await res.json();
+ const refreshFn = get().refreshAccessToken;
+ targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => refreshFn());
+ targetClient.onConnectionChange((connected) => {
+ if (get().activeAccountId === accountId) {
+ set({ connectionLost: !connected });
+ }
+ accountStore.updateAccount(accountId, { isConnected: connected });
+ });
+ await targetClient.connect();
+ clients.set(accountId, targetClient);
+ scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
+ }
+ } else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
+ const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`);
+ if (res.ok) {
+ const { serverUrl, username, password } = await res.json();
+ targetClient = new JMAPClient(serverUrl, username, password);
+ targetClient.onConnectionChange((connected) => {
+ if (get().activeAccountId === accountId) {
+ set({ connectionLost: !connected });
+ }
+ accountStore.updateAccount(accountId, { isConnected: connected });
+ });
+ await targetClient.connect();
+ clients.set(accountId, targetClient);
+ }
+ }
+ } catch (err) {
+ debug.error(`Failed to restore client for ${accountId}:`, err);
+ accountStore.updateAccount(accountId, {
+ isConnected: false,
+ hasError: true,
+ errorMessage: err instanceof Error ? err.message : 'Connection failed',
+ });
+ set({ isLoading: false });
+ return;
+ }
+ }
+
+ if (!targetClient) {
+ set({ isLoading: false });
+ return;
+ }
+
+ // Restore cached state or fetch fresh
+ const restored = restoreAccount(accountId);
+ accountStore.setActiveAccount(accountId);
+ accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
+
+ set({
+ isAuthenticated: true,
+ isLoading: false,
+ serverUrl: targetAccount.serverUrl,
+ username: targetAccount.username,
+ client: targetClient,
+ authMode: targetAccount.authMode,
+ connectionLost: false,
+ error: null,
+ activeAccountId: accountId,
+ });
+
+ if (!restored) {
+ // Fetch fresh data
+ try {
+ const { identities, primaryIdentity } = loadIdentities(await targetClient.getIdentities(), targetAccount.username);
+ set({ identities, primaryIdentity });
+ initializeFeatureStores(targetClient);
+ } catch (err) {
+ debug.error(`Failed to load data for ${accountId}:`, err);
+ }
+ } else {
+ const identityState = useIdentityStore.getState();
+ set({
+ identities: identityState.identities,
+ primaryIdentity: identityState.identities[0] ?? null,
+ });
+ }
+
+ // Sync settings
+ fetchConfig().then(config => {
+ if (!config.settingsSyncEnabled) return;
+ useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
+ useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
+ });
+ }).catch(() => {});
},
checkAuth: async () => {
- const state = get();
+ const accountStore = useAccountStore.getState();
+ const accounts = accountStore.accounts;
+ // Multi-account restoration: restore all registered accounts
+ if (accounts.length > 0) {
+ set({ isLoading: true });
+
+ // Determine which account to activate first
+ const defaultAccount = accountStore.getDefaultAccount();
+ const activeId = get().activeAccountId;
+ const targetId = activeId || defaultAccount?.id || accounts[0].id;
+
+ // Try to connect all accounts
+ for (const account of accounts) {
+ if (clients.has(account.id)) continue; // Already connected
+
+ try {
+ if (account.authMode === 'oauth') {
+ const res = await fetch(`/api/auth/token?slot=${account.cookieSlot}`, { method: 'PUT' });
+ if (res.ok) {
+ const { access_token, expires_in } = await res.json();
+ const refreshFn = get().refreshAccessToken;
+ const client = JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => refreshFn());
+ client.onConnectionChange((connected) => {
+ if (get().activeAccountId === account.id) {
+ set({ connectionLost: !connected });
+ }
+ accountStore.updateAccount(account.id, { isConnected: connected });
+ });
+ await client.connect();
+ clients.set(account.id, client);
+ scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
+ accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
+ } else {
+ throw new Error(`Token refresh failed: ${res.status}`);
+ }
+ } else if (account.authMode === 'basic' && account.rememberMe) {
+ const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`);
+ if (res.ok) {
+ const { serverUrl, username, password } = await res.json();
+ const client = new JMAPClient(serverUrl, username, password);
+ client.onConnectionChange((connected) => {
+ if (get().activeAccountId === account.id) {
+ set({ connectionLost: !connected });
+ }
+ accountStore.updateAccount(account.id, { isConnected: connected });
+ });
+ await client.connect();
+ clients.set(account.id, client);
+ accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
+ } else {
+ throw new Error(`Session cookie missing: ${res.status}`);
+ }
+ } else {
+ // Basic auth without rememberMe — can't restore
+ throw new Error('No saved session');
+ }
+ } catch (err) {
+ debug.error(`Failed to restore account ${account.id}:`, err);
+ accountStore.updateAccount(account.id, {
+ isConnected: false,
+ hasError: true,
+ errorMessage: err instanceof Error ? err.message : 'Restore failed',
+ });
+ }
+ }
+
+ // Activate the target account
+ const targetClient = clients.get(targetId);
+ const targetAccount = accountStore.getAccountById(targetId);
+ if (targetClient && targetAccount) {
+ accountStore.setActiveAccount(targetId);
+ const { identities, primaryIdentity } = loadIdentities(await targetClient.getIdentities(), targetAccount.username);
+ initializeFeatureStores(targetClient);
+
+ set({
+ isAuthenticated: true,
+ isLoading: false,
+ serverUrl: targetAccount.serverUrl,
+ username: targetAccount.username,
+ client: targetClient,
+ identities,
+ primaryIdentity,
+ authMode: targetAccount.authMode,
+ connectionLost: false,
+ error: null,
+ activeAccountId: targetId,
+ });
+
+ fetchConfig().then(config => {
+ if (!config.settingsSyncEnabled) return;
+ useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
+ useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
+ });
+ }).catch(() => {});
+ return;
+ }
+
+ // If target didn't connect, try any connected account
+ for (const [id, client] of clients.entries()) {
+ const acc = accountStore.getAccountById(id);
+ if (acc) {
+ accountStore.setActiveAccount(id);
+ const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), acc.username);
+ initializeFeatureStores(client);
+
+ set({
+ isAuthenticated: true,
+ isLoading: false,
+ serverUrl: acc.serverUrl,
+ username: acc.username,
+ client,
+ identities,
+ primaryIdentity,
+ authMode: acc.authMode,
+ connectionLost: false,
+ error: null,
+ activeAccountId: id,
+ });
+ return;
+ }
+ }
+
+ // No accounts could be restored
+ markSessionExpired();
+ set({
+ isAuthenticated: false,
+ isLoading: false,
+ client: null,
+ serverUrl: null,
+ username: null,
+ authMode: 'basic',
+ rememberMe: false,
+ accessToken: null,
+ tokenExpiresAt: null,
+ activeAccountId: null,
+ });
+ return;
+ }
+
+ // Legacy single-account fallback (for accounts not yet in registry)
+ const state = get();
if (state.isAuthenticated && !state.client) {
if (state.authMode === 'oauth' && state.serverUrl) {
set({ isLoading: true });
@@ -418,6 +880,25 @@ export const useAuthStore = create()(
});
await client.connect();
+ const accountId = generateAccountId(state.username || '', state.serverUrl);
+ clients.set(accountId, client);
+
+ // Migrate to account registry
+ accountStore.addAccount({
+ label: state.username || '',
+ serverUrl: state.serverUrl,
+ username: state.username || '',
+ authMode: 'oauth',
+ rememberMe: true,
+ displayName: state.username || '',
+ email: state.username || '',
+ lastLoginAt: Date.now(),
+ isConnected: true,
+ hasError: false,
+ isDefault: accountStore.accounts.length === 0,
+ });
+ accountStore.setActiveAccount(accountId);
+
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || '');
initializeFeatureStores(client);
@@ -428,9 +909,9 @@ export const useAuthStore = create()(
identities,
primaryIdentity,
accessToken: token,
+ activeAccountId: accountId,
});
- // Sync settings from server (only if enabled)
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => {
@@ -462,6 +943,25 @@ export const useAuthStore = create()(
});
await client.connect();
+ const accountId = generateAccountId(username, serverUrl);
+ clients.set(accountId, client);
+
+ // Migrate to account registry
+ accountStore.addAccount({
+ label: username,
+ serverUrl,
+ username,
+ authMode: 'basic',
+ rememberMe: state.rememberMe,
+ displayName: username,
+ email: username,
+ lastLoginAt: Date.now(),
+ isConnected: true,
+ hasError: false,
+ isDefault: accountStore.accounts.length === 0,
+ });
+ accountStore.setActiveAccount(accountId);
+
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
@@ -474,9 +974,9 @@ export const useAuthStore = create()(
identities,
primaryIdentity,
authMode: 'basic',
+ activeAccountId: accountId,
});
- // Sync settings from server (only if enabled)
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
@@ -502,6 +1002,7 @@ export const useAuthStore = create()(
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
+ activeAccountId: null,
});
}
@@ -516,6 +1017,10 @@ export const useAuthStore = create()(
const primaryIdentity = identities[0] ?? null;
set({ identities, primaryIdentity });
},
+
+ getClientForAccount: (accountId: string) => {
+ return clients.get(accountId);
+ },
}),
{
name: 'auth-storage',
@@ -527,6 +1032,7 @@ export const useAuthStore = create()(
? state.isAuthenticated
: undefined,
rememberMe: state.rememberMe,
+ activeAccountId: state.activeAccountId,
}),
}
)