mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
3322 lines
142 KiB
TypeScript
3322 lines
142 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import { JMAPClient, RateLimitError, RequestTimeoutError } from '@/lib/jmap/client';
|
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|
import { useIdentityStore } from './identity-store';
|
|
import { setClientLookup } from './client-registry';
|
|
import { useContactStore } from './contact-store';
|
|
import { useVacationStore } from './vacation-store';
|
|
import { subscriptionOwner, useCalendarStore } from './calendar-store';
|
|
import { useFilterStore } from './filter-store';
|
|
import { useSettingsStore } from './settings-store';
|
|
import { useAccountStore, type AccountEntry } from './account-store';
|
|
import { fetchPrincipalDisplayName } from '@/lib/stalwart/principal';
|
|
import { fetchConfig, getCachedConfig } from '@/hooks/use-config';
|
|
import { debug } from '@/lib/debug';
|
|
import { setServerAuthIssue } from '@/lib/server-auth-status';
|
|
import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
|
import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation';
|
|
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
|
|
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
|
|
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
|
|
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup';
|
|
import { forgetPrincipals } from '@/lib/delegation/known-principals';
|
|
import { useSearchHistoryStore } from './search-history-store';
|
|
import { useCalendarNotificationStore } from './calendar-notification-store';
|
|
import { usePolicyStore } from './policy-store';
|
|
import { policySubjectForClient } from '@/lib/admin/policy-subject';
|
|
import { clearSessionEnd, rememberSessionEnd } from '@/lib/session-policy/sign-out-reason';
|
|
import { SESSION_TOO_OLD } from '@/lib/session-policy/constants';
|
|
import type { Identity } from '@/lib/jmap/types';
|
|
import { authHooks } from '@/lib/plugin-hooks';
|
|
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
|
|
import { toAsciiEmail } from '@/lib/idn';
|
|
import { flushComposers } from '@/lib/compose-flush-registry';
|
|
import { sortIdentities } from '@/lib/identity-order';
|
|
import {
|
|
LiteLoginError,
|
|
clearLiteRefreshToken,
|
|
getLiteClientId,
|
|
liteExchangeAuthorizationCode,
|
|
liteRefreshTokens,
|
|
liteTokenLogin,
|
|
nameLiteRefreshToken,
|
|
readLiteAccessToken,
|
|
readLiteBasicSession,
|
|
revokeAllLiteSessions,
|
|
revokeLiteSlot,
|
|
saveLiteAccessToken,
|
|
saveLiteBasicSession,
|
|
saveLiteRefreshToken,
|
|
} from '@/lib/auth/lite-tokens';
|
|
import { clearLiteOAuthFlow, readLiteOAuthFlow } from '@/lib/auth/lite-oauth';
|
|
|
|
/** Logins restored at once after the one on screen: enough to overlap round trips, few enough not to crowd the server. */
|
|
const RESTORE_CONCURRENCY = 4;
|
|
|
|
interface AuthState {
|
|
isAuthenticated: boolean;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
isRateLimited: boolean;
|
|
rateLimitUntil: number | null;
|
|
serverUrl: string | null;
|
|
username: string | null;
|
|
client: IJMAPClient | null;
|
|
identities: Identity[];
|
|
primaryIdentity: Identity | null;
|
|
authMode: 'basic' | 'oauth' | 'token';
|
|
rememberMe: boolean;
|
|
accessToken: string | null;
|
|
tokenExpiresAt: number | null;
|
|
connectionLost: boolean;
|
|
activeAccountId: string | null;
|
|
isDemoMode: boolean;
|
|
/**
|
|
* Bumped when background account restoration finishes connecting clients
|
|
* after the UI already unblocked, so effects that bind per-client handlers
|
|
* (push notifications) re-run over the now-complete client set.
|
|
*/
|
|
connectedAccountsRevision: number;
|
|
/**
|
|
* True while the logins other than the one on screen are still being
|
|
* restored after a load, so views that span every login (the unified
|
|
* mailbox) can say they are not complete yet.
|
|
*/
|
|
restoringAccounts: boolean;
|
|
|
|
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
|
/**
|
|
* Sign in with an access token instead of a password (Bearer auth), for
|
|
* servers that hand out API tokens, such as Fastmail. The token names no
|
|
* account: the JMAP session says whose it is. It is never renewed; once
|
|
* the server stops accepting it, the user signs in again.
|
|
*/
|
|
loginWithToken: (serverUrl: string, token: string, rememberMe?: boolean) => Promise<boolean>;
|
|
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string, serverId?: string) => Promise<boolean>;
|
|
loginWithServerSso: (code: string, state: string) => Promise<boolean>;
|
|
loginDemo: () => Promise<boolean>;
|
|
/**
|
|
* Obtain a usable access token for `accountId`, or the active account.
|
|
*
|
|
* Renews against the IdP by default. `allowCached` lets a session restore
|
|
* reuse the token the server still holds for this slot - IdPs that gate
|
|
* refresh tokens behind an `nbf` claim reject an early renewal outright.
|
|
*/
|
|
refreshAccessToken: (options?: { allowCached?: boolean; accountId?: string }) => Promise<string | null>;
|
|
/**
|
|
* Sign out of the active account. Switches to the next signed-in account
|
|
* when there is one; otherwise ends the identity provider's session too
|
|
* (see {@link LogoutOptions}) and leaves the app.
|
|
*/
|
|
logout: (options?: LogoutOptions) => Promise<void>;
|
|
/**
|
|
* Sign out of every account. Ends at most one identity provider session:
|
|
* see {@link pickEndSessionSlot}; none with `endProviderSession: false`
|
|
* (the organisation's session limits, which sign out without asking).
|
|
*/
|
|
logoutAll: (options?: LogoutOptions) => Promise<void>;
|
|
removeAccount: (accountId: string) => void;
|
|
switchAccount: (accountId: string) => Promise<void>;
|
|
checkAuth: () => Promise<void>;
|
|
clearError: () => void;
|
|
syncIdentities: () => void;
|
|
/**
|
|
* Keep a Basic-auth session working after the user changed the password:
|
|
* the client, the server-side management context and the remembered
|
|
* session still hold the old one, which the server now refuses.
|
|
*/
|
|
updateBasicPassword: (newPassword: string) => Promise<void>;
|
|
/**
|
|
* Sign in, in the background, to a Basic account restored from the account
|
|
* list backup with its password. The active account stays as it is.
|
|
* Resolves false when the server refuses the password or cannot be reached.
|
|
*/
|
|
signInRestoredAccount: (accountId: string, password: string, rememberMe: boolean) => Promise<boolean>;
|
|
refreshIdentities: () => Promise<void>;
|
|
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
|
getAllConnectedClients: () => Map<string, JMAPClient>;
|
|
}
|
|
|
|
export interface LogoutOptions {
|
|
/**
|
|
* Also end the identity provider's session when the account signed in
|
|
* through it (#905). Default true. False when the session ended on its own
|
|
* (rejected or unrenewable token): the user did not ask to leave the
|
|
* provider, and signing in again should stay a single click.
|
|
*/
|
|
endProviderSession?: boolean;
|
|
}
|
|
|
|
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
|
{ key: 'cors_blocked', matches: ['CORS_ERROR'] },
|
|
{ key: 'totp_required', matches: ['TOTP_REQUIRED'] },
|
|
{ key: 'token_exchange_failed', matches: ['TOKEN_EXCHANGE_FAILED'] },
|
|
{ key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] },
|
|
{ key: 'connection_failed', matches: ['network', 'Failed to fetch', 'NetworkError', 'ECONNREFUSED', 'Load failed', 'cancelled'] },
|
|
{ key: 'server_error', matches: ['500', '502', '503', '504', 'Internal Server Error', 'Service Unavailable'] },
|
|
];
|
|
|
|
function classifyLoginError(error: unknown): string {
|
|
if (!(error instanceof Error)) return 'generic';
|
|
const msg = error.message;
|
|
for (const { key, matches } of ERROR_PATTERNS) {
|
|
if (matches.some((pattern) => msg.includes(pattern))) return key;
|
|
}
|
|
return 'generic';
|
|
}
|
|
|
|
function isRateLimitError(error: unknown): error is RateLimitError {
|
|
return error instanceof RateLimitError;
|
|
}
|
|
|
|
/**
|
|
* Ask our own backend to try the credentials before the browser does (#969):
|
|
* a password, or the access token of a token login. A wrong password answered
|
|
* straight from the JMAP server arrives as
|
|
* 401 + `WWW-Authenticate: Basic`, which makes the browser open its native
|
|
* login dialog on top of our form when the JMAP server shares our origin
|
|
* (reverse-proxied under the same host). Rejecting wrong credentials via a
|
|
* JSON reply from our origin sidesteps that. Only a definitive
|
|
* `unauthorized` short-circuits; anything else (route missing, backend can't
|
|
* reach the JMAP server, TOTP challenge, ...) falls through to the regular
|
|
* browser-side connect so no deployment loses the ability to log in.
|
|
*/
|
|
async function precheckCredentials(
|
|
serverUrl: string,
|
|
credentials: { username: string; password: string } | { token: string },
|
|
): Promise<boolean> {
|
|
// App-relative servers (the dev mock) never send a Basic challenge, and the
|
|
// static Lite build has no backend to ask.
|
|
if (IS_LITE || serverUrl.startsWith('/')) return false;
|
|
try {
|
|
const res = await apiFetch('/api/auth/verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ serverUrl, ...credentials }),
|
|
});
|
|
if (!res.ok) return false;
|
|
const body = await res.json().catch(() => null);
|
|
return body?.result === 'unauthorized';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// An auth/session endpoint answered with a server-side error (5xx) - an
|
|
// outage, not a rejection of our credentials.
|
|
class TransientAuthError extends Error {
|
|
constructor(message: string, readonly status: number) {
|
|
super(`${message}: ${status}`);
|
|
}
|
|
}
|
|
|
|
// True when a restore/refresh attempt failed because the server could not be
|
|
// reached (network error) or answered 5xx (restart, maintenance, proxy
|
|
// hiccup). Such failures must keep the account and its cookies - "stay signed
|
|
// in" has to survive downtime and offline spells. Only a definitive rejection
|
|
// (401/400) may evict. Mirrors the rate-limit carve-out (#104).
|
|
function isTransientAuthError(error: unknown): boolean {
|
|
if (error instanceof TransientAuthError) return true;
|
|
// fetch() rejects with TypeError when the network is unreachable.
|
|
if (error instanceof TypeError) return true;
|
|
// A request that timed out, or a fetch that failed while the no-cors probe
|
|
// got through (connect() reports that as CORS_ERROR, which is also what a
|
|
// load balancer switching origins produces), is an outage too. (#1167)
|
|
if (error instanceof RequestTimeoutError) return true;
|
|
if (error instanceof Error && error.message === 'CORS_ERROR') return true;
|
|
// JMAPClient.connect()/refreshSession() embed the HTTP status in the
|
|
// message - a 5xx there is the server being down, not an auth failure.
|
|
if (error instanceof Error) {
|
|
const m = error.message.match(/(?:Failed to get session|Session refresh failed): (\d{3})/);
|
|
if (m) return m[1].startsWith('5');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getClientRateLimitState(client: IJMAPClient | null): Pick<AuthState, 'isRateLimited' | 'rateLimitUntil'> {
|
|
if (!client) {
|
|
return { isRateLimited: false, rateLimitUntil: null };
|
|
}
|
|
|
|
const remainingMs = client.getRateLimitRemainingMs();
|
|
if (remainingMs <= 0) {
|
|
return { isRateLimited: false, rateLimitUntil: null };
|
|
}
|
|
|
|
return {
|
|
isRateLimited: true,
|
|
rateLimitUntil: Date.now() + remainingMs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Refresh the account registry's cached `displayName` from the server (#900).
|
|
*
|
|
* The name is captured once by `addAccount` (a no-op for an existing entry),
|
|
* so without this an account keeps whatever name it had at first login
|
|
* forever. On Stalwart the identity name itself is a one-time snapshot of the
|
|
* principal "Full name", so the live value has to come from the principal;
|
|
* elsewhere the primary identity name is the best available source.
|
|
*
|
|
* Best-effort and never throws: a failed lookup keeps the cached name.
|
|
*/
|
|
export async function syncAccountDisplayName(
|
|
accountId: string,
|
|
client: IJMAPClient,
|
|
fallbackName?: string | null,
|
|
): Promise<void> {
|
|
const account = useAccountStore.getState().getAccountById(accountId);
|
|
if (!account) return;
|
|
|
|
const name = (await fetchPrincipalDisplayName(client, account.cookieSlot))
|
|
|| fallbackName?.trim()
|
|
|| '';
|
|
if (!name) return;
|
|
|
|
// Re-read: the entry may have changed (or been removed) during the request.
|
|
const current = useAccountStore.getState().getAccountById(accountId);
|
|
if (!current || current.displayName === name) return;
|
|
|
|
const updates: Partial<AccountEntry> = { displayName: name };
|
|
// `label` is seeded from the same value and never edited separately, so
|
|
// keep it in step unless it has diverged for some other reason.
|
|
if (!current.label || current.label === current.displayName) {
|
|
updates.label = name;
|
|
}
|
|
useAccountStore.getState().updateAccount(accountId, updates);
|
|
}
|
|
|
|
/*
|
|
* Sign-out ordering (#905).
|
|
*
|
|
* A token renewal or Stalwart context sync still in flight when the user signs
|
|
* out would write its cookies back after sign-out cleared them, reviving the
|
|
* session. Such requests are tracked per slot. Sign-out marks the slot closing
|
|
* (no new ones start), lets the pending ones settle - aborting any that take
|
|
* too long, which drops their response and its cookies - and only then clears
|
|
* the slot's credentials. That cleanup is awaited with a timeout, so its
|
|
* result (the provider's logout URL) is known before the page navigates, and
|
|
* failures are reported instead of dropped.
|
|
*/
|
|
|
|
/** How long sign-out waits for a token renewal already in flight. */
|
|
const SESSION_WRITE_SETTLE_TIMEOUT_MS = 3000;
|
|
/** How long sign-out waits for the server to revoke and clear a slot's credentials. */
|
|
const CREDENTIAL_CLEANUP_TIMEOUT_MS = 8000;
|
|
|
|
interface SessionWrite {
|
|
slot: number;
|
|
settled: Promise<unknown>;
|
|
abort: () => void;
|
|
}
|
|
|
|
const sessionWrites = new Set<SessionWrite>();
|
|
// Slots being signed out: nothing may write their session cookies anymore.
|
|
const closingSlots = new Set<number>();
|
|
|
|
/** Run a request that may write `slot`'s session cookies, so sign-out can wait for it. */
|
|
function trackSessionWrite<T>(slot: number, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
const controller = new AbortController();
|
|
const request = run(controller.signal);
|
|
const entry: SessionWrite = { slot, settled: request.catch(() => {}), abort: () => controller.abort() };
|
|
sessionWrites.add(entry);
|
|
void entry.settled.then(() => sessionWrites.delete(entry));
|
|
return request;
|
|
}
|
|
|
|
const TIMED_OUT = Symbol('timed out');
|
|
|
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<typeof TIMED_OUT>((resolve) => {
|
|
timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
});
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
/** Wait for the session writes of closing `slots` to finish, aborting stragglers. */
|
|
async function settleSessionWrites(slots: ReadonlySet<number>): Promise<void> {
|
|
const pending = [...sessionWrites].filter((write) => slots.has(write.slot));
|
|
if (pending.length === 0) return;
|
|
const result = await withTimeout(Promise.all(pending.map((write) => write.settled)), SESSION_WRITE_SETTLE_TIMEOUT_MS);
|
|
if (result === TIMED_OUT) {
|
|
debug.warn('auth', 'A token renewal was still running at sign-out; cancelling it');
|
|
for (const write of pending) write.abort();
|
|
}
|
|
}
|
|
|
|
async function syncStalwartAuthContext(
|
|
serverUrl: string,
|
|
username: string,
|
|
authHeader: string,
|
|
slot: number,
|
|
): Promise<void> {
|
|
// The passthrough context lives on the server; Lite has none. A slot being
|
|
// signed out must not get its context back.
|
|
if (IS_LITE || closingSlots.has(slot)) return;
|
|
try {
|
|
const response = await trackSessionWrite(slot, (signal) => apiFetch('/api/auth/stalwart-context', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ serverUrl, username, authHeader, slot }),
|
|
signal,
|
|
}));
|
|
|
|
const data = await response.json().catch(() => ({})) as { error?: string; hint?: string; warning?: string };
|
|
if (!response.ok) {
|
|
// Not debug-gated: without the context every server-side helper
|
|
// answers 401, and this is the only place the browser learns why (#1073).
|
|
const reason = data.hint || data.error || `HTTP ${response.status}`;
|
|
setServerAuthIssue(reason);
|
|
console.warn(`[auth] The server could not store the sign-in context (${response.status}): ${reason}`);
|
|
} else {
|
|
setServerAuthIssue(data.warning ?? null);
|
|
if (data.warning) console.warn(`[auth] ${data.warning}`);
|
|
}
|
|
} catch (error) {
|
|
debug.warn('auth', 'Failed to sync Stalwart auth context:', error);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Per-slot credential endpoints.
|
|
*
|
|
* The regular build keeps refresh tokens and remembered passwords in encrypted
|
|
* per-slot cookies behind /api/auth/*. The static Lite build has no server and
|
|
* keeps them in web storage instead (lib/auth/lite-tokens.ts). The Lite
|
|
* variants below answer with the same Response shapes the routes do, so every
|
|
* caller's status handling (401 = rejected, 5xx/network = outage) is shared.
|
|
*/
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
function liteErrorStatus(err: LiteLoginError): number {
|
|
switch (err.code) {
|
|
case 'endpoint_missing':
|
|
return 404;
|
|
case 'totp_required':
|
|
case 'invalid_credentials':
|
|
case 'refresh_rejected':
|
|
return 401;
|
|
default:
|
|
return err.status && err.status >= 500 ? err.status : 502;
|
|
}
|
|
}
|
|
|
|
/** POST /api/auth/totp-token-exchange - password (+ optional TOTP) to bearer tokens for `slot`. */
|
|
async function exchangePasswordForTokens(params: {
|
|
serverUrl: string;
|
|
username: string;
|
|
password: string;
|
|
totp?: string;
|
|
slot: number;
|
|
redirectUri: string;
|
|
rememberMe: boolean;
|
|
}): Promise<Response> {
|
|
const { serverUrl, username, password, totp, slot, redirectUri, rememberMe } = params;
|
|
if (!IS_LITE) {
|
|
return apiFetch('/api/auth/totp-token-exchange', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
// server_id isn't passed - the route looks up the server entry by
|
|
// serverUrl, so per-server OAuth still applies for password+TOTP.
|
|
body: JSON.stringify({ serverUrl, username, password, totp, slot, redirectUri, rememberMe }),
|
|
}).then((res) => noteSessionAnswer(res, slot));
|
|
}
|
|
try {
|
|
const tokens = await liteTokenLogin({ serverUrl, username, password, totp, redirectUri });
|
|
// "Remember me" keeps the refresh token across browser restarts
|
|
// (localStorage); otherwise it lives with the tab (sessionStorage).
|
|
if (tokens.refreshToken) {
|
|
saveLiteRefreshToken(slot, { serverUrl, username, refreshToken: tokens.refreshToken, clientId: tokens.clientId ?? getLiteClientId() }, rememberMe);
|
|
} else {
|
|
clearLiteRefreshToken(slot);
|
|
}
|
|
saveLiteAccessToken(slot, tokens.accessToken, tokens.expiresIn, rememberMe);
|
|
return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn, has_refresh_token: !!tokens.refreshToken });
|
|
} catch (err) {
|
|
if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err));
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** POST /api/auth/token?slot=N - redeem an OAuth authorization code for `slot`. */
|
|
async function exchangeOAuthCode(params: {
|
|
serverUrl: string;
|
|
code: string;
|
|
codeVerifier: string;
|
|
redirectUri: string;
|
|
slot: number;
|
|
serverId?: string;
|
|
remember: boolean;
|
|
}): Promise<Response> {
|
|
const { serverUrl, code, codeVerifier, redirectUri, slot, serverId, remember } = params;
|
|
if (!IS_LITE) {
|
|
return apiFetch(`/api/auth/token?slot=${slot}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
code,
|
|
code_verifier: codeVerifier,
|
|
redirect_uri: redirectUri,
|
|
slot,
|
|
...(serverId ? { server_id: serverId } : {}),
|
|
remember,
|
|
}),
|
|
}).then((res) => noteSessionAnswer(res, slot));
|
|
}
|
|
// The login page parked where and as whom to redeem the code
|
|
// (lib/auth/lite-oauth.ts); without it this is not a flow we started. A
|
|
// code is good for one attempt, so the flow goes either way.
|
|
const flow = readLiteOAuthFlow();
|
|
clearLiteOAuthFlow();
|
|
if (!flow) return jsonResponse({ error: 'missing_flow' }, 400);
|
|
try {
|
|
const tokens = await liteExchangeAuthorizationCode({
|
|
tokenEndpoint: flow.tokenEndpoint,
|
|
code,
|
|
codeVerifier,
|
|
redirectUri: flow.redirectUri,
|
|
clientId: flow.clientId,
|
|
});
|
|
// The username is filled in once the session names the account
|
|
// (nameLiteRefreshToken in loginWithOAuth).
|
|
if (tokens.refreshToken) {
|
|
saveLiteRefreshToken(slot, {
|
|
serverUrl,
|
|
username: '',
|
|
refreshToken: tokens.refreshToken,
|
|
clientId: flow.clientId,
|
|
tokenEndpoint: flow.tokenEndpoint,
|
|
revocationEndpoint: flow.revocationEndpoint,
|
|
}, flow.persistent);
|
|
} else {
|
|
clearLiteRefreshToken(slot);
|
|
}
|
|
saveLiteAccessToken(slot, tokens.accessToken, tokens.expiresIn, flow.persistent);
|
|
return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn });
|
|
} catch (err) {
|
|
if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err));
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Credentials a remembered session signs back in with: the password (Basic
|
|
* auth), or the access token of a token login (Bearer auth).
|
|
*/
|
|
type RememberedSession =
|
|
| { serverUrl: string; username: string; password: string; token?: undefined }
|
|
| { serverUrl: string; username: string; token: string; password?: undefined };
|
|
|
|
/** A client for remembered credentials. */
|
|
function clientForSession(session: RememberedSession): JMAPClient {
|
|
return session.token !== undefined
|
|
? JMAPClient.withBearer(session.serverUrl, session.token, session.username)
|
|
: new JMAPClient(session.serverUrl, session.username, session.password);
|
|
}
|
|
|
|
/** POST /api/auth/session?slot=N - remember a password or access token for `slot`. */
|
|
async function persistSession(slot: number, session: RememberedSession): Promise<void> {
|
|
if (IS_LITE) {
|
|
// Only reached for a password when the server has no token login: keep
|
|
// the credentials with this tab so a reload does not sign the user out.
|
|
saveLiteBasicSession(slot, session);
|
|
return;
|
|
}
|
|
// A slot being signed out must not get its cookie back; sign-out waits for
|
|
// a write still running (login doesn't wait for it, see there).
|
|
if (closingSlots.has(slot)) return;
|
|
try {
|
|
const res = await trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/session?slot=${slot}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...session, slot }),
|
|
signal,
|
|
})).then((answer) => noteSessionAnswer(answer, slot));
|
|
if (!res.ok) debug.error('Failed to store session: server returned', res.status);
|
|
} catch (err) {
|
|
debug.error('Failed to store session:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When the server says each cookie slot signed in, from the sign-in and
|
|
* restore answers. The organisation's maximum session length counts from
|
|
* the server's time in the browser too, so the server never refuses a
|
|
* session before the browser warned (lib/auth/session-age.ts).
|
|
*/
|
|
const serverSignedInAt = new Map<number, number>();
|
|
|
|
/**
|
|
* The server's sign-in time for a sign-in to `slot` that just happened, or
|
|
* the browser's clock. An older answer belongs to an earlier sign-in.
|
|
*/
|
|
export function signedInAtForSlot(slot: number | undefined): number {
|
|
const now = Date.now();
|
|
const server = slot !== undefined ? serverSignedInAt.get(slot) : undefined;
|
|
return server !== undefined && Math.abs(now - server) < 10 * 60 * 1000 ? server : now;
|
|
}
|
|
|
|
/**
|
|
* Reads what a sign-in, restore or renewal answer says about the session:
|
|
* the server's sign-in time, or that it refused the session because it
|
|
* reached the organisation's maximum length. The login page says so only
|
|
* when no other account stays signed in here; a later successful restore
|
|
* takes the reason back.
|
|
*/
|
|
async function noteSessionAnswer(res: Response, slot: number | undefined): Promise<Response> {
|
|
if (typeof res.clone !== 'function' || (!res.ok && res.status !== 401)) return res;
|
|
try {
|
|
const data = await res.clone().json() as { error?: unknown; maxHours?: unknown; signedInAt?: unknown; signed_in_at?: unknown };
|
|
if (res.ok) {
|
|
clearSessionEnd();
|
|
const at = typeof data?.signedInAt === 'number' ? data.signedInAt : data?.signed_in_at;
|
|
if (typeof at === 'number' && Number.isFinite(at) && slot !== undefined) {
|
|
serverSignedInAt.set(slot, at);
|
|
const account = useAccountStore.getState().accounts.find((a) => a.cookieSlot === slot);
|
|
if (account && account.signedInAt !== at) useAccountStore.getState().updateAccount(account.id, { signedInAt: at });
|
|
}
|
|
} else if (data?.error === SESSION_TOO_OLD && typeof data.maxHours === 'number') {
|
|
const othersSignedIn = useAccountStore.getState().accounts.some((a) => a.cookieSlot !== slot && a.isConnected);
|
|
if (!othersSignedIn) rememberSessionEnd({ kind: 'max', hours: data.maxHours });
|
|
}
|
|
} catch {
|
|
/* not JSON */
|
|
}
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* PUT /api/auth/token?slot=N - renew (or, unless `force`, reuse) the slot's
|
|
* access token. `remember: false` keeps a rotated refresh token in a cookie
|
|
* that ends with the browser session.
|
|
*/
|
|
async function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: boolean } = {}): Promise<Response> {
|
|
// Renewing would write the tokens of a slot being signed out back.
|
|
if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401);
|
|
if (!IS_LITE) {
|
|
const query = `${opts.force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`;
|
|
return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal }))
|
|
.then((res) => noteSessionAnswer(res, slot));
|
|
}
|
|
// Like the route's cookie cache: a restore resumes with the token it had,
|
|
// since a renewal before the refresh token's `nbf` is refused (#552).
|
|
if (!opts.force) {
|
|
const cached = readLiteAccessToken(slot);
|
|
if (cached) return jsonResponse({ access_token: cached.accessToken, expires_in: cached.expiresIn });
|
|
}
|
|
return trackSessionWrite(slot, async () => {
|
|
try {
|
|
const tokens = await liteRefreshTokens(slot);
|
|
return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn });
|
|
} catch (err) {
|
|
if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err));
|
|
throw err; // network failure - callers treat it as an outage, not a rejection
|
|
}
|
|
});
|
|
}
|
|
|
|
/** PUT /api/auth/session[?slot=N] - the slot's remembered password or access token. */
|
|
async function fetchSlotSession(slot?: number): Promise<Response> {
|
|
if (!IS_LITE) {
|
|
return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' })
|
|
.then((res) => noteSessionAnswer(res, slot ?? 0));
|
|
}
|
|
const stored = readLiteBasicSession(slot ?? 0);
|
|
return stored ? jsonResponse(stored) : jsonResponse({ error: 'no_session' }, 401);
|
|
}
|
|
|
|
/**
|
|
* DELETE one of the credential endpoints. Resolves to the JSON body, or null
|
|
* when the server could not clear it - reported, since those credentials may
|
|
* then still resume the session. keepalive lets a request that outlives the
|
|
* cleanup timeout finish after the page has navigated away.
|
|
*/
|
|
async function deleteCredentials(path: string): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
const res = await apiFetch(path, { method: 'DELETE', keepalive: true });
|
|
if (!res.ok) {
|
|
console.warn(`[auth] Sign-out could not clear ${path}: HTTP ${res.status}`);
|
|
return null;
|
|
}
|
|
return await res.json().catch(() => ({}));
|
|
} catch (err) {
|
|
console.warn(`[auth] Sign-out could not reach ${path}:`, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** The provider logout URL from a token DELETE answer, when it is one a browser may be sent to. */
|
|
function readEndSessionUrl(body: Record<string, unknown> | null): string | null {
|
|
const url = body?.end_session_url;
|
|
if (typeof url !== 'string') return null;
|
|
try {
|
|
return new URL(url).protocol === 'https:' ? url : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear credentials for the closing `slots` once their in-flight session
|
|
* writes have settled, then reopen the slots. `requests` issues the DELETEs
|
|
* and picks the provider logout URL out of the answers.
|
|
*/
|
|
async function clearClosingSlots(
|
|
slots: ReadonlySet<number>,
|
|
requests: () => Promise<string | null>,
|
|
): Promise<string | null> {
|
|
try {
|
|
await settleSessionWrites(slots);
|
|
const result = await withTimeout(requests(), CREDENTIAL_CLEANUP_TIMEOUT_MS);
|
|
if (result === TIMED_OUT) {
|
|
console.warn('[auth] Sign-out cleanup is taking long; finishing it in the background');
|
|
return null;
|
|
}
|
|
return result;
|
|
} finally {
|
|
for (const slot of slots) closingSlots.delete(slot);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Revoke and clear a slot's credentials: the remembered session and, with
|
|
* `includeToken`, the refresh token. With `endSession`, resolves to the URL
|
|
* that ends the identity provider's session, when there is one.
|
|
*/
|
|
function clearSlotCredentials(slot: number, includeToken: boolean, endSession = false): Promise<string | null> {
|
|
closingSlots.add(slot);
|
|
return clearClosingSlots(new Set([slot]), async () => {
|
|
if (IS_LITE) {
|
|
await revokeLiteSlot(slot, getCachedConfig()?.oauthRevokeOnSignOut !== false);
|
|
return null;
|
|
}
|
|
const [, token] = await Promise.all([
|
|
deleteCredentials(`/api/auth/session?slot=${slot}`),
|
|
includeToken ? deleteCredentials(`/api/auth/token?slot=${slot}${endSession ? '&end_session=true' : ''}`) : null,
|
|
]);
|
|
return readEndSessionUrl(token);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Revoke and clear every slot's credentials. With `endSessionSlot`, resolves
|
|
* to the URL that ends that slot's identity provider session, when there is one.
|
|
*/
|
|
function clearAllCredentials(endSessionSlot: number | null): Promise<string | null> {
|
|
const slots = new Set(Array.from({ length: MAX_ACCOUNT_SLOTS }, (_, i) => i));
|
|
for (const slot of slots) closingSlots.add(slot);
|
|
return clearClosingSlots(slots, async () => {
|
|
if (IS_LITE) {
|
|
await revokeAllLiteSessions(getCachedConfig()?.oauthRevokeOnSignOut !== false);
|
|
return null;
|
|
}
|
|
const endSession = endSessionSlot === null ? '' : `&end_session_slot=${endSessionSlot}`;
|
|
const [, token] = await Promise.all([
|
|
deleteCredentials('/api/auth/session?all=true'),
|
|
deleteCredentials(`/api/auth/token?all=true${endSession}`),
|
|
]);
|
|
return readEndSessionUrl(token);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether signing `account` out should also end its identity provider
|
|
* session: only for a sign-in through the provider's own login (OAuth code or
|
|
* SSO flow - a password login leaves no provider session to end), only when
|
|
* the page can navigate the whole window (an embedding portal owns SSO and is
|
|
* told through the iframe bridge instead), and not in Lite, which has no
|
|
* server to build the logout request.
|
|
*/
|
|
function shouldEndProviderSession(account: AccountEntry | null | undefined): boolean {
|
|
return !!account?.providerSession && !IS_LITE && !isEmbedded();
|
|
}
|
|
|
|
/**
|
|
* The account whose provider session "Sign out of all accounts" ends. A page
|
|
* can make one top-level navigation, so one provider can be visited: the
|
|
* active account's, when it signed in through one, else the first such account
|
|
* in the list. Accounts on that same provider share its browser session and
|
|
* are signed out of it together. Accounts on other providers are signed out of
|
|
* Bulwark and their tokens revoked, but those providers' sessions stay open.
|
|
*/
|
|
export function pickEndSessionSlot(accounts: AccountEntry[], activeAccountId: string | null): number | null {
|
|
const eligible = accounts.filter((account) => shouldEndProviderSession(account));
|
|
const target = eligible.find((account) => account.id === activeAccountId) ?? eligible[0];
|
|
return target ? target.cookieSlot : null;
|
|
}
|
|
|
|
function bindClientStatusHandlers(
|
|
client: IJMAPClient,
|
|
set: (state: Partial<AuthState>) => void,
|
|
get: () => AuthState,
|
|
accountId?: string,
|
|
): void {
|
|
client.onConnectionChange((connected) => {
|
|
if (!accountId || get().activeAccountId === accountId) {
|
|
set({ connectionLost: !connected });
|
|
}
|
|
if (accountId) {
|
|
useAccountStore.getState().updateAccount(accountId, { isConnected: connected });
|
|
}
|
|
});
|
|
|
|
client.onRateLimit((rateLimited, retryAfterMs) => {
|
|
const isActiveAccount = !accountId || get().activeAccountId === accountId;
|
|
const nextRateLimitUntil = rateLimited ? Date.now() + retryAfterMs : null;
|
|
|
|
if (isActiveAccount) {
|
|
set({
|
|
isRateLimited: rateLimited,
|
|
rateLimitUntil: nextRateLimitUntil,
|
|
connectionLost: false,
|
|
});
|
|
}
|
|
|
|
if (accountId) {
|
|
useAccountStore.getState().updateAccount(accountId, {
|
|
isConnected: !rateLimited,
|
|
hasError: rateLimited,
|
|
errorMessage: rateLimited ? 'Temporarily rate limited by server' : undefined,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
|
|
// The synced per-account default sender identity (#507) is keyed by
|
|
// AccountEntry.id and re-applied by applyPreferredIdentity() once
|
|
// loadFromServer resolves (the accountId isn't known here). At load time we
|
|
// only honour the browser-local fallback (identity-storage) so the ordering
|
|
// is stable before - or entirely without - settings sync.
|
|
const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId;
|
|
|
|
const identities = sortIdentities(rawIdentities, username);
|
|
|
|
// If a local preferred primary is set, move it to the front.
|
|
if (preferredPrimaryId) {
|
|
const idx = identities.findIndex((id) => id.id === preferredPrimaryId);
|
|
if (idx > 0) {
|
|
const [preferred] = identities.splice(idx, 1);
|
|
identities.unshift(preferred);
|
|
}
|
|
}
|
|
|
|
const primaryIdentity = identities[0] ?? null;
|
|
useIdentityStore.getState().setIdentities(identities);
|
|
return { identities, primaryIdentity };
|
|
}
|
|
|
|
/**
|
|
* Re-apply the per-account default sender identity once synced settings are
|
|
* available (issue #507). The choice is stored server-side in the settings
|
|
* store (`preferredIdentityIds`, keyed by AccountEntry.id), so it can only be
|
|
* applied after `loadFromServer` resolves. It reorders the account's identities
|
|
* so the preferred one is primary - the composer defaults its `From` to
|
|
* identities[0]. No-op when nothing is configured for the account.
|
|
*
|
|
* Also performs the one-time migration of the pre-#507 browser-local default
|
|
* (identity-storage) into the synced per-account map, keyed by accountId.
|
|
*
|
|
* @param accountId The account to apply for; defaults to the active account.
|
|
*/
|
|
/**
|
|
* Load the account's synced settings and turn syncing back on for it. Every
|
|
* path that makes an account active after sync was switched off for the
|
|
* previous one must run this, or later edits are never saved to the server.
|
|
*/
|
|
function resumeSettingsSync(account: Pick<AccountEntry, 'id' | 'username' | 'serverUrl'>): void {
|
|
fetchConfig().then(config => {
|
|
if (!config.settingsSyncEnabled) return;
|
|
useSettingsStore.getState().loadFromServer(account.username, account.serverUrl).finally(() => {
|
|
useSettingsStore.getState().enableSync(account.username, account.serverUrl);
|
|
applyPreferredIdentity(account.id);
|
|
});
|
|
}).catch(() => {});
|
|
}
|
|
|
|
export function applyPreferredIdentity(accountId?: string | null): void {
|
|
const targetId = accountId ?? useAccountStore.getState().activeAccountId;
|
|
if (!targetId) return;
|
|
|
|
const idStore = useIdentityStore.getState();
|
|
// Only touch the live identity store when it currently holds this account's
|
|
// identities (true for the active account). Switching snapshots/restores the
|
|
// ordering per account, so a background account's order is restored later.
|
|
// The local fallback below also belongs to the active account, so gate first.
|
|
if (useAccountStore.getState().activeAccountId !== targetId) return;
|
|
|
|
let preferred = useSettingsStore.getState().preferredIdentityIds[targetId] ?? null;
|
|
|
|
// One-time migration: before #507 the default lived only in the browser-local
|
|
// identity-storage (never synced). If the synced map has no entry for this
|
|
// account yet, adopt that local value and persist it (keyed by accountId) so
|
|
// it follows the user across devices.
|
|
if (!preferred) {
|
|
const legacy = idStore.preferredPrimaryId;
|
|
if (legacy) {
|
|
preferred = legacy;
|
|
const current = useSettingsStore.getState().preferredIdentityIds;
|
|
useSettingsStore.getState().updateSetting('preferredIdentityIds', { ...current, [targetId]: legacy }, { internal: true });
|
|
}
|
|
}
|
|
if (!preferred) return;
|
|
|
|
idStore.setPreferredPrimary(preferred);
|
|
const ids = [...idStore.identities];
|
|
const idx = ids.findIndex((i) => i.id === preferred);
|
|
if (idx > 0) {
|
|
const [p] = ids.splice(idx, 1);
|
|
ids.unshift(p);
|
|
idStore.setIdentities(ids);
|
|
}
|
|
useAuthStore.setState({ identities: ids, primaryIdentity: ids[0] ?? null });
|
|
}
|
|
|
|
function getLocaleLoginPath(): string {
|
|
if (typeof window === 'undefined') return '/en/login';
|
|
|
|
const prefix = getPathPrefix();
|
|
const locale = getLocaleFromPath();
|
|
return `${prefix}/${locale}/login`;
|
|
}
|
|
|
|
/**
|
|
* Set once sign-out has sent the browser to the identity provider's logout.
|
|
* The pages' auth guards answer the signed-out state by heading for the login
|
|
* page, and that later navigation would cancel the one to the provider.
|
|
*/
|
|
let leavingForProviderLogout = false;
|
|
|
|
/**
|
|
* Remembers where the user was so login can send them back. Stores the query
|
|
* and hash too, not just the path - a deep link's disambiguators (#733) live
|
|
* there, and dropping them silently lands the user on the wrong thing.
|
|
*/
|
|
export function saveRedirectAfterLogin(): void {
|
|
if (typeof window === 'undefined' || leavingForProviderLogout) return;
|
|
|
|
try {
|
|
const loginPath = getLocaleLoginPath();
|
|
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
|
|
// The app's home is where a login lands anyway. Saving it only made the
|
|
// login page navigate a second time, through the locale-aware router,
|
|
// which prefixes the locale again (/en/en) and races the first.
|
|
const home = `${getPathPrefix()}/${getLocaleFromPath()}`;
|
|
if (currentPath === home || currentPath === `${home}/`) {
|
|
sessionStorage.removeItem('redirect_after_login');
|
|
return;
|
|
}
|
|
|
|
if (currentPath !== loginPath) {
|
|
sessionStorage.setItem('redirect_after_login', currentPath);
|
|
}
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the login page can be reached without reloading the app. Before a
|
|
* session was usable on this page load there is no mail state to throw away,
|
|
* so a client-side navigation does and the app isn't downloaded and started
|
|
* a second time; after one, the reload is what clears it.
|
|
*
|
|
* Not in Lite: its static router lands on `/login/` with a trailing slash,
|
|
* which the login path checks here (`/login`) don't recognise.
|
|
*/
|
|
export function canEnterLoginInPlace(): boolean {
|
|
return !IS_LITE && !signedInSincePageLoad && !leavingForProviderLogout;
|
|
}
|
|
|
|
export function redirectToLogin(): void {
|
|
if (typeof window === 'undefined' || leavingForProviderLogout) return;
|
|
navigateToLogin();
|
|
}
|
|
|
|
function navigateToLogin(): void {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const loginPath = getLocaleLoginPath();
|
|
if (window.location.pathname === loginPath) return;
|
|
replaceWindowLocation(loginPath);
|
|
}
|
|
|
|
/** How long an expired session waits for the open composers to save. */
|
|
const EXPIRY_DRAFT_FLUSH_MS = 3000;
|
|
/** Set when an expired session couldn't save every open message; the login page says so. */
|
|
export const SESSION_EXPIRED_DRAFTS_UNSAVED_KEY = 'session_expired_drafts_unsaved';
|
|
|
|
/**
|
|
* The server refused to renew the session. The access token in hand usually
|
|
* still works for a while (renewal runs before it expires), so the open
|
|
* composers save their drafts with it before the sign-out throws their
|
|
* state away, as the organisation's idle timeout does (WCAG 2.2.5). The
|
|
* slot counts as closing meanwhile: a save that needs a new token gets none
|
|
* at once instead of waiting on this refresh. Bounded, so a hung save never
|
|
* keeps an expired session open.
|
|
*/
|
|
async function saveDraftsBeforeExpiry(slot: number): Promise<void> {
|
|
closingSlots.add(slot);
|
|
try {
|
|
const result = await flushComposers(EXPIRY_DRAFT_FLUSH_MS);
|
|
if (result.failed > 0 || result.timedOut) {
|
|
try {
|
|
sessionStorage.setItem(SESSION_EXPIRED_DRAFTS_UNSAVED_KEY, 'true');
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
} catch {
|
|
/* the sign-out goes on */
|
|
}
|
|
}
|
|
|
|
function markSessionExpired(): void {
|
|
try {
|
|
sessionStorage.setItem('session_expired', 'true');
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
|
|
saveRedirectAfterLogin();
|
|
}
|
|
|
|
const SIGNED_OUT_KEY = 'signed_out';
|
|
|
|
/**
|
|
* True once after the user signed out on purpose. The login page then skips
|
|
* automatic SSO: it would sign the user straight back in whenever the
|
|
* provider's session outlived the sign-out (#905).
|
|
*/
|
|
export function consumeSignedOut(): boolean {
|
|
try {
|
|
const signedOut = sessionStorage.getItem(SIGNED_OUT_KEY) === 'true';
|
|
sessionStorage.removeItem(SIGNED_OUT_KEY);
|
|
return signedOut;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Leave the app after a full sign-out: through the identity provider's logout
|
|
* when there is one to end, otherwise straight to the login page.
|
|
*/
|
|
function finishSignOut(endSessionUrl: string | null, userInitiated: boolean): void {
|
|
if (userInitiated) {
|
|
try {
|
|
sessionStorage.setItem(SIGNED_OUT_KEY, 'true');
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
if (endSessionUrl) {
|
|
leavingForProviderLogout = true;
|
|
replaceWindowLocation(endSessionUrl);
|
|
return;
|
|
}
|
|
navigateToLogin();
|
|
}
|
|
|
|
function initializeFeatureStores(client: IJMAPClient): void {
|
|
if (client.supportsContacts()) {
|
|
const contactStore = useContactStore.getState();
|
|
contactStore.setSupportsSync(true);
|
|
contactStore.fetchAddressBooks(client).catch((err) => debug.error('Failed to fetch address books:', err));
|
|
contactStore.fetchContacts(client).catch((err) => debug.error('Failed to fetch contacts:', err));
|
|
|
|
// Default trusted-sender syncing on when contacts are available, unless the
|
|
// user has already made an explicit choice (`null` = not yet decided).
|
|
const settings = useSettingsStore.getState();
|
|
if (settings.trustedSendersAddressBook === null) {
|
|
settings.updateSetting('trustedSendersAddressBook', true, { internal: true });
|
|
}
|
|
} else {
|
|
useContactStore.getState().setSupportsSync(false);
|
|
}
|
|
|
|
// Directory (RFC 9670 principals) is independent of contacts support and only
|
|
// works when the server allows directory queries; populates recipient
|
|
// autocomplete with other users on the server.
|
|
if (client.supportsPrincipals()) {
|
|
useContactStore.getState().fetchDirectory(client).catch((err) => debug.error('Failed to fetch directory:', err));
|
|
}
|
|
|
|
const vacationStore = useVacationStore.getState();
|
|
if (client.supportsVacationResponse()) {
|
|
vacationStore.setSupported(true);
|
|
vacationStore.fetchVacationResponse(client).catch((err) => debug.error('Failed to fetch vacation response:', err));
|
|
} else {
|
|
vacationStore.setSupported(false);
|
|
}
|
|
|
|
if (client.supportsCalendars()) {
|
|
const calendarStore = useCalendarStore.getState();
|
|
calendarStore.setSupported(true);
|
|
calendarStore.fetchCalendars(client).catch((err) => debug.error('Failed to fetch calendars:', err));
|
|
}
|
|
|
|
if (client.supportsSieve()) {
|
|
const filterStore = useFilterStore.getState();
|
|
filterStore.setSupported(true);
|
|
filterStore.fetchFilters(client).catch((err) => debug.error('Failed to fetch filters:', err));
|
|
}
|
|
}
|
|
|
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let refreshPromise: Promise<string | null> | null = null;
|
|
|
|
// Multi-account state: per-account JMAP clients and refresh timers
|
|
const clients = new Map<string, JMAPClient>();
|
|
|
|
/** Server URL recorded on the demo accounts; nothing is ever sent to it. */
|
|
const DEMO_SERVER_URL = 'https://demo.example.com';
|
|
|
|
/**
|
|
* Account switches can overlap (click B, then C before B lands). Each switch
|
|
* takes a generation and gives up at its next await once a newer one has
|
|
* started, so only the last click is applied.
|
|
*/
|
|
let switchGeneration = 0;
|
|
/**
|
|
* True while a switch has cleared the stores and not yet filled them with
|
|
* the target account. A switch starting then must not snapshot the empty
|
|
* stores over the outgoing account's cached state.
|
|
*/
|
|
let storesClearedForSwitch = false;
|
|
|
|
/**
|
|
* Snapshot the account whose data is on screen right now and clear the
|
|
* stores. Reads the active account when it runs, not when the switch began:
|
|
* an overlapping switch may have changed it in between, and a snapshot under
|
|
* the wrong key would later restore one account's identities (and so its
|
|
* From address) into another.
|
|
*/
|
|
function snapshotAndClearForSwitch(activeAccountId: string | null): void {
|
|
if (!storesClearedForSwitch && activeAccountId) {
|
|
snapshotAccount(activeAccountId);
|
|
}
|
|
clearAllStores();
|
|
storesClearedForSwitch = true;
|
|
}
|
|
const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
const refreshPromises = new Map<string, Promise<string | null>>();
|
|
|
|
// Retry backoff for transiently failed token refreshes (#588). The values
|
|
// are pseudo-expiries for scheduleRefresh - its "expiry - 60s" math turns
|
|
// them into delays of 30s, 1m, 2m and 5m (capped). Consecutive failures
|
|
// climb the ladder; any success resets it.
|
|
const TOKEN_REFRESH_RETRY_LADDER_SECONDS = [90, 120, 180, 360] as const;
|
|
const refreshFailureCounts = new Map<string, number>();
|
|
|
|
function nextRefreshRetrySeconds(accountId?: string): number {
|
|
const key = accountId ?? '__global__';
|
|
const failures = refreshFailureCounts.get(key) ?? 0;
|
|
refreshFailureCounts.set(key, failures + 1);
|
|
return TOKEN_REFRESH_RETRY_LADDER_SECONDS[
|
|
Math.min(failures, TOKEN_REFRESH_RETRY_LADDER_SECONDS.length - 1)
|
|
];
|
|
}
|
|
|
|
function resetRefreshBackoff(accountId?: string): void {
|
|
refreshFailureCounts.delete(accountId ?? '__global__');
|
|
permanentRefreshFailureCounts.delete(accountId ?? '__global__');
|
|
}
|
|
|
|
// /api/auth/token answers 503 for an upstream outage (retry forever - the IdP
|
|
// may come back) but 500/502 for a Bulwark-side failure (misconfiguration,
|
|
// broken token response). The latter does not heal by waiting, so after this
|
|
// many consecutive answers the account is evicted and the user asked to sign
|
|
// in again instead of retrying silently forever. (#972)
|
|
const MAX_PERMANENT_REFRESH_FAILURES = 5;
|
|
const permanentRefreshFailureCounts = new Map<string, number>();
|
|
|
|
function isPermanentRefreshFailure(status: number): boolean {
|
|
return status === 500 || status === 502;
|
|
}
|
|
|
|
/** Records a 500/502 refresh answer; true once the consecutive cap is reached. */
|
|
function recordPermanentRefreshFailure(status: number, accountId?: string): boolean {
|
|
if (!isPermanentRefreshFailure(status)) return false;
|
|
const key = accountId ?? '__global__';
|
|
const failures = (permanentRefreshFailureCounts.get(key) ?? 0) + 1;
|
|
permanentRefreshFailureCounts.set(key, failures);
|
|
if (failures < MAX_PERMANENT_REFRESH_FAILURES) return false;
|
|
permanentRefreshFailureCounts.delete(key);
|
|
return true;
|
|
}
|
|
|
|
function notifySignInAgain(): void {
|
|
void import('@/stores/toast-store').then(({ toast }) => {
|
|
toast.error('Your session could not be renewed', 'Sign in again to continue.');
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// Only re-arm a failed refresh while someone is still signed in to that
|
|
// account. A sign-out during the outage - or while the request was in
|
|
// flight - must end the retry loop instead of keeping it alive with
|
|
// doomed requests (#588).
|
|
function shouldRetryRefresh(slot: number, accountId?: string): boolean {
|
|
if (closingSlots.has(slot)) return false;
|
|
if (accountId) return !!useAccountStore.getState().getAccountById(accountId);
|
|
return useAuthStore.getState().isAuthenticated;
|
|
}
|
|
|
|
function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | null>, 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Derive the accountId a *connected* client actually belongs to, using the
|
|
* same canonicalisation as login (primary-identity email for OAuth, else the
|
|
* JMAP session username). Lets a caller detect a slot->token mapping that
|
|
* resolves to the wrong account before it surfaces as the wrong mailbox.
|
|
* Returns null when it can't determine the identity (treated as "don't block").
|
|
*/
|
|
export function buildServerIdentifiers(
|
|
sessionUsername: string | undefined,
|
|
primaryEmail: string | undefined,
|
|
serverUrl: string,
|
|
): string[] {
|
|
const ids = new Set<string>();
|
|
if (sessionUsername) ids.add(generateAccountId(sessionUsername, serverUrl));
|
|
if (primaryEmail) ids.add(generateAccountId(primaryEmail, serverUrl));
|
|
return [...ids];
|
|
}
|
|
|
|
export async function connectedAccountCandidates(client: JMAPClient, serverUrl: string): Promise<string[]> {
|
|
// accountId is generated differently per auth mode: OAuth/SSO registers from
|
|
// the primary-identity EMAIL, basic auth from the typed login username. A
|
|
// single derivation can't match both, so collect every server-confirmed
|
|
// identifier the connected session exposes — the JMAP Session.username
|
|
// (authenticated login) and the primary sending-identity email — and let the
|
|
// caller accept the session if the target accountId matches ANY of them.
|
|
// Deliberately excludes client.getUsername(), which echoes the constructor
|
|
// username (always the target) and would defeat the desync check. An empty
|
|
// result means nothing could be confirmed → the caller should NOT force a
|
|
// re-auth.
|
|
let sessionUser: string | undefined;
|
|
try {
|
|
sessionUser = client.getSessionUsername();
|
|
} catch { /* session unavailable */ }
|
|
let primaryEmail: string | undefined;
|
|
try {
|
|
// Pure resolution (no setIdentities side effect): this guard runs while the
|
|
// previous account may still be on screen during a seamless switch, so it
|
|
// must not mutate the live identity store.
|
|
const sorted = sortIdentities(await client.getIdentities(), client.getUsername());
|
|
primaryEmail = sorted[0]?.email;
|
|
} catch { /* identities unavailable */ }
|
|
return buildServerIdentifiers(sessionUser, primaryEmail, serverUrl);
|
|
}
|
|
|
|
/**
|
|
* Decide whether a freshly connected session may be bound to `accountId`.
|
|
*
|
|
* `connectedCandidates` are the server-confirmed identifiers of the session we
|
|
* just connected ({@link connectedAccountCandidates}). We accept when they
|
|
* overlap either the stored `accountId` (full-email / OAuth logins, where the
|
|
* id already IS the canonical address) or `storedIdentifiers` — the identifiers
|
|
* captured when THIS account last logged in, which cover a short login username
|
|
* the server canonicalizes to a full address.
|
|
*
|
|
* - 'accept' — bind the session (matched, or nothing confirmable to check).
|
|
* - 'trust' — legacy account with no baseline yet: accept and backfill (TOFU),
|
|
* so short-username accounts created before this check self-heal
|
|
* instead of bouncing forever.
|
|
* - 'reject' — server identity contradicts a known baseline: a real desync;
|
|
* force a clean re-auth.
|
|
*/
|
|
export function classifySessionMatch(
|
|
connectedCandidates: string[],
|
|
accountId: string,
|
|
storedIdentifiers: string[] | undefined,
|
|
): 'accept' | 'trust' | 'reject' {
|
|
if (connectedCandidates.length === 0) return 'accept';
|
|
const accepted = new Set<string>([accountId, ...(storedIdentifiers ?? [])]);
|
|
if (connectedCandidates.some((c) => accepted.has(c))) return 'accept';
|
|
if (storedIdentifiers === undefined) return 'trust';
|
|
return 'reject';
|
|
}
|
|
|
|
function clearRefreshTimer(accountId?: string): void {
|
|
if (accountId) {
|
|
const timer = refreshTimers.get(accountId);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
refreshTimers.delete(accountId);
|
|
}
|
|
refreshPromises.delete(accountId);
|
|
refreshFailureCounts.delete(accountId);
|
|
} else {
|
|
if (refreshTimer) {
|
|
clearTimeout(refreshTimer);
|
|
refreshTimer = null;
|
|
}
|
|
refreshFailureCounts.delete('__global__');
|
|
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();
|
|
refreshFailureCounts.clear();
|
|
}
|
|
|
|
/**
|
|
* Synchronously clears all auth and feature store state.
|
|
* Called during full logout (no remaining accounts).
|
|
*/
|
|
/**
|
|
* Drop the calendar subscriptions of a login being signed out: they persist
|
|
* across account switches, and their feed URLs are often secret.
|
|
*/
|
|
function forgetCalendarSubscriptions(client: IJMAPClient): void {
|
|
try {
|
|
useCalendarStore.getState().forgetICalSubscriptions(subscriptionOwner(client));
|
|
} catch {
|
|
// A client that cannot name its server and login owns no subscription.
|
|
}
|
|
}
|
|
|
|
function performFullLogout(set: (state: Partial<AuthState>) => void): void {
|
|
useSettingsStore.getState().disableSync();
|
|
// With settings sync on, the local settings and templates are a copy of the
|
|
// signed-out account's server file. Left in place, the next account to sign
|
|
// in here would show them and push them to its own file (#1185).
|
|
useSettingsStore.getState().forgetSyncedSettings();
|
|
|
|
set({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
serverUrl: null,
|
|
username: null,
|
|
client: null,
|
|
identities: [],
|
|
primaryIdentity: null,
|
|
authMode: 'basic',
|
|
rememberMe: false,
|
|
accessToken: null,
|
|
tokenExpiresAt: null,
|
|
connectionLost: false,
|
|
error: null,
|
|
activeAccountId: null,
|
|
isDemoMode: false,
|
|
});
|
|
|
|
clearAllStores();
|
|
// Calendar subscriptions outlive account switches, but their feed URLs are
|
|
// often secret: nobody is signed in any more, so none may stay behind.
|
|
useCalendarStore.getState().clearICalSubscriptions();
|
|
useSearchHistoryStore.getState().clearRecentSearches();
|
|
useCalendarNotificationStore.getState().clearAll();
|
|
clearAllPluginStorage();
|
|
|
|
// Remove persisted state AFTER the final set() so the persist middleware
|
|
// doesn't re-write stale values.
|
|
try { localStorage.removeItem('auth-storage'); } catch { /* noop */ }
|
|
try { localStorage.removeItem('account-registry'); } catch { /* noop */ }
|
|
purgeSignedOutData();
|
|
// Other tabs still hold the accounts' mail in memory.
|
|
broadcastSignOut();
|
|
}
|
|
|
|
/**
|
|
* How long sign-out waits for plugins to tidy up. Plugins observe sign-out;
|
|
* they cannot stop it or hold it up, or a plugin could keep the user signed
|
|
* in on a shared computer.
|
|
*/
|
|
export const LOGOUT_HOOK_BUDGET_MS = 2000;
|
|
|
|
function notifySignedIn(state: { activeAccountId: string | null; username: string | null }): void {
|
|
if (!state.activeAccountId) return;
|
|
// The organisation's maximum session length counts from a sign-in, never
|
|
// from a restore, and from the server's time when it said.
|
|
const slot = useAccountStore.getState().getAccountById(state.activeAccountId)?.cookieSlot;
|
|
useAccountStore.getState().updateAccount(state.activeAccountId, { signedInAt: signedInAtForSlot(slot) });
|
|
void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' });
|
|
}
|
|
|
|
function beforeLogoutHooks(accountId: string): Promise<void> {
|
|
return Promise.race([
|
|
Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId)]).then(() => {}),
|
|
new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
|
|
]);
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
serverUrl: null,
|
|
username: null,
|
|
client: null,
|
|
identities: [],
|
|
primaryIdentity: null,
|
|
authMode: 'basic',
|
|
rememberMe: false,
|
|
accessToken: null,
|
|
tokenExpiresAt: null,
|
|
connectionLost: false,
|
|
activeAccountId: null,
|
|
isDemoMode: false,
|
|
connectedAccountsRevision: 0,
|
|
restoringAccounts: false,
|
|
|
|
login: async (serverUrl, typedUsername, password, totp, rememberMe) => {
|
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
|
|
|
// Sign in with the ASCII (punycode) form of an IDN domain, the form
|
|
// Stalwart stores and reports back in the session and identities, so
|
|
// `user@bücher.de` and `user@xn--bcher-kva.de` are one account (#1100).
|
|
const username = toAsciiEmail(typedUsername);
|
|
|
|
try {
|
|
// Resolve account/slot info up front so the TOTP exchange can target
|
|
// the right per-account refresh-token cookie slot.
|
|
const accountStore = useAccountStore.getState();
|
|
const accountId = generateAccountId(username, serverUrl);
|
|
const cookieSlot = accountStore.hasAccount(username, serverUrl)
|
|
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
|
|
: accountStore.getNextCookieSlot();
|
|
|
|
let client: JMAPClient;
|
|
let upgradedToOAuth = false;
|
|
// Lite keeps a Basic password for the tab only when the server has
|
|
// no token login to use instead; a token login that failed for
|
|
// another reason must not leave the password in web storage.
|
|
let tokenLoginUnavailable = false;
|
|
let oauthAccessToken: string | null = null;
|
|
let oauthExpiresIn = 0;
|
|
|
|
// Lite has no session cookie to remember a password in, so every
|
|
// password login goes through Stalwart's token flow and only a
|
|
// refresh token is kept: localStorage with "remember me", the tab's
|
|
// sessionStorage without it (lib/auth/lite-tokens.ts). Servers
|
|
// without the endpoint fall back to Basic auth below.
|
|
const useTokenLogin = !!totp || IS_LITE;
|
|
if (useTokenLogin) {
|
|
// Stalwart 0.16+ dropped the `password$totp` basic-auth convention;
|
|
// the MFA code must be exchanged for tokens via the structured login
|
|
// endpoint (handled server-side). Token auth also survives TOTP
|
|
// rotation, unlike basic auth which embeds the ~30s code per request.
|
|
let bearerToken: string | null = null;
|
|
try {
|
|
// The callback URL the OAuth client already registers; the route
|
|
// needs an identical redirect URI for the login + token-exchange
|
|
// steps (and registered when require_client_registration is on).
|
|
// Lite on Stalwart uses the mount root instead: one locale-free
|
|
// URI per Application for the admin to register.
|
|
const redirectUri = typeof window === 'undefined'
|
|
? ''
|
|
: IS_LITE_STALWART
|
|
? `${window.location.origin}${getPathPrefix()}/`
|
|
: `${window.location.origin}${getPathPrefix()}/${getLocaleFromPath()}/auth/callback`;
|
|
const tokenRes = await exchangePasswordForTokens({
|
|
serverUrl, username, password, totp, slot: cookieSlot, redirectUri, rememberMe: !!rememberMe,
|
|
});
|
|
if (tokenRes.ok) {
|
|
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
|
|
bearerToken = access_token;
|
|
oauthExpiresIn = expires_in;
|
|
debug.log('auth', 'TOTP login exchanged for token-based auth (has_refresh_token=' + has_refresh_token + ')');
|
|
} else {
|
|
if (tokenRes.status === 404) tokenLoginUnavailable = true;
|
|
const errorBody = await tokenRes.json().catch(() => ({ error: 'unknown' }));
|
|
// A correct password with a missing/invalid MFA token surfaces as
|
|
// a TOTP prompt rather than a generic failure.
|
|
if (errorBody?.error === 'totp_required') {
|
|
throw new Error('TOTP_REQUIRED');
|
|
}
|
|
// The server took the password and code but would not issue
|
|
// tokens (in Lite: an OAuth client with a secret, which a
|
|
// browser cannot send). A server with the structured login
|
|
// endpoint is 0.16+ and refuses `password$totp` over Basic,
|
|
// so the legacy fallback would only pop the browser's
|
|
// Basic-auth dialog and then report a wrong code.
|
|
if (totp && errorBody?.error === 'token_exchange_failed') {
|
|
throw new Error('TOKEN_EXCHANGE_FAILED');
|
|
}
|
|
debug.warn('auth', 'TOTP login exchange failed, trying legacy basic auth:', tokenRes.status, errorBody);
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof Error && (err.message === 'TOTP_REQUIRED' || err.message === 'TOKEN_EXCHANGE_FAILED')) throw err;
|
|
// Unreachable (a server without /api/auth rarely answers CORS).
|
|
tokenLoginUnavailable = true;
|
|
debug.warn('auth', 'TOTP login exchange error, trying legacy basic auth:', err);
|
|
}
|
|
|
|
if (bearerToken) {
|
|
client = JMAPClient.withBearer(serverUrl, bearerToken, username, () => get().refreshAccessToken({ accountId }));
|
|
await client.connect();
|
|
oauthAccessToken = bearerToken;
|
|
upgradedToOAuth = true;
|
|
} else if (totp) {
|
|
// Legacy fallback for pre-0.16 Stalwart, which accepts the TOTP
|
|
// appended to the password over basic auth.
|
|
if (await precheckCredentials(serverUrl, { username, password: `${password}$${totp}` })) {
|
|
throw new Error('Invalid username or password');
|
|
}
|
|
client = new JMAPClient(serverUrl, username, `${password}$${totp}`);
|
|
await client.connect();
|
|
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
|
|
client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp());
|
|
debug.log('auth', 'TOTP re-auth enabled (legacy basic-auth path)');
|
|
} else {
|
|
// Lite on a server without token login (or one that blocks the
|
|
// browser's /api/auth call): plain Basic auth; the session is
|
|
// then kept with the tab only, see persistSession.
|
|
client = new JMAPClient(serverUrl, username, password);
|
|
await client.connect();
|
|
}
|
|
} else {
|
|
if (await precheckCredentials(serverUrl, { username, password })) {
|
|
throw new Error('Invalid username or password');
|
|
}
|
|
client = new JMAPClient(serverUrl, username, password);
|
|
await client.connect();
|
|
}
|
|
|
|
// Snapshot/clear before kicking off any feature-store fetches so they
|
|
// don't write into stores we're about to wipe.
|
|
const prevAccountId = get().activeAccountId;
|
|
if (prevAccountId && prevAccountId !== accountId) {
|
|
snapshotAccount(prevAccountId);
|
|
clearAllStores();
|
|
}
|
|
|
|
// Identities can fly in parallel with everything below.
|
|
const identitiesPromise = client.getIdentities();
|
|
|
|
const effectiveAuthMode = upgradedToOAuth ? 'oauth' : 'basic';
|
|
|
|
// Run the remaining independent requests in parallel. The session
|
|
// write and stalwart-context write are best-effort persistence; the
|
|
// outer login still succeeds even if they log a warning. Errors are
|
|
// caught locally so Promise.all doesn't reject on either. Only the
|
|
// context is waited for: the server-side helpers the mail view
|
|
// calls next need it, while the remembered session only matters
|
|
// for the next visit (and sign-out waits for its write).
|
|
// In Lite a Basic session is kept for the tab (sessionStorage) on a
|
|
// server without token login, so a reload does not sign the user
|
|
// out; the regular build only writes the cookie when the user asked
|
|
// to be remembered.
|
|
if ((IS_LITE ? tokenLoginUnavailable : rememberMe) && !upgradedToOAuth) {
|
|
void persistSession(cookieSlot, { serverUrl, username, password });
|
|
}
|
|
|
|
const [rawIdentities] = await Promise.all([
|
|
identitiesPromise,
|
|
syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot),
|
|
]);
|
|
|
|
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
|
|
initializeFeatureStores(client);
|
|
|
|
// Store client in multi-account map
|
|
clients.set(accountId, client);
|
|
bindClientStatusHandlers(client, set, get, accountId);
|
|
|
|
accountStore.addAccount({
|
|
label: primaryIdentity?.name || username,
|
|
serverUrl,
|
|
username,
|
|
authMode: effectiveAuthMode,
|
|
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);
|
|
void syncAccountDisplayName(accountId, client, primaryIdentity?.name);
|
|
|
|
// Update account entry in case it already existed (addAccount is a no-op for existing accounts)
|
|
accountStore.updateAccount(accountId, {
|
|
authMode: effectiveAuthMode,
|
|
rememberMe: !!rememberMe,
|
|
providerSession: false,
|
|
isConnected: true,
|
|
hasError: false,
|
|
errorMessage: undefined,
|
|
lastLoginAt: Date.now(),
|
|
});
|
|
|
|
// Capture the server-confirmed identity now so a later account switch
|
|
// recognizes this session even when `username` is a short login name
|
|
// the server canonicalizes to a different address (see the switch guard).
|
|
const serverIdentifiers = buildServerIdentifiers(client.getSessionUsername(), primaryIdentity?.email, serverUrl);
|
|
if (serverIdentifiers.length > 0) {
|
|
accountStore.updateAccount(accountId, { serverIdentifiers });
|
|
}
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl,
|
|
username,
|
|
client,
|
|
...getClientRateLimitState(client),
|
|
identities,
|
|
primaryIdentity,
|
|
authMode: effectiveAuthMode,
|
|
rememberMe: !!rememberMe,
|
|
accessToken: oauthAccessToken,
|
|
tokenExpiresAt: oauthAccessToken ? Date.now() + oauthExpiresIn * 1000 : null,
|
|
connectionLost: false,
|
|
error: null,
|
|
activeAccountId: accountId,
|
|
});
|
|
|
|
// Kick off mailbox/quota/email fetches now so they overlap with the
|
|
// soft-nav + home-page hydration that follows login. Dynamic import
|
|
// avoids a static circular dep with email-store.
|
|
import('@/stores/email-store').then(({ useEmailStore }) => {
|
|
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
|
debug.error('Initial data prefetch failed:', err);
|
|
});
|
|
}).catch(() => {});
|
|
|
|
// Schedule token refresh for TOTP-upgraded sessions
|
|
if (upgradedToOAuth && oauthExpiresIn > 0) {
|
|
scheduleRefresh(oauthExpiresIn, () => get().refreshAccessToken({ accountId }), accountId);
|
|
}
|
|
|
|
// Sync settings from server (only if enabled)
|
|
fetchConfig().then(config => {
|
|
if (!config.settingsSyncEnabled) return;
|
|
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
|
useSettingsStore.getState().enableSync(username, serverUrl);
|
|
applyPreferredIdentity(accountId);
|
|
});
|
|
}).catch(() => {});
|
|
|
|
notifySignedIn(get());
|
|
return true;
|
|
} catch (error) {
|
|
debug.error('Login error:', error);
|
|
set({
|
|
isLoading: false,
|
|
error: classifyLoginError(error),
|
|
isAuthenticated: false,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
client: null,
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
|
|
loginWithToken: async (serverUrl, typedToken, rememberMe) => {
|
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
|
|
|
// Pasted tokens often come with the scheme or surrounding whitespace.
|
|
const token = typedToken.trim().replace(/^Bearer\s+/i, '');
|
|
|
|
try {
|
|
if (!token || /\s/.test(token) || await precheckCredentials(serverUrl, { token })) {
|
|
throw new Error('INVALID_TOKEN');
|
|
}
|
|
|
|
// No refresh callback: a pasted token cannot be renewed.
|
|
const client = JMAPClient.withBearer(serverUrl, token, '');
|
|
try {
|
|
await client.connect();
|
|
} catch (err) {
|
|
if (err instanceof Error && /^Authentication failed|: 40[13]$/.test(err.message)) {
|
|
throw new Error('INVALID_TOKEN');
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const username = client.getSessionUsername() || client.getUsername();
|
|
if (!username) throw new Error('The server did not name the account');
|
|
|
|
const accountStore = useAccountStore.getState();
|
|
const accountId = generateAccountId(username, serverUrl);
|
|
const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot();
|
|
|
|
const prevAccountId = get().activeAccountId;
|
|
if (prevAccountId && prevAccountId !== accountId) {
|
|
snapshotAccount(prevAccountId);
|
|
clearAllStores();
|
|
}
|
|
|
|
// Lite keeps the token with the tab either way, like its Basic
|
|
// sessions; the regular build only when asked to remember it.
|
|
const [rawIdentities] = await Promise.all([
|
|
client.getIdentities(),
|
|
IS_LITE || rememberMe ? persistSession(cookieSlot, { serverUrl, username, token }) : null,
|
|
syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot),
|
|
]);
|
|
|
|
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
|
|
initializeFeatureStores(client);
|
|
|
|
clients.set(accountId, client);
|
|
bindClientStatusHandlers(client, set, get, accountId);
|
|
|
|
accountStore.addAccount({
|
|
label: primaryIdentity?.name || username,
|
|
serverUrl,
|
|
username,
|
|
authMode: 'token',
|
|
rememberMe: !!rememberMe,
|
|
displayName: primaryIdentity?.name || username,
|
|
email: primaryIdentity?.email || username,
|
|
lastLoginAt: Date.now(),
|
|
isConnected: true,
|
|
hasError: false,
|
|
isDefault: accountStore.accounts.length === 0,
|
|
});
|
|
// The session was remembered at `cookieSlot`; see loginWithOAuth.
|
|
const serverIdentifiers = buildServerIdentifiers(client.getSessionUsername(), primaryIdentity?.email, serverUrl);
|
|
accountStore.updateAccount(accountId, {
|
|
cookieSlot,
|
|
authMode: 'token',
|
|
rememberMe: !!rememberMe,
|
|
providerSession: false,
|
|
...(serverIdentifiers.length > 0 ? { serverIdentifiers } : {}),
|
|
});
|
|
accountStore.setActiveAccount(accountId);
|
|
void syncAccountDisplayName(accountId, client, primaryIdentity?.name);
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl,
|
|
username,
|
|
client,
|
|
...getClientRateLimitState(client),
|
|
identities,
|
|
primaryIdentity,
|
|
authMode: 'token',
|
|
rememberMe: !!rememberMe,
|
|
accessToken: null,
|
|
tokenExpiresAt: null,
|
|
connectionLost: false,
|
|
error: null,
|
|
activeAccountId: accountId,
|
|
});
|
|
|
|
import('@/stores/email-store').then(({ useEmailStore }) => {
|
|
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
|
debug.error('Initial data prefetch failed:', err);
|
|
});
|
|
}).catch(() => {});
|
|
|
|
resumeSettingsSync({ id: accountId, username, serverUrl });
|
|
notifySignedIn(get());
|
|
return true;
|
|
} catch (error) {
|
|
debug.error('Token login error:', error);
|
|
set({
|
|
isLoading: false,
|
|
error: error instanceof Error && error.message === 'INVALID_TOKEN' ? 'invalid_token' : classifyLoginError(error),
|
|
isAuthenticated: false,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
client: null,
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
|
|
loginDemo: async () => {
|
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
|
try {
|
|
// Clear all store data before re-initializing with fresh demo data
|
|
clearAllStores();
|
|
|
|
const { DemoJMAPClient } = await import('@/lib/demo/demo-client');
|
|
const { seedDemoPreferences } = await import('@/lib/demo/demo-preferences');
|
|
// Two logins: the work account (primary, with the shared Support
|
|
// mailbox) and a mail-only personal account, so the account
|
|
// switcher and the merged inbox have more than one source.
|
|
const client = new DemoJMAPClient('work');
|
|
const personal = new DemoJMAPClient('personal');
|
|
await Promise.all([client.connect(), personal.connect()]);
|
|
|
|
const username = client.getUsername();
|
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
|
initializeFeatureStores(client);
|
|
|
|
// Register the account entries so the account switcher shows
|
|
// names and avatars, and the unified views find both clients.
|
|
const accountStore = useAccountStore.getState();
|
|
const displayName = primaryIdentity?.name || 'Nora Lindqvist';
|
|
const register = (demoClient: InstanceType<typeof DemoJMAPClient>, label: string, isDefault: boolean) => {
|
|
const email = demoClient.getUsername();
|
|
const id = accountStore.addAccount({
|
|
label,
|
|
serverUrl: DEMO_SERVER_URL,
|
|
username: email,
|
|
authMode: 'basic',
|
|
rememberMe: false,
|
|
displayName,
|
|
email,
|
|
lastLoginAt: Date.now(),
|
|
isConnected: true,
|
|
hasError: false,
|
|
isDefault,
|
|
});
|
|
// The registry is typed for the real client; the unified views only
|
|
// use the IJMAPClient surface, which the demo client implements.
|
|
clients.set(id, demoClient as unknown as JMAPClient);
|
|
return id;
|
|
};
|
|
const demoAccountId = register(client, 'Meridian Labs', true);
|
|
register(personal, 'Personal', false);
|
|
accountStore.setActiveAccount(demoAccountId);
|
|
seedDemoPreferences();
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl: 'demo.example.com',
|
|
username,
|
|
client,
|
|
...getClientRateLimitState(client),
|
|
identities,
|
|
primaryIdentity,
|
|
authMode: 'basic',
|
|
rememberMe: false,
|
|
accessToken: null,
|
|
tokenExpiresAt: null,
|
|
connectionLost: false,
|
|
error: null,
|
|
activeAccountId: demoAccountId,
|
|
isDemoMode: true,
|
|
});
|
|
notifySignedIn(get());
|
|
return true;
|
|
} catch (error) {
|
|
debug.error('Demo login error:', error);
|
|
set({
|
|
isLoading: false,
|
|
error: 'generic',
|
|
isAuthenticated: false,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
client: null,
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
|
|
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri, serverId) => {
|
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
|
|
|
// Lite: the slot whose refresh token this attempt wrote, until the
|
|
// session has named its account.
|
|
let unnamedLiteSlot: number | null = null;
|
|
|
|
try {
|
|
// Determine slot for this account (use slot from sessionStorage if re-adding).
|
|
// Note: `parseInt(getItem(...) || '0')` collapses "no value set" and
|
|
// "value is 0" into the same case, so the fallback to getNextCookieSlot()
|
|
// never fired for the common "+ Add Account" path - every OAuth account
|
|
// ended up on slot 0 and overwrote earlier accounts' refresh-token cookies.
|
|
// Distinguishing rawSlot === null from a parsed 0 fixes that. The page
|
|
// also writes oauth_cookie_slot before redirecting to the IdP.
|
|
const accountStore = useAccountStore.getState();
|
|
const rawSlot = typeof window !== 'undefined'
|
|
? sessionStorage.getItem('oauth_cookie_slot')
|
|
: null;
|
|
const pendingSlot = rawSlot !== null ? parseInt(rawSlot, 10) : NaN;
|
|
const slot = !isNaN(pendingSlot) && pendingSlot >= 0 && pendingSlot < MAX_ACCOUNT_SLOTS
|
|
? pendingSlot
|
|
: accountStore.getNextCookieSlot();
|
|
// The login page parks "Remember me" here when the box is offered.
|
|
const remember = typeof window === 'undefined' || sessionStorage.getItem('oauth_remember') !== 'false';
|
|
|
|
const tokenRes = await exchangeOAuthCode({ serverUrl, code, codeVerifier, redirectUri, slot, serverId, remember });
|
|
|
|
if (!tokenRes.ok) {
|
|
throw new Error('token_exchange_failed');
|
|
}
|
|
if (IS_LITE) unnamedLiteSlot = slot;
|
|
|
|
const { access_token, expires_in } = await tokenRes.json();
|
|
|
|
// The account id comes from the session, so the refresh hook reads
|
|
// it once it is known below.
|
|
let boundAccountId: string | undefined;
|
|
const client = JMAPClient.withBearer(serverUrl, access_token, '', () => get().refreshAccessToken({ accountId: boundAccountId }));
|
|
await client.connect();
|
|
|
|
const jmapUsername = client.getUsername();
|
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), jmapUsername);
|
|
// For OAuth/OIDC, the JMAP session account name may be the
|
|
// preferred_username claim rather than the real email address.
|
|
// Prefer the email from the primary identity when available.
|
|
const username = primaryIdentity?.email || jmapUsername;
|
|
if (IS_LITE) {
|
|
nameLiteRefreshToken(slot, username);
|
|
unnamedLiteSlot = null;
|
|
}
|
|
initializeFeatureStores(client);
|
|
|
|
// Register in account store
|
|
const accountId = generateAccountId(username, serverUrl);
|
|
boundAccountId = accountId;
|
|
|
|
// Snapshot current account if switching away and clear stores so
|
|
// the new account starts with a clean email/contact/calendar state.
|
|
const prevAccountId = get().activeAccountId;
|
|
if (prevAccountId && prevAccountId !== accountId) {
|
|
snapshotAccount(prevAccountId);
|
|
clearAllStores();
|
|
}
|
|
|
|
clients.set(accountId, client);
|
|
bindClientStatusHandlers(client, set, get, accountId);
|
|
|
|
accountStore.addAccount({
|
|
label: primaryIdentity?.name || username,
|
|
serverUrl,
|
|
username,
|
|
authMode: 'oauth',
|
|
rememberMe: remember,
|
|
displayName: primaryIdentity?.name || username,
|
|
email: primaryIdentity?.email || username,
|
|
lastLoginAt: Date.now(),
|
|
isConnected: true,
|
|
hasError: false,
|
|
isDefault: accountStore.accounts.length === 0,
|
|
});
|
|
// The refresh-token cookie was written to `slot`. Force the stored
|
|
// cookieSlot to match: addAccount preserves the prior slot when
|
|
// re-adding an existing account, and recomputes via getNextCookieSlot
|
|
// for new accounts (which may disagree if another tab claimed a slot
|
|
// mid-flow). Either way, the cookie's slot is the source of truth.
|
|
accountStore.updateAccount(accountId, { cookieSlot: slot, providerSession: true });
|
|
accountStore.setActiveAccount(accountId);
|
|
|
|
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot);
|
|
void syncAccountDisplayName(accountId, client, primaryIdentity?.name);
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl,
|
|
username,
|
|
client,
|
|
...getClientRateLimitState(client),
|
|
identities,
|
|
primaryIdentity,
|
|
authMode: 'oauth',
|
|
accessToken: access_token,
|
|
tokenExpiresAt: Date.now() + expires_in * 1000,
|
|
connectionLost: false,
|
|
error: null,
|
|
activeAccountId: accountId,
|
|
});
|
|
|
|
import('@/stores/email-store').then(({ useEmailStore }) => {
|
|
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
|
debug.error('Initial data prefetch failed:', err);
|
|
});
|
|
}).catch(() => {});
|
|
|
|
scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId }), accountId);
|
|
|
|
notifyParent('sso:auth-success', { username });
|
|
|
|
// Sync settings from server (only if enabled)
|
|
fetchConfig().then(config => {
|
|
if (!config.settingsSyncEnabled) return;
|
|
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
|
useSettingsStore.getState().enableSync(username, serverUrl);
|
|
applyPreferredIdentity(accountId);
|
|
});
|
|
}).catch(() => {});
|
|
|
|
// Clean up sessionStorage
|
|
if (typeof window !== 'undefined') {
|
|
sessionStorage.removeItem('oauth_cookie_slot');
|
|
sessionStorage.removeItem('oauth_remember');
|
|
}
|
|
|
|
notifySignedIn(get());
|
|
return true;
|
|
} catch (error) {
|
|
debug.error('OAuth login error:', error);
|
|
// The code was redeemed but the session never came up: nothing owns
|
|
// the refresh token the exchange stored.
|
|
if (unnamedLiteSlot !== null) clearLiteRefreshToken(unnamedLiteSlot);
|
|
const errorMsg = error instanceof Error ? error.message : 'generic';
|
|
notifyParent('sso:auth-failure', { error: errorMsg });
|
|
set({
|
|
isLoading: false,
|
|
error: errorMsg,
|
|
isAuthenticated: false,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
client: null,
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
|
|
loginWithServerSso: async (code, state) => {
|
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
|
|
|
try {
|
|
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie.
|
|
// Pass the next-free cookie slot so /api/auth/sso/complete writes the refresh
|
|
// token to the correct per-account jmap_rt_<slot> cookie. Without this the
|
|
// route hardcoded slot 0, which broke "+ Add Account" by overwriting the
|
|
// first account's refresh-token cookie.
|
|
const accountStore = useAccountStore.getState();
|
|
const slot = accountStore.getNextCookieSlot();
|
|
|
|
// SSO token exchange and config fetch are independent - fire both
|
|
// up front and let them resolve in parallel.
|
|
const [ssoRes, config] = await Promise.all([
|
|
apiFetch('/api/auth/sso/complete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ code, state, slot }),
|
|
}).then((res) => noteSessionAnswer(res, slot)),
|
|
fetchConfig(),
|
|
]);
|
|
|
|
if (!ssoRes.ok) {
|
|
const errorData = await ssoRes.json().catch(() => ({ error: 'token_exchange_failed' }));
|
|
throw new Error(errorData.error || 'token_exchange_failed');
|
|
}
|
|
|
|
// `remember` is what the login page chose when the flow started.
|
|
const { access_token, expires_in, remember } = await ssoRes.json();
|
|
|
|
const ssoServerUrl = config.jmapServerUrl;
|
|
|
|
if (!ssoServerUrl) {
|
|
throw new Error('Server URL not configured');
|
|
}
|
|
|
|
let boundAccountId: string | undefined;
|
|
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => get().refreshAccessToken({ accountId: boundAccountId }));
|
|
await client.connect();
|
|
|
|
const jmapUsername = client.getUsername();
|
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), jmapUsername);
|
|
// For SSO/OIDC, the JMAP session account name may be the
|
|
// preferred_username claim rather than the real email address.
|
|
// Prefer the email from the primary identity when available.
|
|
const username = primaryIdentity?.email || jmapUsername;
|
|
initializeFeatureStores(client);
|
|
|
|
const accountId = generateAccountId(username, ssoServerUrl);
|
|
boundAccountId = accountId;
|
|
|
|
const prevAccountId = get().activeAccountId;
|
|
if (prevAccountId && prevAccountId !== accountId) {
|
|
snapshotAccount(prevAccountId);
|
|
clearAllStores();
|
|
}
|
|
|
|
clients.set(accountId, client);
|
|
bindClientStatusHandlers(client, set, get, accountId);
|
|
|
|
accountStore.addAccount({
|
|
label: primaryIdentity?.name || username,
|
|
serverUrl: ssoServerUrl,
|
|
username,
|
|
authMode: 'oauth',
|
|
rememberMe: remember !== false,
|
|
displayName: primaryIdentity?.name || username,
|
|
email: primaryIdentity?.email || username,
|
|
lastLoginAt: Date.now(),
|
|
isConnected: true,
|
|
hasError: false,
|
|
isDefault: accountStore.accounts.length === 0,
|
|
});
|
|
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete.
|
|
// Force the stored cookieSlot to match - see loginWithOAuth above for the
|
|
// re-add and concurrent-tab cases this guards against.
|
|
accountStore.updateAccount(accountId, { cookieSlot: slot, providerSession: true });
|
|
accountStore.setActiveAccount(accountId);
|
|
|
|
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot);
|
|
void syncAccountDisplayName(accountId, client, primaryIdentity?.name);
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl: ssoServerUrl,
|
|
username,
|
|
client,
|
|
...getClientRateLimitState(client),
|
|
identities,
|
|
primaryIdentity,
|
|
authMode: 'oauth',
|
|
accessToken: access_token,
|
|
tokenExpiresAt: Date.now() + expires_in * 1000,
|
|
connectionLost: false,
|
|
error: null,
|
|
activeAccountId: accountId,
|
|
});
|
|
|
|
import('@/stores/email-store').then(({ useEmailStore }) => {
|
|
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
|
debug.error('Initial data prefetch failed:', err);
|
|
});
|
|
}).catch(() => {});
|
|
|
|
scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId }), accountId);
|
|
|
|
notifyParent('sso:auth-success', { username });
|
|
|
|
fetchConfig().then(cfg => {
|
|
if (!cfg.settingsSyncEnabled) return;
|
|
useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => {
|
|
useSettingsStore.getState().enableSync(username, ssoServerUrl);
|
|
applyPreferredIdentity(accountId);
|
|
});
|
|
}).catch(() => {});
|
|
|
|
notifySignedIn(get());
|
|
return true;
|
|
} catch (error) {
|
|
debug.error('Server SSO login error:', error);
|
|
const errorMsg = error instanceof Error ? error.message : 'generic';
|
|
notifyParent('sso:auth-failure', { error: errorMsg });
|
|
set({
|
|
isLoading: false,
|
|
error: errorMsg,
|
|
isAuthenticated: false,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
client: null,
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
|
|
refreshAccessToken: async (options) => {
|
|
// Every multi-account client and timer passes its own account: renewing
|
|
// the active slot on a background account's behalf would hand that
|
|
// client another account's token (#989).
|
|
const accountId = options?.accountId ?? get().activeAccountId;
|
|
if (accountId) {
|
|
const inFlight = refreshPromises.get(accountId);
|
|
if (inFlight) return inFlight;
|
|
} else if (refreshPromise) {
|
|
return refreshPromise;
|
|
}
|
|
|
|
const account = accountId ? useAccountStore.getState().getAccountById(accountId) : null;
|
|
const slot = account?.cookieSlot ?? 0;
|
|
const isActive = !accountId || accountId === get().activeAccountId;
|
|
// The account is being signed out: a renewal now would only undo that.
|
|
if (closingSlots.has(slot)) return null;
|
|
|
|
const promise = (async () => {
|
|
try {
|
|
// Default to forcing a genuine refresh: the usual caller was told
|
|
// the current token is unusable (rejected by JMAP, or due for
|
|
// scheduled renewal), so the server-side cache must be skipped.
|
|
// Session restore passes allowCached to reuse a still-valid token.
|
|
const res = await fetchSlotAccessToken(slot, { force: !options?.allowCached, remember: account?.rememberMe });
|
|
|
|
if (!res.ok) {
|
|
// Only a definitive 401 ends the session. Anything else (5xx
|
|
// while the server restarts, proxy errors) is an outage - keep
|
|
// the session and retry shortly so "stay signed in" survives
|
|
// maintenance windows and offline spells.
|
|
if (res.status === 401) {
|
|
resetRefreshBackoff(accountId ?? undefined);
|
|
// Only this login's refresh token was refused.
|
|
if (accountId && !isActive) {
|
|
get().removeAccount(accountId);
|
|
return null;
|
|
}
|
|
await saveDraftsBeforeExpiry(slot);
|
|
notifyParent('sso:session-expired');
|
|
markSessionExpired();
|
|
get().logout({ endProviderSession: false });
|
|
return null;
|
|
}
|
|
// A Bulwark-side failure (500/502) that keeps repeating will not
|
|
// heal by waiting: stop retrying and ask for a fresh sign-in. (#972)
|
|
if (recordPermanentRefreshFailure(res.status, accountId ?? undefined)) {
|
|
debug.error(`Token refresh failed permanently (${res.status}) ${MAX_PERMANENT_REFRESH_FAILURES} times - signing out`);
|
|
resetRefreshBackoff(accountId ?? undefined);
|
|
notifySignInAgain();
|
|
notifyParent('sso:session-expired');
|
|
markSessionExpired();
|
|
// removeAccount() on the active account would sign out as if the user asked to.
|
|
if (accountId && accountId !== get().activeAccountId) get().removeAccount(accountId);
|
|
else {
|
|
await saveDraftsBeforeExpiry(slot);
|
|
get().logout({ endProviderSession: false });
|
|
}
|
|
return null;
|
|
}
|
|
if (shouldRetryRefresh(slot, accountId ?? undefined)) {
|
|
const retryIn = nextRefreshRetrySeconds(accountId ?? undefined);
|
|
debug.error(`Token refresh unavailable (${res.status}), retrying with backoff`);
|
|
scheduleRefresh(retryIn, () => get().refreshAccessToken({ accountId: accountId ?? undefined }), accountId ?? undefined);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const { access_token, expires_in } = await res.json();
|
|
|
|
// Signed out while the renewal was in flight: nothing to keep alive.
|
|
if (closingSlots.has(slot)) return null;
|
|
|
|
const owner = (accountId ? clients.get(accountId) : undefined) ?? (isActive ? get().client : null);
|
|
owner?.updateAccessToken(access_token);
|
|
|
|
if (account) {
|
|
await syncStalwartAuthContext(
|
|
account.serverUrl,
|
|
account.username,
|
|
`Bearer ${access_token}`,
|
|
slot,
|
|
);
|
|
}
|
|
|
|
if (isActive) {
|
|
set({
|
|
accessToken: access_token,
|
|
tokenExpiresAt: Date.now() + expires_in * 1000,
|
|
});
|
|
}
|
|
|
|
resetRefreshBackoff(accountId ?? undefined);
|
|
scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId: accountId ?? undefined }), accountId ?? undefined);
|
|
if (accountId) void authHooks.onTokenRefresh.emit({ accountId });
|
|
return access_token;
|
|
} catch (error) {
|
|
// Network failure (offline, Wi-Fi switch, server unreachable) -
|
|
// not a rejection. Keep the session and retry with backoff.
|
|
debug.error('Token refresh failed, retrying with backoff:', error);
|
|
if (shouldRetryRefresh(slot, accountId ?? undefined)) {
|
|
scheduleRefresh(nextRefreshRetrySeconds(accountId ?? undefined), () => get().refreshAccessToken({ accountId: accountId ?? undefined }), accountId ?? undefined);
|
|
}
|
|
return null;
|
|
} finally {
|
|
if (accountId) refreshPromises.delete(accountId);
|
|
else refreshPromise = null;
|
|
}
|
|
})();
|
|
|
|
if (accountId) refreshPromises.set(accountId, promise);
|
|
else refreshPromise = promise;
|
|
|
|
return promise;
|
|
},
|
|
|
|
logout: async (options) => {
|
|
const state = get();
|
|
const wasDemoMode = state.isDemoMode;
|
|
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;
|
|
// `options` can be a click event when the action is wired straight to onClick.
|
|
const userInitiated = options?.endProviderSession !== false;
|
|
const endProviderSession = userInitiated && !wasDemoMode && shouldEndProviderSession(account);
|
|
|
|
await beforeLogoutHooks(accountId ?? 'all');
|
|
|
|
// From here on nothing may renew this slot's session; the cleanup
|
|
// below reopens it once the credentials are gone.
|
|
if (!wasDemoMode) closingSlots.add(slot);
|
|
clearRefreshTimer(accountId ?? undefined);
|
|
|
|
// Disconnect and null out the client BEFORE clearing stores so the
|
|
// page doesn't fire data-loading effects with the stale client.
|
|
const oldClient = state.client;
|
|
set({ client: null });
|
|
oldClient?.disconnect();
|
|
if (oldClient && !wasDemoMode) forgetCalendarSubscriptions(oldClient);
|
|
|
|
// Remove client from multi-account map
|
|
if (accountId) {
|
|
clients.delete(accountId);
|
|
evictAccount(accountId);
|
|
accountStore.removeAccount(accountId);
|
|
forgetPrincipals(accountId);
|
|
if (!wasDemoMode) clearPluginStorageForAccount(accountId);
|
|
}
|
|
|
|
await useSettingsStore.getState().flushSync();
|
|
useSettingsStore.getState().disableSync();
|
|
|
|
void authHooks.onAfterLogout.emit({
|
|
accountId: accountId ?? 'all',
|
|
});
|
|
|
|
// Check if there are remaining accounts to switch to. Read the store
|
|
// afresh: `accountStore` is the state from before removeAccount().
|
|
// Prefer an account that is signed in here: one restored from the
|
|
// account list backup (or otherwise not connected) has no client, and
|
|
// picking it signs out every account left.
|
|
const remainingAccounts = useAccountStore.getState().accounts;
|
|
const nextAccount = wasDemoMode
|
|
? undefined
|
|
: remainingAccounts.find((a) => clients.has(a.id)) ?? remainingAccounts[0];
|
|
const droppedAccounts: AccountEntry[] = [];
|
|
|
|
if (nextAccount) {
|
|
// Switch to the next account - this is the one path that stays in-app
|
|
clearAllStores();
|
|
|
|
const nextClient = clients.get(nextAccount.id);
|
|
if (nextClient) {
|
|
const restored = restoreAccount(nextAccount.id);
|
|
accountStore.setActiveAccount(nextAccount.id);
|
|
|
|
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
|
|
const restoredPrimary = restoredIdentities[0] ?? null;
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl: nextAccount.serverUrl,
|
|
username: nextAccount.username,
|
|
client: nextClient,
|
|
authMode: nextAccount.authMode,
|
|
rememberMe: nextAccount.rememberMe,
|
|
connectionLost: false,
|
|
error: null,
|
|
activeAccountId: nextAccount.id,
|
|
identities: restoredIdentities,
|
|
primaryIdentity: restoredPrimary,
|
|
});
|
|
|
|
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));
|
|
}
|
|
|
|
// Sync was switched off for the signed-out account above; the
|
|
// account staying signed in needs it back.
|
|
resumeSettingsSync(nextAccount);
|
|
|
|
// The provider session is left alone: visiting its logout page
|
|
// would take the user away from the accounts still signed in
|
|
// here, which may share that session. Revoking the token ends
|
|
// Bulwark's grant.
|
|
void clearSlotCredentials(slot, wasOAuth);
|
|
return;
|
|
}
|
|
|
|
// Client not in memory. Trying to async-restore during logout caused
|
|
// the original bug. An account signed in before this page load (the
|
|
// one being signed out was added after an OAuth redirect or a reload
|
|
// of the add-account page) was simply never restored here: reload,
|
|
// and the usual restore picks it up.
|
|
if (!nextAccount.hasError && !nextAccount.awaitingSignIn) {
|
|
accountStore.setActiveAccount(nextAccount.id);
|
|
set({ activeAccountId: nextAccount.id, serverUrl: nextAccount.serverUrl, username: nextAccount.username });
|
|
await clearSlotCredentials(slot, wasOAuth);
|
|
replaceWindowLocation(`${getPathPrefix()}/${getLocaleFromPath()}`);
|
|
return;
|
|
}
|
|
|
|
// Its restore failed - sign out fully instead.
|
|
// A full logout leaves no account behind: drop every remaining one
|
|
// and clear its slot too. Left in place, its remembered session
|
|
// would sign the next visitor of this browser straight back in.
|
|
debug.error(`Cannot restore next account ${nextAccount.id}, performing full logout`);
|
|
for (const remaining of useAccountStore.getState().accounts) {
|
|
droppedAccounts.push(remaining);
|
|
clearRefreshTimer(remaining.id);
|
|
clients.get(remaining.id)?.disconnect();
|
|
clients.delete(remaining.id);
|
|
evictAccount(remaining.id);
|
|
accountStore.removeAccount(remaining.id);
|
|
}
|
|
}
|
|
|
|
// Full logout. The credentials are cleared before the page state:
|
|
// once signed out, the pages' auth guards head for the login page and
|
|
// would cut the cleanup short. Awaiting it also means the provider's
|
|
// logout URL is known before navigating, and nothing is left to
|
|
// resume the session when the browser comes back.
|
|
const [endSessionUrl] = wasDemoMode ? [null] : await Promise.all([
|
|
clearSlotCredentials(slot, wasOAuth, endProviderSession),
|
|
...droppedAccounts.map((dropped) => clearSlotCredentials(dropped.cookieSlot ?? 0, dropped.authMode === 'oauth')),
|
|
]);
|
|
|
|
performFullLogout(set);
|
|
|
|
notifyParent('sso:logout');
|
|
|
|
finishSignOut(endSessionUrl, userInitiated);
|
|
},
|
|
|
|
// Remove a specific (typically non-active) account: tear down its client,
|
|
// drop it from the registry, and clear its per-slot cookies. If asked to
|
|
// remove the active account, defer to logout() which handles switching
|
|
// away or redirecting.
|
|
removeAccount: (accountId: string) => {
|
|
if (accountId === get().activeAccountId) { void get().logout(); return; }
|
|
const accountStore = useAccountStore.getState();
|
|
const account = accountStore.getAccountById(accountId);
|
|
if (!account) return;
|
|
const slot = account.cookieSlot ?? 0;
|
|
const wasOAuth = account.authMode === 'oauth';
|
|
|
|
// Started first: the cleanup marks the slot closing right away, so a
|
|
// renewal in flight cannot bring the session back.
|
|
void clearSlotCredentials(slot, wasOAuth);
|
|
clearRefreshTimer(accountId);
|
|
const client = clients.get(accountId);
|
|
if (client) {
|
|
forgetCalendarSubscriptions(client);
|
|
try { client.disconnect(); } catch { /* noop */ }
|
|
}
|
|
clients.delete(accountId);
|
|
evictAccount(accountId);
|
|
forgetAccountRows(accountId, get().client);
|
|
accountStore.removeAccount(accountId);
|
|
forgetPrincipals(accountId);
|
|
clearPluginStorageForAccount(accountId);
|
|
},
|
|
|
|
logoutAll: async (options) => {
|
|
await beforeLogoutHooks('all');
|
|
|
|
const accountStore = useAccountStore.getState();
|
|
const allAccounts = [...accountStore.accounts];
|
|
// `options` can be a click event when the action is wired straight to onClick.
|
|
const endProviderSession = options?.endProviderSession !== false;
|
|
const endSessionSlot = get().isDemoMode || !endProviderSession ? null : pickEndSessionSlot(allAccounts, get().activeAccountId);
|
|
|
|
// Starts by marking every slot closing, so nothing renews a session
|
|
// while the clients are torn down.
|
|
const cleanup = clearAllCredentials(endSessionSlot);
|
|
|
|
// The settings are reset below; save the last edits first.
|
|
await useSettingsStore.getState().flushSync();
|
|
|
|
// Disconnect all clients
|
|
set({ client: null });
|
|
for (const c of clients.values()) {
|
|
c.disconnect();
|
|
}
|
|
clients.clear();
|
|
clearAllRefreshTimers();
|
|
evictAll();
|
|
|
|
// Credentials are cleared before the page state - see logout().
|
|
const endSessionUrl = await cleanup;
|
|
|
|
performFullLogout(set);
|
|
|
|
// Clear all accounts from registry
|
|
for (const account of allAccounts) {
|
|
accountStore.removeAccount(account.id);
|
|
}
|
|
|
|
void authHooks.onAfterLogout.emit({
|
|
accountId: 'all'
|
|
});
|
|
|
|
notifyParent('sso:logout');
|
|
|
|
finishSignOut(endSessionUrl, true);
|
|
},
|
|
|
|
switchAccount: async (accountId: string) => {
|
|
const state = get();
|
|
if (state.activeAccountId === accountId) return;
|
|
const generation = ++switchGeneration;
|
|
const superseded = () => generation !== switchGeneration;
|
|
|
|
const accountStore = useAccountStore.getState();
|
|
const targetAccount = accountStore.getAccountById(accountId);
|
|
if (!targetAccount) return;
|
|
// Restored from the account list backup and never signed in here:
|
|
// there is no session to resume, so ask for the password instead.
|
|
if (targetAccount.awaitingSignIn && !clients.has(accountId)) {
|
|
replaceWindowLocation(`${getLocaleLoginPath()}?mode=add-account&username=${encodeURIComponent(targetAccount.username)}`);
|
|
return;
|
|
}
|
|
|
|
// A switch between two already-connected accounts is done seamlessly:
|
|
// the current account's client and mail stay on screen while we verify
|
|
// the target session, then the store state is swapped in one synchronous
|
|
// batch (further below). Nulling the client / raising isLoading here
|
|
// would trip the page's full-screen loading gate and blank the whole app
|
|
// mid-switch, so we only do that when the target must be re-connected
|
|
// over the network (nothing worth keeping on screen while we wait).
|
|
let targetClient = clients.get(accountId);
|
|
const wasConnected = !!targetClient;
|
|
let targetRestoreRateLimited = false;
|
|
|
|
if (!targetClient) {
|
|
// Null out the client immediately so the page doesn't fire data-loading
|
|
// effects with the old client while stores are being cleared.
|
|
set({ isLoading: true, client: null, isRateLimited: false, rateLimitUntil: null });
|
|
|
|
// Snapshot current account, then clear - there's nothing to keep on
|
|
// screen during the network round-trip.
|
|
snapshotAndClearForSwitch(get().activeAccountId);
|
|
await useSettingsStore.getState().flushSync();
|
|
useSettingsStore.getState().disableSync();
|
|
|
|
// Client not connected - try to restore
|
|
try {
|
|
if (targetAccount.authMode === 'oauth') {
|
|
const res = await fetchSlotAccessToken(targetAccount.cookieSlot, { remember: targetAccount.rememberMe });
|
|
if (res.ok) {
|
|
const { access_token, expires_in } = await res.json();
|
|
targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => get().refreshAccessToken({ accountId }));
|
|
bindClientStatusHandlers(targetClient, set, get, accountId);
|
|
await targetClient.connect();
|
|
clients.set(accountId, targetClient);
|
|
scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId }), accountId);
|
|
await syncStalwartAuthContext(
|
|
targetAccount.serverUrl,
|
|
targetAccount.username,
|
|
targetClient.getAuthHeader(),
|
|
targetAccount.cookieSlot,
|
|
);
|
|
}
|
|
} else if (targetAccount.rememberMe) {
|
|
const res = await fetchSlotSession(targetAccount.cookieSlot);
|
|
if (res.ok) {
|
|
const session: RememberedSession = await res.json();
|
|
targetClient = clientForSession(session);
|
|
bindClientStatusHandlers(targetClient, set, get, accountId);
|
|
await targetClient.connect();
|
|
clients.set(accountId, targetClient);
|
|
await syncStalwartAuthContext(session.serverUrl, session.username, targetClient.getAuthHeader(), targetAccount.cookieSlot);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
debug.error(`Failed to restore client for ${accountId}:`, err);
|
|
if (isRateLimitError(err)) {
|
|
targetRestoreRateLimited = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// A newer switch owns the screen now. A client restored here stays in
|
|
// the pool for when the account is picked again.
|
|
if (superseded()) return;
|
|
|
|
if (!targetClient) {
|
|
if (targetRestoreRateLimited) {
|
|
if (state.activeAccountId && state.activeAccountId !== accountId) {
|
|
const prevClient = clients.get(state.activeAccountId);
|
|
const prevAccount = accountStore.getAccountById(state.activeAccountId);
|
|
if (prevClient && prevAccount) {
|
|
restoreAccount(state.activeAccountId);
|
|
storesClearedForSwitch = false;
|
|
accountStore.setActiveAccount(state.activeAccountId);
|
|
set({
|
|
isLoading: false,
|
|
serverUrl: prevAccount.serverUrl,
|
|
username: prevAccount.username,
|
|
client: prevClient,
|
|
...getClientRateLimitState(prevClient),
|
|
authMode: prevAccount.authMode,
|
|
rememberMe: prevAccount.rememberMe,
|
|
connectionLost: false,
|
|
error: 'connection_failed',
|
|
activeAccountId: state.activeAccountId,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
|
|
return;
|
|
}
|
|
|
|
// Cannot restore - remove the stale account and redirect to login
|
|
evictAccount(accountId);
|
|
accountStore.removeAccount(accountId);
|
|
void clearSlotCredentials(targetAccount.cookieSlot, targetAccount.authMode === 'oauth');
|
|
|
|
// Restore the previous account if still available
|
|
if (state.activeAccountId && state.activeAccountId !== accountId) {
|
|
const prevClient = clients.get(state.activeAccountId);
|
|
const prevAccount = accountStore.getAccountById(state.activeAccountId);
|
|
if (prevClient && prevAccount) {
|
|
restoreAccount(state.activeAccountId);
|
|
storesClearedForSwitch = false;
|
|
accountStore.setActiveAccount(state.activeAccountId);
|
|
set({
|
|
isLoading: false,
|
|
serverUrl: prevAccount.serverUrl,
|
|
username: prevAccount.username,
|
|
client: prevClient,
|
|
...getClientRateLimitState(prevClient),
|
|
authMode: prevAccount.authMode,
|
|
rememberMe: prevAccount.rememberMe,
|
|
connectionLost: false,
|
|
activeAccountId: state.activeAccountId,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
set({ isLoading: false });
|
|
// Redirect to login so the user can re-authenticate
|
|
replaceWindowLocation(getLocaleLoginPath());
|
|
return;
|
|
}
|
|
|
|
// GUARD: verify the connected session actually belongs to the target
|
|
// account before we bind it. A corrupted slot->token mapping (e.g.
|
|
// persisted client state left over from an older build, or any future
|
|
// slot desync) can hand back a *different* account's token; the
|
|
// connection then succeeds and we would silently show the wrong
|
|
// mailbox. We accept the session when it matches the stored accountId
|
|
// OR the server identity captured at this account's login (so a short
|
|
// login username the server canonicalizes to a full address is still
|
|
// recognized). On a genuine mismatch, drop the poisoned cookies for
|
|
// this slot and force a clean re-auth instead of surfacing someone
|
|
// else's mail.
|
|
const connectedCandidates = await connectedAccountCandidates(targetClient, targetAccount.serverUrl);
|
|
if (superseded()) return;
|
|
const verdict = classifySessionMatch(connectedCandidates, accountId, targetAccount.serverIdentifiers);
|
|
if (verdict === 'reject') {
|
|
debug.error(`switchAccount: slot ${targetAccount.cookieSlot} for ${accountId} resolved to [${connectedCandidates.join(", ")}] — forcing re-auth`);
|
|
clients.delete(accountId);
|
|
try { targetClient.disconnect(); } catch { /* noop */ }
|
|
void clearSlotCredentials(targetAccount.cookieSlot, true);
|
|
accountStore.updateAccount(accountId, { isConnected: false, hasError: true, errorMessage: 'session_mismatch' });
|
|
set({ isLoading: false, error: 'connection_failed', activeAccountId: state.activeAccountId });
|
|
replaceWindowLocation(getLocaleLoginPath());
|
|
return;
|
|
}
|
|
// Refresh (or, for a legacy 'trust' entry, establish) the identity
|
|
// baseline now that we've confirmed a good session.
|
|
if (connectedCandidates.length > 0) {
|
|
accountStore.updateAccount(accountId, { serverIdentifiers: connectedCandidates });
|
|
}
|
|
|
|
// Seamless path: the outgoing account is still on screen (we deferred
|
|
// clearing it), so snapshot + clear it now - synchronously, right before
|
|
// the restore and client swap below - so the UI never blanks between the
|
|
// two accounts. The network path already snapshotted and cleared above.
|
|
// The settings flush is a request, so it goes first: awaited after the
|
|
// clear, the list showed "No emails" until it came back.
|
|
if (wasConnected) {
|
|
await useSettingsStore.getState().flushSync();
|
|
useSettingsStore.getState().disableSync();
|
|
if (superseded()) return;
|
|
snapshotAndClearForSwitch(get().activeAccountId);
|
|
}
|
|
|
|
// Restore cached state or fetch fresh
|
|
const restored = restoreAccount(accountId);
|
|
storesClearedForSwitch = false;
|
|
accountStore.setActiveAccount(accountId);
|
|
accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
|
|
|
|
// Build identity state up front so the name updates atomically
|
|
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
|
|
const restoredPrimary = restoredIdentities[0] ?? null;
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl: targetAccount.serverUrl,
|
|
username: targetAccount.username,
|
|
client: targetClient,
|
|
...getClientRateLimitState(targetClient),
|
|
authMode: targetAccount.authMode,
|
|
rememberMe: targetAccount.rememberMe,
|
|
connectionLost: false,
|
|
error: null,
|
|
activeAccountId: accountId,
|
|
identities: restoredIdentities,
|
|
primaryIdentity: restoredPrimary,
|
|
});
|
|
|
|
if (!restored) {
|
|
// Fetch fresh data
|
|
try {
|
|
const { identities, primaryIdentity } = loadIdentities(await targetClient.getIdentities(), targetAccount.username);
|
|
// Switched on again while these loaded: they belong to this
|
|
// account, not the one now active.
|
|
if (superseded()) return;
|
|
set({ identities, primaryIdentity });
|
|
initializeFeatureStores(targetClient);
|
|
} catch (err) {
|
|
debug.error(`Failed to load data for ${accountId}:`, err);
|
|
}
|
|
}
|
|
void syncAccountDisplayName(accountId, targetClient, get().primaryIdentity?.name);
|
|
|
|
// Sync settings
|
|
resumeSettingsSync(targetAccount);
|
|
},
|
|
|
|
checkAuth: async () => {
|
|
const accountStore = useAccountStore.getState();
|
|
let accounts = accountStore.accounts;
|
|
|
|
// If every account is a demo account, re-initialize demo mode
|
|
// instead of trying to restore a server session (which doesn't exist).
|
|
if (accounts.length > 0 && accounts.every((a) => a.serverUrl === DEMO_SERVER_URL)) {
|
|
await get().loginDemo();
|
|
return;
|
|
}
|
|
|
|
// Orphan-cookie adoption - when no accounts are registered but a
|
|
// basic-auth session cookie is present (set by /api/auth/impersonate
|
|
// or by another server-side hand-off), promote it into the account
|
|
// registry so the normal restoration path picks it up. Without this
|
|
// the cookies sit unused and the SPA bounces to the login screen.
|
|
// (Not in Lite: nothing hands sessions over server-side.)
|
|
if (accounts.length === 0 && !IS_LITE) {
|
|
try {
|
|
const restore = await apiFetch('/api/auth/session', { method: 'PUT' });
|
|
if (restore.ok) {
|
|
const data = await restore.json();
|
|
if (data?.serverUrl && data?.username && (data?.password || data?.token)) {
|
|
// Stalwart master-user impersonation uses "target%master" as
|
|
// the auth username. The full string must be preserved for
|
|
// JMAP auth, but the user-facing display (avatar, switcher,
|
|
// sign-out copy) should only show the target mailbox.
|
|
const fullUsername: string = data.username;
|
|
const displayMailbox = fullUsername.includes('%')
|
|
? fullUsername.split('%', 1)[0]
|
|
: fullUsername;
|
|
accountStore.addAccount({
|
|
label: displayMailbox,
|
|
serverUrl: data.serverUrl,
|
|
username: fullUsername,
|
|
authMode: data.token ? 'token' : 'basic',
|
|
rememberMe: true,
|
|
displayName: displayMailbox,
|
|
email: displayMailbox,
|
|
lastLoginAt: Date.now(),
|
|
isConnected: false,
|
|
hasError: false,
|
|
isDefault: true,
|
|
});
|
|
accounts = useAccountStore.getState().accounts;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
debug.error('Orphan session cookie adoption failed:', err);
|
|
}
|
|
}
|
|
|
|
// Multi-account restoration: restore all registered accounts
|
|
if (accounts.length > 0) {
|
|
// Null out client so the page doesn't fire data-loading effects
|
|
// with a stale client reference while we're restoring accounts.
|
|
set({ isLoading: true, client: null });
|
|
|
|
// Determine which account to activate first
|
|
const defaultAccount = accountStore.getDefaultAccount();
|
|
const activeId = get().activeAccountId;
|
|
const targetId = activeId || defaultAccount?.id || accounts[0].id;
|
|
|
|
// Restore one account: decrypt the stored credential/token, connect,
|
|
// and sync the passthrough auth context. The context write never
|
|
// throws and doesn't depend on the connect result, so its round trip
|
|
// overlaps the connect instead of running after it.
|
|
// Accounts signed in without "Remember me": a reload ends them on
|
|
// purpose, so it is not reported as an expired session.
|
|
let forgottenAccounts = 0;
|
|
const restoreAccount = async (account: (typeof accounts)[number]) => {
|
|
if (clients.has(account.id)) return; // Already connected
|
|
// Restored from the account list backup: listed until signed in.
|
|
if (account.awaitingSignIn) return;
|
|
|
|
// A password or access token login without rememberMe leaves
|
|
// nothing to restore - the user logged in without persisting
|
|
// credentials. Evict silently so the login screen is shown without
|
|
// flagging a fake error. (Lite keeps a tab-scoped session either
|
|
// way; a missing one surfaces as a 401 from fetchSlotSession below
|
|
// and evicts too.) A password + TOTP login runs on tokens, but
|
|
// without rememberMe it ends the same way: its refresh token goes.
|
|
// So does a provider sign-in made with the box left unticked.
|
|
if (!account.rememberMe && !IS_LITE) {
|
|
forgottenAccounts += 1;
|
|
evictAccount(account.id);
|
|
accountStore.removeAccount(account.id);
|
|
if (account.authMode === 'oauth') void clearSlotCredentials(account.cookieSlot, true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (account.authMode === 'oauth') {
|
|
const res = await fetchSlotAccessToken(account.cookieSlot);
|
|
if (res.ok) {
|
|
const { access_token, expires_in } = await res.json();
|
|
const client = JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => get().refreshAccessToken({ accountId: account.id }));
|
|
bindClientStatusHandlers(client, set, get, account.id);
|
|
const contextSync = syncStalwartAuthContext(account.serverUrl, account.username, client.getAuthHeader(), account.cookieSlot);
|
|
await client.connect();
|
|
clients.set(account.id, client);
|
|
scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId: account.id }), account.id);
|
|
await contextSync;
|
|
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
|
void syncAccountDisplayName(account.id, client);
|
|
} else if (res.status >= 500 && !recordPermanentRefreshFailure(res.status, account.id)) {
|
|
throw new TransientAuthError('Token refresh failed', res.status);
|
|
} else {
|
|
// Repeated 500/502 (see recordPermanentRefreshFailure) falls
|
|
// through here and evicts the account like a rejection. (#972)
|
|
if (res.status >= 500) notifySignInAgain();
|
|
throw new Error(`Token refresh failed: ${res.status}`);
|
|
}
|
|
} else {
|
|
const res = await fetchSlotSession(account.cookieSlot);
|
|
if (res.ok) {
|
|
const session: RememberedSession = await res.json();
|
|
const client = clientForSession(session);
|
|
bindClientStatusHandlers(client, set, get, account.id);
|
|
const contextSync = syncStalwartAuthContext(session.serverUrl, session.username, client.getAuthHeader(), account.cookieSlot);
|
|
await client.connect();
|
|
clients.set(account.id, client);
|
|
await contextSync;
|
|
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
|
void syncAccountDisplayName(account.id, client);
|
|
} else if (res.status >= 500) {
|
|
throw new TransientAuthError('Session restore failed', res.status);
|
|
} else {
|
|
throw new Error(`Session cookie missing: ${res.status}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
debug.error(`Failed to restore account ${account.id}:`, err);
|
|
if (isRateLimitError(err)) {
|
|
accountStore.updateAccount(account.id, {
|
|
isConnected: false,
|
|
hasError: true,
|
|
errorMessage: 'Temporarily rate limited by server',
|
|
});
|
|
return;
|
|
}
|
|
// Outage or offline - keep the account (and its cookies) so the
|
|
// session resumes once the server is reachable again. Same
|
|
// treatment as the rate-limit case above; only a definitive
|
|
// rejection below evicts.
|
|
if (isTransientAuthError(err)) {
|
|
accountStore.updateAccount(account.id, {
|
|
isConnected: false,
|
|
hasError: true,
|
|
errorMessage: 'Server unreachable',
|
|
});
|
|
return;
|
|
}
|
|
// Remove unrestorable accounts so the user is prompted to log in
|
|
// again rather than seeing a stale error entry forever. Its
|
|
// refresh token goes with it: nothing would use or revoke it.
|
|
evictAccount(account.id);
|
|
accountStore.removeAccount(account.id);
|
|
void clearSlotCredentials(account.cookieSlot, account.authMode === 'oauth');
|
|
}
|
|
};
|
|
|
|
// Restore the account the UI will show first, so time-to-inbox pays
|
|
// for one account's round trips, not every registered account's. The
|
|
// rest connect in the background once the target is up; the revision
|
|
// bump re-runs per-client effects (push binding) over the full set.
|
|
const targetEntry = accounts.find((account) => account.id === targetId);
|
|
const otherAccounts = accounts.filter((account) => account.id !== targetId);
|
|
if (targetEntry) await restoreAccount(targetEntry);
|
|
|
|
// A few at a time rather than one after the other: with many logins
|
|
// the serial walk left the last of them - and with it a complete
|
|
// unified inbox - waiting on every other login's round trips. Each
|
|
// restore handles its own failures, so one slow or broken login
|
|
// holds up only its own slot.
|
|
const restoreRemaining = async () => {
|
|
const queue = [...otherAccounts];
|
|
const worker = async () => {
|
|
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
await restoreAccount(next);
|
|
}
|
|
};
|
|
await Promise.allSettled(
|
|
Array.from({ length: Math.min(RESTORE_CONCURRENCY, queue.length) }, worker),
|
|
);
|
|
};
|
|
|
|
if (clients.has(targetId)) {
|
|
if (otherAccounts.length > 0) {
|
|
set({ restoringAccounts: true });
|
|
void restoreRemaining().then(() => {
|
|
set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1, restoringAccounts: false }));
|
|
});
|
|
}
|
|
} else {
|
|
// Target didn't restore - the fallback below needs the other
|
|
// accounts connected before it can pick one, so wait for them.
|
|
await restoreRemaining();
|
|
}
|
|
|
|
// Activate the target account
|
|
const targetClient = clients.get(targetId);
|
|
const targetAccount = accountStore.getAccountById(targetId);
|
|
if (targetClient && targetAccount) {
|
|
accountStore.setActiveAccount(targetId);
|
|
initializeFeatureStores(targetClient);
|
|
|
|
// Identities only feed the composer's From picker and the settings
|
|
// pages - not the mail list. Load them in the background instead of
|
|
// spending a serial round trip before the UI unblocks.
|
|
const identitiesLoaded = targetClient.getIdentities()
|
|
.then((raw) => {
|
|
const { identities, primaryIdentity } = loadIdentities(raw, targetAccount.username);
|
|
set({ identities, primaryIdentity });
|
|
})
|
|
.catch((err) => debug.error('Failed to load identities during restore:', err));
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl: targetAccount.serverUrl,
|
|
username: targetAccount.username,
|
|
client: targetClient,
|
|
...getClientRateLimitState(targetClient),
|
|
authMode: targetAccount.authMode,
|
|
rememberMe: targetAccount.rememberMe,
|
|
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);
|
|
// The preferred identity can only be applied once identities
|
|
// are known; both loads run concurrently, so join here.
|
|
identitiesLoaded.then(() => applyPreferredIdentity(targetAccount.id));
|
|
});
|
|
}).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,
|
|
...getClientRateLimitState(client),
|
|
identities,
|
|
primaryIdentity,
|
|
authMode: acc.authMode,
|
|
rememberMe: acc.rememberMe,
|
|
connectionLost: false,
|
|
error: null,
|
|
activeAccountId: id,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No accounts could be restored. Accounts restored from the account
|
|
// list backup were never signed in here, so they say nothing about
|
|
// the connection.
|
|
if (accounts.some((account) => {
|
|
const entry = accountStore.getAccountById(account.id);
|
|
return entry && !entry.awaitingSignIn;
|
|
})) {
|
|
set({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
client: null,
|
|
error: 'connection_failed',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (forgottenAccounts === accounts.length) saveRedirectAfterLogin();
|
|
else markSessionExpired();
|
|
set({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
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, isRateLimited: false, rateLimitUntil: null });
|
|
try {
|
|
// Restore, not renewal - let the server hand back the cached
|
|
// token if it is still valid rather than spending a refresh.
|
|
const token = await get().refreshAccessToken({ allowCached: true });
|
|
if (token && state.serverUrl) {
|
|
const refreshFn = get().refreshAccessToken;
|
|
const client = JMAPClient.withBearer(state.serverUrl, token, state.username || '', () => refreshFn());
|
|
await client.connect();
|
|
|
|
const accountId = generateAccountId(state.username || '', state.serverUrl);
|
|
clients.set(accountId, client);
|
|
bindClientStatusHandlers(client, set, get, accountId);
|
|
|
|
// 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);
|
|
void syncAccountDisplayName(accountId, client, primaryIdentity?.name);
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
client,
|
|
...getClientRateLimitState(client),
|
|
identities,
|
|
primaryIdentity,
|
|
accessToken: token,
|
|
activeAccountId: accountId,
|
|
});
|
|
|
|
fetchConfig().then(config => {
|
|
if (!config.settingsSyncEnabled) return;
|
|
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => {
|
|
useSettingsStore.getState().enableSync(state.username || '', state.serverUrl!);
|
|
applyPreferredIdentity(accountId);
|
|
});
|
|
}).catch(() => {});
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
debug.error('OAuth session restore failed:', error);
|
|
if (isRateLimitError(error)) {
|
|
set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
|
|
return;
|
|
}
|
|
clearRefreshTimer();
|
|
}
|
|
}
|
|
|
|
if (state.authMode === 'basic') {
|
|
set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
|
|
try {
|
|
const res = await fetchSlotSession();
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (!data.serverUrl || !data.username || !data.password) {
|
|
debug.error('Session restore returned incomplete data');
|
|
throw new Error('Incomplete session data');
|
|
}
|
|
const { serverUrl, username, password } = data;
|
|
const client = new JMAPClient(serverUrl, username, password);
|
|
await client.connect();
|
|
|
|
const accountId = generateAccountId(username, serverUrl);
|
|
clients.set(accountId, client);
|
|
bindClientStatusHandlers(client, set, get, accountId);
|
|
|
|
// 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 cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0;
|
|
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot);
|
|
|
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
|
initializeFeatureStores(client);
|
|
void syncAccountDisplayName(accountId, client, primaryIdentity?.name);
|
|
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
serverUrl,
|
|
username,
|
|
client,
|
|
...getClientRateLimitState(client),
|
|
identities,
|
|
primaryIdentity,
|
|
authMode: 'basic',
|
|
activeAccountId: accountId,
|
|
});
|
|
|
|
fetchConfig().then(config => {
|
|
if (!config.settingsSyncEnabled) return;
|
|
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
|
useSettingsStore.getState().enableSync(username, serverUrl);
|
|
applyPreferredIdentity(accountId);
|
|
});
|
|
}).catch(() => {});
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
debug.error('Basic session restore failed:', error);
|
|
if (isRateLimitError(error) || isTransientAuthError(error)) {
|
|
set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
markSessionExpired();
|
|
|
|
set({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
isRateLimited: false,
|
|
rateLimitUntil: null,
|
|
client: null,
|
|
serverUrl: null,
|
|
username: null,
|
|
authMode: 'basic',
|
|
rememberMe: false,
|
|
accessToken: null,
|
|
tokenExpiresAt: null,
|
|
activeAccountId: null,
|
|
});
|
|
}
|
|
|
|
set({ isLoading: false });
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
|
|
updateBasicPassword: async (newPassword) => {
|
|
const { client, activeAccountId } = get();
|
|
const account = activeAccountId ? useAccountStore.getState().getAccountById(activeAccountId) : undefined;
|
|
if (!client || !account || account.authMode !== 'basic') return;
|
|
client.updateBasicAuth(newPassword);
|
|
await syncStalwartAuthContext(account.serverUrl, account.username, client.getAuthHeader(), account.cookieSlot);
|
|
if (account.rememberMe || IS_LITE) {
|
|
await persistSession(account.cookieSlot, { serverUrl: account.serverUrl, username: account.username, password: newPassword });
|
|
}
|
|
},
|
|
|
|
signInRestoredAccount: async (accountId, password, rememberMe) => {
|
|
const accountStore = useAccountStore.getState();
|
|
const account = accountStore.getAccountById(accountId);
|
|
if (!account || account.authMode !== 'basic' || clients.has(accountId)) return false;
|
|
const { serverUrl, username, cookieSlot } = account;
|
|
if (await precheckCredentials(serverUrl, { username, password })) return false;
|
|
const client = new JMAPClient(serverUrl, username, password);
|
|
try {
|
|
await client.connect();
|
|
} catch (err) {
|
|
debug.error(`Failed to sign in to restored account ${accountId}:`, err);
|
|
client.disconnect();
|
|
return false;
|
|
}
|
|
// Without a remembered session the next reload would drop the account
|
|
// again, like any sign-in made without "Remember me".
|
|
await Promise.all([
|
|
rememberMe || IS_LITE ? persistSession(cookieSlot, { serverUrl, username, password }) : null,
|
|
syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot),
|
|
]);
|
|
clients.set(accountId, client);
|
|
bindClientStatusHandlers(client, set, get, accountId);
|
|
accountStore.updateAccount(accountId, {
|
|
awaitingSignIn: false, isConnected: true, hasError: false, errorMessage: undefined,
|
|
rememberMe, lastLoginAt: Date.now(), signedInAt: signedInAtForSlot(cookieSlot),
|
|
});
|
|
void syncAccountDisplayName(accountId, client);
|
|
set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 }));
|
|
return true;
|
|
},
|
|
|
|
syncIdentities: () => {
|
|
const identityState = useIdentityStore.getState();
|
|
const identities = identityState.identities;
|
|
const primaryIdentity = identities[0] ?? null;
|
|
set({ identities, primaryIdentity });
|
|
|
|
const { activeAccountId, client } = get();
|
|
if (activeAccountId && client) {
|
|
void syncAccountDisplayName(activeAccountId, client, primaryIdentity?.name);
|
|
}
|
|
},
|
|
|
|
refreshIdentities: async () => {
|
|
const { client, username } = get();
|
|
if (!client || !username) return;
|
|
try {
|
|
const rawIdentities = await client.getIdentities();
|
|
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
|
|
set({ identities, primaryIdentity });
|
|
} catch {
|
|
// Silently fail - background sync should not surface errors to the user
|
|
}
|
|
},
|
|
|
|
getClientForAccount: (accountId: string) => {
|
|
return clients.get(accountId);
|
|
},
|
|
|
|
getAllConnectedClients: () => {
|
|
return new Map(clients);
|
|
},
|
|
}),
|
|
{
|
|
name: 'auth-storage',
|
|
partialize: (state) => {
|
|
// Don't persist unauthenticated state - prevents resurrecting stale sessions
|
|
if (!state.isAuthenticated) return {};
|
|
return {
|
|
serverUrl: state.serverUrl,
|
|
username: state.username,
|
|
authMode: state.authMode,
|
|
isAuthenticated: (state.authMode === 'oauth' || state.rememberMe)
|
|
? state.isAuthenticated
|
|
: undefined,
|
|
rememberMe: state.rememberMe,
|
|
activeAccountId: state.activeAccountId,
|
|
};
|
|
},
|
|
}
|
|
)
|
|
);
|
|
|
|
// Expose getClientForAccount to the calendar/contact stores via a small
|
|
// shared registry - see [[stores/client-registry]] for rationale.
|
|
setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId));
|
|
|
|
// Before signing in, the server only hands out the public part of the admin
|
|
// policy; fetch the rest once a session exists.
|
|
useAuthStore.subscribe((state, prev) => {
|
|
if (state.isAuthenticated && !prev.isAuthenticated) void usePolicyStore.getState().refreshIfPartial();
|
|
});
|
|
|
|
// Domain and group scopes follow the active account. setPolicySubject skips
|
|
// the work when the account's address and groups stayed the same.
|
|
let policySubjectRequest = 0;
|
|
useAuthStore.subscribe((state, prev) => {
|
|
if (
|
|
state.client === prev.client
|
|
&& state.activeAccountId === prev.activeAccountId
|
|
&& state.isAuthenticated === prev.isAuthenticated
|
|
&& state.connectedAccountsRevision === prev.connectedAccountsRevision
|
|
) return;
|
|
const request = ++policySubjectRequest;
|
|
if (!state.isAuthenticated || !state.client) {
|
|
usePolicyStore.getState().setPolicySubject(null);
|
|
return;
|
|
}
|
|
void policySubjectForClient(state.client).then(async (subject) => {
|
|
if (request !== policySubjectRequest) return;
|
|
// The server sends only the scopes of the domains signed in when the
|
|
// policy was fetched; an account of another domain may have its own.
|
|
if (subject && !usePolicyStore.getState().coversAddress(subject.email)) {
|
|
await usePolicyStore.getState().fetchPolicy();
|
|
if (request !== policySubjectRequest) return;
|
|
}
|
|
usePolicyStore.getState().setPolicySubject(subject);
|
|
});
|
|
});
|
|
|
|
// See canEnterLoginInPlace.
|
|
let signedInSincePageLoad = useAuthStore.getState().isAuthenticated;
|
|
useAuthStore.subscribe((state) => {
|
|
if (state.isAuthenticated) signedInSincePageLoad = true;
|
|
});
|
|
|
|
// Another tab signed everyone out: this one's cookies and account list are
|
|
// gone too, so leave for the login page instead of showing stale mail.
|
|
if (typeof window !== 'undefined') {
|
|
onSignedOutElsewhere(() => {
|
|
if (useAuthStore.getState().isAuthenticated) navigateToLogin();
|
|
});
|
|
}
|
|
|
|
// Plugins hear when a session becomes usable (after start-up or a sign-in)
|
|
// and when another account becomes active, whichever path got there.
|
|
useAuthStore.subscribe((state, prev) => {
|
|
const ready = state.isAuthenticated && !!state.client && !!state.activeAccountId;
|
|
const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId;
|
|
if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId });
|
|
if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) {
|
|
void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId });
|
|
}
|
|
});
|