Merge branch 'feat/org-sessions' into feat/org-features

This commit is contained in:
Linus Rath
2026-10-28 04:25:22 +01:00
48 changed files with 3288 additions and 74 deletions
+2
View File
@@ -19,6 +19,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard";
import { IS_LITE } from "@/lib/lite";
import { setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n/routing";
@@ -61,6 +62,7 @@ export default async function LocaleLayout({
<ProInterfaceRedirect />
<ImpersonationReconciler />
<PushBindingsHost />
<SessionTimeoutGuard />
{children}
<PluginDialogHost />
<CommandPaletteHost />
+18 -2
View File
@@ -34,6 +34,7 @@ import { toAsciiDomain } from "@/lib/idn";
import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view";
import { buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff";
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
function completeAddressDomain(username: string): string {
@@ -139,6 +140,7 @@ function VersionBadge() {
function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
const tSessionLimits = useTranslations("session_limits");
const params = useParams();
const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account";
@@ -207,6 +209,8 @@ function LoginPageContent() {
// those sessions then only last as long as the tab (lib/auth/lite-tokens.ts).
const [liteTokenLoginSupported, setLiteTokenLoginSupported] = useState<boolean | null>(null);
const [sessionExpired, setSessionExpired] = useState(false);
// Signed out by the organisation's idle timeout or maximum session length.
const [sessionEnd, setSessionEnd] = useState<SessionEndReason | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
const [showThemeMenu, setShowThemeMenu] = useState(false);
@@ -332,6 +336,11 @@ function LoginPageContent() {
sessionStorage.removeItem('session_expired');
}
} catch { /* sessionStorage unavailable */ }
const ended = consumeSessionEnd();
if (ended) {
setSessionEnd(ended);
setSessionExpired(true);
}
}, []);
useEffect(() => {
@@ -1229,8 +1238,15 @@ function LoginPageContent() {
<Info className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 self-center flex items-center gap-2">
<p className="text-sm text-info flex-1 leading-relaxed">
{t("session_expired")}
<p className="text-sm text-info flex-1 leading-relaxed" data-testid="session-expired-reason">
{sessionEnd?.kind === "idle"
? tSessionLimits("reason_idle", { minutes: sessionEnd.minutes })
: sessionEnd?.kind === "max"
? tSessionLimits("reason_max", { hours: sessionEnd.hours })
: t("session_expired")}
{sessionEnd?.draftsUnsaved && (
<span className="block mt-1" data-testid="session-drafts-not-saved">{tSessionLimits("drafts_not_saved")}</span>
)}
</p>
<button
type="button"
+47 -5
View File
@@ -23,6 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation';
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
function sessionCookieOptions() {
return {
@@ -73,6 +74,37 @@ function getSlot(request: NextRequest): number {
return slot;
}
/**
* The organisation's maximum session length for a remembered session
* (lib/auth/session-age.ts). A cookie from before sign-in times were
* recorded counts as signed in when the server first enforced the limits,
* a fixed time, so a kept copy of it runs out like any other. Returns the
* sign-in time, or the limit when the session is too old: its cookies are
* cleared then.
*/
async function checkSessionAge(
cookieStore: Awaited<ReturnType<typeof cookies>>,
slot: number,
credentials: SessionCredentials,
): Promise<{ signedInAt: number } | { maxHours: number }> {
const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince();
// An impersonation login is `target%master`; the policy is the target's.
const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
if (isPastMaxAge(signedInAt, maxHours)) {
cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
return { maxHours };
}
return { signedInAt };
}
function sessionTooOld(maxHours: number) {
return NextResponse.json(
{ error: SESSION_TOO_OLD, maxHours },
{ status: 401, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
// so a cross-site top-level POST would otherwise reach this handler.
@@ -162,7 +194,9 @@ export async function POST(request: NextRequest) {
throw error;
}
refund();
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password);
// A remembered sign-in starts the maximum session length.
const signedInAt = Date.now();
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, slot, {
@@ -176,7 +210,8 @@ export async function POST(request: NextRequest) {
const warning = insecureCookieHint(request);
if (warning) logger.warn(`session: ${warning}`);
return NextResponse.json(warning ? { ok: true, warning } : { ok: true });
// The browser counts the maximum session length from the server's time.
return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json(
@@ -202,12 +237,15 @@ export async function GET(request: NextRequest) {
}
const credentials = decryptSession(token);
if (!credentials) {
if (!credentials || holdsMasterPassword(credentials)) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
const age = await checkSessionAge(cookieStore, slot, credentials);
if ('maxHours' in age) return sessionTooOld(age.maxHours);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
@@ -217,7 +255,7 @@ export async function GET(request: NextRequest) {
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
{ serverUrl, username },
{ serverUrl, username, signedInAt: age.signedInAt },
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
} catch (error) {
@@ -258,13 +296,17 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
const age = await checkSessionAge(cookieStore, slot, credentials);
if ('maxHours' in age) return sessionTooOld(age.maxHours);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
});
return NextResponse.json(credentials, {
// The browser counts the maximum session length from the server's time.
return NextResponse.json({ ...credentials, signedInAt: age.signedInAt }, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
} catch (error) {
+5
View File
@@ -14,6 +14,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { storeIdToken } from '@/lib/oauth/end-session';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { writeSignInStamp } from '@/lib/auth/session-age';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
@@ -105,6 +106,7 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
const signedInAt = Date.now();
// For the mobile handoff flow the tokens are handed back to the app
// verbatim - we deliberately don't write any cookies on the webmail
@@ -115,6 +117,8 @@ export async function POST(request: NextRequest) {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
@@ -166,6 +170,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
signed_in_at: signedInAt,
});
} catch (error) {
// Clean up pending cookie on any error
+13
View File
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { cookies } from 'next/headers';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age';
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
@@ -101,6 +104,16 @@ export async function POST(request: NextRequest) {
// Just signed in: an older refusal of this credential no longer applies.
recordStoredCredentialStatus(authHeader, 200);
// A token sign-in's stamp (lib/auth/session-age.ts) knows no account:
// the token routes never see one. The first verified account names it,
// so the maximum session length of its domain applies on renewal.
const cookieStore = await cookies();
const refreshToken = cookieStore.get(refreshTokenCookieName(slot))?.value;
const stamp = refreshToken ? readSignInStamp(cookieStore, slot, refreshToken) : null;
if (refreshToken && stamp && !stamp.account) {
writeSignInStamp(cookieStore, slot, { signedInAt: stamp.signedInAt, refreshToken, account: accountName });
}
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
+44
View File
@@ -30,6 +30,15 @@ import {
} from '@/lib/oauth/end-session';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import {
clearSignInStamp,
isPastMaxAge,
maxSessionHoursFor,
SESSION_TOO_OLD,
tokenSignedInAt,
writeSignInStamp,
} from '@/lib/auth/session-age';
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
@@ -142,16 +151,21 @@ export async function POST(request: NextRequest) {
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
// A sign-in: the maximum session length counts from here, on the server
// and, from this answer, in the browser.
const signedInAt = Date.now();
const response = NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
signed_in_at: signedInAt,
});
const cookieStore = await cookies();
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
}
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600, [tokens.refresh_token, serverId]);
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]);
@@ -189,6 +203,25 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
}
// The organisation's maximum session length (lib/auth/session-age.ts).
// A refresh token without a matching stamp (from before stamps, or one
// whose stamp was deleted) counts from the fixed time the server first
// enforced the limits, never from now.
const age = await tokenSignedInAt(cookieStore, slot, refreshToken);
if (age) {
const context = readStalwartAuthContextFromStore(cookieStore, slot);
const account = age.stamp?.account ?? context?.accountName ?? context?.username ?? null;
const maxHours = await maxSessionHoursFor(account);
if (isPastMaxAge(age.signedInAt, maxHours)) {
await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 });
}
// Bind the time to this token from now on.
if (!age.stamp) writeSignInStamp(cookieStore, slot, { signedInAt: age.signedInAt, refreshToken, ...(account ? { account } : {}) });
}
const signedIn = age ? { signed_in_at: age.signedInAt } : {};
// A session restore calls this to get its token back, not because the
// current one expired. Serving the cached token avoids spending a refresh
// the IdP may legitimately reject: Rauthy stamps refresh tokens with
@@ -204,6 +237,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
access_token: cached.accessToken,
expires_in: cached.expiresIn,
...signedIn,
});
}
}
@@ -252,6 +286,14 @@ export async function PUT(request: NextRequest) {
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
// The stamp follows the rotated token, with the same sign-in time and lifetime.
if (age) {
writeSignInStamp(cookieStore, slot, {
signedInAt: age.signedInAt,
refreshToken: tokens.refresh_token,
...(age.stamp?.account ? { account: age.stamp.account } : {}),
});
}
}
const expiresIn = tokens.expires_in || 3600;
@@ -265,6 +307,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
access_token: tokens.access_token,
expires_in: expiresIn,
...signedIn,
});
} catch (error) {
logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' });
@@ -307,6 +350,7 @@ async function signOutSlot(
for (const name of [cookieName, refreshTokenServerCookieName(slot), accessTokenCookieName(slot)]) {
if (cookieStore.get(name)) cookieStore.delete(name);
}
clearSignInStamp(cookieStore, slot);
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
return endSessionUrl;
}
+7 -1
View File
@@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { writeSignInStamp } from '@/lib/auth/session-age';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
@@ -208,11 +209,15 @@ async function storeAndRespond(
rememberMe: boolean,
): Promise<NextResponse> {
const cookieStore = await cookies();
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
const signedInAt = Date.now();
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
// Without "Remember me" the sign-in ends with the browser session.
const { maxAge: _maxAge, ...sessionOptions } = getCookieOptions();
cookieStore.set(cookieName, tokens.refresh_token, rememberMe ? getCookieOptions() : sessionOptions);
const options = rememberMe ? getCookieOptions() : sessionOptions;
cookieStore.set(cookieName, tokens.refresh_token, options);
writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }, options);
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (serverId) {
@@ -225,5 +230,6 @@ async function storeAndRespond(
access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600,
has_refresh_token: !!tokens.refresh_token,
signed_in_at: signedInAt,
});
}
@@ -1,14 +1,91 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import { SettingItem, SettingsSection } from '@/components/settings/settings-section';
import { InheritNote } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/** Organisation feature 4; filled in by its own step (local-data/org-features-plan.md). */
interface SessionField {
key: 'idleMinutes' | 'maxHours' | 'warnSeconds';
label: string;
description: string;
unit: string;
max: number;
/** What 0 means. */
zero: string;
}
const FIELDS: SessionField[] = [
{
key: 'idleMinutes',
label: 'Sign out after inactivity',
description: 'Signs users out of every account in the browser after this many minutes without activity in any Bulwark tab. Open messages are saved as drafts first.',
unit: 'minutes',
max: 7 * 24 * 60,
zero: 'Off',
},
{
key: 'maxHours',
label: 'Maximum session length',
description: 'Signs users out this many hours after they signed in, active or not, also with "Remember me". The server enforces domain values too; group values only apply in the browser.',
unit: 'hours',
max: 24 * 365,
zero: 'Off',
},
{
key: 'warnSeconds',
label: 'Warning before signing out',
description: 'How long a countdown shows before either limit signs users out. During the inactivity warning users can choose to stay signed in.',
unit: 'seconds',
max: 600,
zero: 'No warning',
},
];
function describe(value: number, field: SessionField): string {
return value > 0 ? `${value} ${field.unit}` : field.zero;
}
/** Organisation > Security > Sessions: the idle timeout and the maximum session length. */
export function SessionsSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return (
<SettingsSection id="sessions" title="Sessions" description="Sign users out after a time without activity, or a fixed time after they signed in.">
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
<SettingsSection id="sessions" title="Sessions" description="Sign users out after a time without activity, or a fixed time after they signed in. Both are off until you set them.">
{FIELDS.map((field) => {
const { value, own, everyone } = draft.orgValue('session', field.key);
const inputId = `org-session-${field.key}`;
return (
<SettingItem key={field.key} label={field.label} description={field.description}>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
<input
id={inputId}
type="number"
inputMode="numeric"
min={0}
max={field.max}
step={1}
aria-label={`${field.label} (${field.unit})`}
placeholder={field.zero}
value={value > 0 ? value : ''}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === '' ? 0 : Math.round(Number(raw));
if (!Number.isFinite(parsed)) return;
draft.setOrgValue('session', field.key, Math.min(field.max, Math.max(0, parsed)));
}}
className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-sm text-muted-foreground">{field.unit}</span>
</div>
<InheritNote
draft={draft}
own={own}
everyone={describe(everyone, field)}
onReset={() => draft.clearOrgValue('session', field.key)}
/>
</div>
</SettingItem>
);
})}
</SettingsSection>
);
}
@@ -0,0 +1,308 @@
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer, type ComposerDraftData } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
const updateSetting = vi.fn();
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: (...args: unknown[]) => updateSetting(...args),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
/**
* Before an automatic sign-out (organisation idle timeout or maximum session
* length) every composer saves what the server doesn't have yet, through the
* compose flush registry.
*/
const DRAFT: ComposerDraftData = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Half-written',
body: '<p>Still typing</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose',
draftId: null,
};
let createDraft: ReturnType<typeof vi.fn>;
beforeEach(() => {
resetComposeFlushRegistry();
createDraft = vi.fn().mockResolvedValue('draft-1');
useAuthStore.setState({
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
});
afterEach(() => {
useAuthStore.setState({ client: null });
});
describe('composer flush before an automatic sign-out', () => {
it('saves edits the autosave has not stored yet', async () => {
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
let result: Awaited<ReturnType<typeof flushComposers>> | undefined;
await act(async () => { result = await flushComposers(5000); });
expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false });
expect(createDraft).toHaveBeenCalledTimes(1);
expect(createDraft.mock.calls[0][1]).toBe('Half-written more');
});
it('leaves a composer without changes alone', async () => {
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
await act(async () => { await flushComposers(5000); });
expect(createDraft).not.toHaveBeenCalled();
});
it('saves a minimised composer opened again with its stashed edits', async () => {
render(<EmailComposer initialData={{ ...DRAFT, unsaved: true }} onClose={vi.fn()} />);
await act(async () => { await flushComposers(5000); });
expect(createDraft).toHaveBeenCalledTimes(1);
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
});
it('waits for a send in flight and saves no draft of the sent message', async () => {
let finishSend!: () => void;
const onSend = vi.fn(() => new Promise<void>((resolve) => { finishSend = resolve; }));
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} onSend={onSend} />);
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalled());
const savesBeforeSignOut = createDraft.mock.calls.length;
let done = false;
const flushed = flushComposers(5000).then((r) => { done = true; return r; });
await new Promise((r) => setTimeout(r, 300));
expect(done).toBe(false);
await act(async () => { finishSend(); });
await expect(flushed).resolves.toMatchObject({ total: 1, failed: 0, timedOut: false });
expect(createDraft).toHaveBeenCalledTimes(savesBeforeSignOut);
});
it('reports a save the server refused, so the login page can say so', async () => {
createDraft.mockRejectedValue(new Error('refused'));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
let result: Awaited<ReturnType<typeof flushComposers>> | undefined;
await act(async () => { result = await flushComposers(5000); });
expect(result).toMatchObject({ total: 1, failed: 1 });
} finally {
consoleError.mockRestore();
}
});
it('hands an already-saved state back as saved', async () => {
const onSaveState = vi.fn();
const view = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} onSaveState={onSaveState} />);
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
await act(async () => { await flushComposers(5000); });
view.unmount();
expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: false }));
});
it('marks the state it hands to the host on unmount as unsaved', () => {
const onSaveState = vi.fn();
const view = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} onSaveState={onSaveState} />);
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
view.unmount();
expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: true }));
});
it('unregisters when it unmounts', async () => {
const view = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
view.unmount();
await expect(flushComposers(1000)).resolves.toMatchObject({ total: 0 });
});
});
@@ -6,6 +6,7 @@ import { toast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
// Sending from the viewer - quick reply, "Send now" / "Reschedule" of a
// scheduled message, and read receipts - used to fail silently or as an
@@ -110,6 +111,21 @@ describe('viewer send failures', () => {
expect((box as HTMLTextAreaElement).value).toBe('Thanks!');
});
it('keeps typed quick-reply text as a draft before an automatic sign-out', async () => {
resetComposeFlushRegistry();
const onSaveQuickReplyDraft = vi.fn().mockResolvedValue(undefined);
render(
<EmailViewer email={makeEmail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
onQuickReply={vi.fn()} onSaveQuickReplyDraft={onSaveQuickReplyDraft} />,
);
await act(async () => { await flushComposers(1000); });
expect(onSaveQuickReplyDraft).not.toHaveBeenCalled();
fireEvent.change(screen.getByPlaceholderText('quick_reply_placeholder'), { target: { value: 'On it.' } });
await act(async () => { await flushComposers(1000); });
expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('On it.');
});
const scheduled = () => makeEmail({
isScheduled: true,
scheduledUndoStatus: 'pending',
+33 -1
View File
@@ -133,6 +133,7 @@ import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
import { fileStorage } from "@/lib/plugin-storage";
import { usePolicyStore } from "@/stores/policy-store";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { toUnicodeEmail } from "@/lib/idn";
/**
@@ -141,6 +142,9 @@ import { toUnicodeEmail } from "@/lib/idn";
* <p> blocks are separated by a blank line, matching their visual rendering (#421).
* Quotes get "> " and list items their markers, like any plain-text mail.
*/
/** How often an automatic sign-out looks whether a send in flight has finished. */
const SEND_WAIT_POLL_MS = 100;
function htmlToPlainText(html: string): string {
return htmlToPlainTextShared(html, { paragraphSpacing: true, mailMarkup: true });
}
@@ -231,6 +235,12 @@ export interface ComposerDraftData {
references?: string[];
/** Priority of a re-opened draft, read back from its headers (#970). */
priority?: MessagePriority;
/**
* Handed to the host when the composer went away with edits the server may
* not have: the composer opened again with it counts them as unsaved, so
* an automatic sign-out saves them (lib/compose-flush-registry.ts).
*/
unsaved?: boolean;
}
interface EmailComposerProps {
@@ -1574,7 +1584,8 @@ export function EmailComposer({
// Bumped on every edit and compared with the edit the last successful save
// covered. Closing a message autosave already stored asked about "unsaved
// changes" that were not there.
const changeVersionRef = useRef(0);
// Stashed edits handed back by the host start out unsaved.
const changeVersionRef = useRef(initialData?.unsaved ? 1 : 0);
const savedVersionRef = useRef(0);
const firstChangeRunRef = useRef(true);
useEffect(() => {
@@ -1648,6 +1659,9 @@ export function EmailComposer({
mode,
replyTo,
priority: priorityRef.current,
// Edits the last save didn't cover; a minimised composer with
// none needs no save before an automatic sign-out.
unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current,
});
}
};
@@ -2699,6 +2713,24 @@ export function EmailComposer({
// Keep saveDraftRef pointing to latest saveDraft
saveDraftRef.current = saveDraft;
// An automatic sign-out saves what the server doesn't have yet, with the
// same save the close and send paths use. A send in flight goes first:
// the sign-out waits for it (within its own time limit), and a message
// that went out needs no draft. A save the server refused throws, so the
// login page can say a draft wasn't saved.
useEffect(() => registerComposeFlush(async () => {
while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS));
if (sentRef.current) return;
const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current;
if (!unsaved) return;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
await saveDraftRef.current();
if (saveFailedRef.current) throw new Error('The draft was not saved');
}), []);
// The draft as plugins see it: onDraftChange, the frames above and below
// the body, and clicks on plugin entries in the Send menu.
const draftSnapshot = () => ({
+17
View File
@@ -102,6 +102,7 @@ import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
@@ -175,6 +176,8 @@ interface EmailViewerProps {
onSetTag?: (emailId: string, tagId: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => void;
onQuickReply?: (body: string) => Promise<void>;
/** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */
onSaveQuickReplyDraft?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onMoveToMailbox?: (mailboxId: string) => void;
@@ -1044,6 +1047,7 @@ export function EmailViewer({
onSetTag,
onDownloadAttachment,
onQuickReply,
onSaveQuickReplyDraft,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
@@ -1369,6 +1373,19 @@ export function EmailViewer({
const [quickReplyText, setQuickReplyText] = useState("");
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
// An automatic sign-out keeps typed quick-reply text as a draft; text on
// its way out as a reply needs none.
const quickReplyDraftRef = useRef({ text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft });
quickReplyDraftRef.current = { text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft };
const hasQuickReplyDraft = quickReplyText.trim().length > 0 && !!onSaveQuickReplyDraft;
useEffect(() => {
if (!hasQuickReplyDraft) return;
return registerComposeFlush(async () => {
const { text, sending, save } = quickReplyDraftRef.current;
if (sending || !text.trim() || !save) return;
await save(text);
});
}, [hasQuickReplyDraft]);
const handleSendQuickReply = async () => {
if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
setIsSendingQuickReply(true);
+17 -39
View File
@@ -115,8 +115,7 @@ import { isFilePreviewable } from "@/lib/file-preview";
import { prepareQuickReply } from "@/lib/outgoing/quick-reply";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { findDraftIdentityId, findReplyIdentityId, resolveComposeAccountEmail } from "@/lib/reply-identity";
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
import { resolveComposeAccountEmail } from "@/lib/reply-identity";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft } from "@/components/icons";
import { ResizeHandle } from "@/components/layout/resize-handle";
@@ -159,6 +158,8 @@ import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-lin
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import type { QuoteHeader } from "@/lib/plugin-types";
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useMinimisedComposerFlush } from "@/hooks/use-minimised-composer-flush";
import { quickReplyEnvelope, saveQuickReplyDraft } from "@/lib/outgoing/quick-reply-envelope";
export interface MailAppProps {
/**
@@ -2266,6 +2267,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setPendingDraft(null);
}, []);
// A minimised composer's edits live only in pendingDraft. Before an
// automatic sign-out it is opened again and saves them itself.
useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true));
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
@@ -3847,49 +3852,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
}, [previewAttachment]);
// Before an automatic sign-out: the typed quick reply is kept as a draft.
const handleSaveQuickReplyDraft = async (body: string) => {
if (!client || !selectedEmail) return;
await saveQuickReplyDraft({ client, email: selectedEmail, identities, body, replyPrefix: t('email_composer.prefix.reply') });
};
const handleQuickReply = async (body: string) => {
if (!client || !selectedEmail) return;
// Quick reply follows the same addressing rules as the composer: Reply-To
// over From, and for our own messages in a thread the original recipients
// instead of ourselves (#703).
const ownIdentityEmails = identities.map(i => i.email).filter(Boolean);
const replySource = {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to,
cc: selectedEmail.cc,
};
const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
.map(r => r.email)
.filter((email): email is string => Boolean(email));
// Quick reply follows the composer's addressing rules and sends from the
// address the message was delivered to (lib/outgoing/quick-reply-envelope.ts).
const { recipients, sendingIdentity, primaryIdentity } = quickReplyEnvelope(selectedEmail, identities);
if (recipients.length === 0) {
throw new Error("No sender email found");
}
const primaryIdentity = identities[0];
// Send from the address the message was delivered to, so a reply out of a
// shared or aliased mailbox does not go out as the account owner. Our own
// message keeps the identity it was sent from - the recipients are the
// other party, so resolving from them would send as their address.
//
// Quick reply resolves the user's OWN identities only. It deliberately does
// NOT take the domain catch-all `From:` rewrite that the full composer
// offers: this surface is a bare text box with no From row, so a rewritten
// From would be applied with nothing on screen to show it - or to correct
// it. A catch-all delivery quick-replies as the matching own identity, and
// the user can open the full composer when they need the rewrite.
const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
? findDraftIdentityId(identities, selectedEmail.from?.[0])
: null;
const sendingIdentityId = selfSentIdentityId ?? findReplyIdentityId(identities, {
to: selectedEmail.to,
cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
});
const sendingIdentity =
(sendingIdentityId ? identities.find((i) => i.id === sendingIdentityId) : undefined) || primaryIdentity;
const headerFromEmail = sendingIdentity?.email;
const headerFromName = sendingIdentity?.name || undefined;
@@ -5448,6 +5425,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onSaveQuickReplyDraft={handleSaveQuickReplyDraft}
onBack={handleMobileBack}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
+8
View File
@@ -24,6 +24,7 @@ import { cn } from "@/lib/utils";
import { getDraftTextBody } from '@/lib/email-body-selection';
import { messagePriorityFromHeaders } from '@/lib/message-priority';
import { useArchiveActionEnabled } from '@/lib/archive-gate';
import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
interface ProEmailTabBodyProps {
tabId: string;
@@ -399,6 +400,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
handleReply(body);
}, [handleReply]);
// Before an automatic sign-out: the typed quick reply is kept as a draft.
const handleSaveQuickReplyDraft = useCallback(async (body: string) => {
if (!client || !email) return;
await saveQuickReplyDraft({ client, email, identities, body, replyPrefix: t('email_composer.prefix.reply') });
}, [client, email, identities, t]);
const handleEditDraft = useCallback(() => {
if (!email) return;
@@ -473,6 +480,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
onSetTag={handleSetTag}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onSaveQuickReplyDraft={handleSaveQuickReplyDraft}
onEditDraft={handleEditDraft}
onMoveToMailbox={handleMoveToMailbox}
currentUserEmail={client?.getUsername()}
@@ -0,0 +1,48 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SessionTimeoutDialog } from '../session-timeout-dialog';
const noop = () => {};
describe('session timeout dialog', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-28T09:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('sits at the end of the body above plugin dialogs and the office editor', () => {
const { container } = render(
<div style={{ position: 'relative', zIndex: 1 }}>
<SessionTimeoutDialog mode="idle" deadline={Date.now() + 30_000} onStay={noop} onDismiss={noop} onSignOut={noop} />
</div>,
);
const overlay = screen.getByTestId('session-timeout-dialog');
expect(container.contains(overlay)).toBe(false);
expect(overlay.parentElement).toBe(document.body);
const z = Number(/z-\[(\d+)\]/.exec(overlay.className)?.[1]);
expect(z).toBeGreaterThan(100000);
});
it('does not read out every second, only the 30 and 10 second marks', () => {
render(<SessionTimeoutDialog mode="idle" deadline={Date.now() + 35_000} onStay={noop} onDismiss={noop} onSignOut={noop} />);
const message = screen.getByText('idle_message');
expect(message.closest('[aria-live]')).toBeNull();
const live = document.querySelector('[aria-live]')!;
expect(live.textContent).toBe('');
act(() => { vi.advanceTimersByTime(5_000); });
expect(live.textContent).toBe('seconds_left');
});
it('points aria-describedby only at a message that is there', () => {
const { rerender } = render(<SessionTimeoutDialog mode="idle" deadline={Date.now() + 30_000} onStay={noop} onDismiss={noop} onSignOut={noop} />);
const dialog = screen.getByRole('alertdialog');
const described = dialog.getAttribute('aria-describedby');
expect(described && document.getElementById(described)).toBeTruthy();
rerender(<SessionTimeoutDialog mode="ending" deadline={null} onStay={noop} onDismiss={noop} onSignOut={noop} />);
expect(screen.getByRole('alertdialog').hasAttribute('aria-describedby')).toBe(false);
});
});
@@ -0,0 +1,328 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS } from '../session-timeout-guard';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
import { consumeSessionEnd } from '@/lib/session-policy/sign-out-reason';
import { LAST_ACTIVITY_KEY } from '@/lib/session-policy/activity-channel';
/** BroadcastChannel between "tabs" of one test. */
class FakeBroadcastChannel {
static instances: FakeBroadcastChannel[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
closed = false;
constructor(public name: string) {
FakeBroadcastChannel.instances.push(this);
}
postMessage(data: unknown) {
for (const other of FakeBroadcastChannel.instances) {
if (other !== this && !other.closed && other.name === this.name) other.onmessage?.({ data } as MessageEvent);
}
}
close() {
this.closed = true;
}
}
const START = new Date('2026-10-28T09:00:00Z').getTime();
const SECOND = 1000;
function fakeClient(email: string) {
return {
getUsername: () => email,
getSharedAccounts: () => [],
getAccountInfo: () => undefined,
getPrincipalTypes: async () => ({}),
};
}
function account(id: string, email: string, signedInAt = START): AccountEntry {
return {
id, label: email, serverUrl: 'https://mail.example.org', username: email, authMode: 'basic', cookieSlot: 0,
rememberMe: true, displayName: email, email, avatarColor: '#336699', lastLoginAt: START, signedInAt,
isConnected: true, hasError: false, isDefault: true,
};
}
function policyWith(session: { idleMinutes: number; maxHours: number; warnSeconds: number }, scopes: SettingsPolicy['scopes'] = []): SettingsPolicy {
return { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, session }, scopes };
}
let logoutAll: ReturnType<typeof vi.fn>;
const realGetAllConnectedClients = useAuthStore.getState().getAllConnectedClients;
async function signIn(policy: SettingsPolicy, email = 'org-alice@example.org') {
usePolicyStore.setState({ rawPolicy: policy });
useAccountStore.setState({ accounts: [account('alice', email)], activeAccountId: 'alice' });
useAuthStore.setState({
isAuthenticated: true,
isDemoMode: false,
activeAccountId: 'alice',
client: fakeClient(email) as never,
logoutAll: logoutAll as never,
});
render(<SessionTimeoutGuard />);
// The limits are worked out asynchronously (the policy subject).
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
}
const advance = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); });
describe('session timeout guard', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(START);
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
FakeBroadcastChannel.instances = [];
localStorage.clear();
sessionStorage.clear();
resetComposeFlushRegistry();
logoutAll = vi.fn(async () => {});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null, getAllConnectedClients: realGetAllConnectedClients });
});
it('warns at idle minus warnSeconds and goes away on "Stay signed in"', async () => {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
await advance(29 * SECOND);
expect(screen.queryByText('idle_title')).toBeNull();
await advance(1 * SECOND);
expect(screen.getByText('idle_title')).toBeTruthy();
// Enter on the warning keeps the user signed in.
expect(document.activeElement?.textContent).toBe('stay');
fireEvent.click(screen.getByText('stay'));
expect(screen.queryByText('idle_title')).toBeNull();
await advance(29 * SECOND);
expect(screen.queryByText('idle_title')).toBeNull();
expect(logoutAll).not.toHaveBeenCalled();
});
it('starts the idle time again on activity in this tab', async () => {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
await advance(20 * SECOND);
fireEvent.keyDown(window, { key: 'a' });
await advance(29 * SECOND);
expect(screen.queryByText('idle_title')).toBeNull();
await advance(1 * SECOND);
expect(screen.getByText('idle_title')).toBeTruthy();
});
it('starts the idle time again on activity in another tab', async () => {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
const otherTab = new FakeBroadcastChannel('bulwark-session-activity');
await advance(40 * SECOND);
expect(screen.getByText('idle_title')).toBeTruthy();
await act(async () => { otherTab.postMessage({ at: Date.now() }); });
expect(screen.queryByText('idle_title')).toBeNull();
await advance(59 * SECOND);
expect(logoutAll).not.toHaveBeenCalled();
});
it('tells the other tabs about activity here', async () => {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
const otherTab = new FakeBroadcastChannel('bulwark-session-activity');
const heard = vi.fn();
otherTab.onmessage = (event) => heard((event.data as { at: number }).at);
await advance(10 * SECOND);
fireEvent.pointerDown(window);
expect(heard).toHaveBeenCalledWith(START + 10 * SECOND);
expect(localStorage.getItem(LAST_ACTIVITY_KEY)).toBe(String(START + 10 * SECOND));
});
it('saves the drafts, then signs every account out with the reason, at zero', async () => {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
const order: string[] = [];
registerComposeFlush(async () => {
await new Promise((r) => setTimeout(r, 500));
order.push('draft saved');
});
logoutAll.mockImplementation(async () => { order.push('signed out'); });
await advance(60 * SECOND);
expect(screen.getByText('signing_out')).toBeTruthy();
expect(logoutAll).not.toHaveBeenCalled();
await advance(500);
expect(order).toEqual(['draft saved', 'signed out']);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
});
it('signs out after the flush timeout when a draft save hangs', async () => {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
registerComposeFlush(() => new Promise<void>(() => {}));
await advance(60 * SECOND);
await advance(DRAFT_FLUSH_TIMEOUT_MS - 1);
expect(logoutAll).not.toHaveBeenCalled();
await advance(1);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
});
it('uses the strictest of the signed-in accounts', async () => {
const policy = policyWith({ idleMinutes: 0, maxHours: 0, warnSeconds: 30 }, [
{ id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 10 } } } },
{ id: 'two', label: 'org-two.test', match: { domains: ['org-two.test'] }, policy: { organisation: { session: { idleMinutes: 2 } } } },
]);
usePolicyStore.setState({ rawPolicy: policy });
useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org'), account('dave', 'org-dave@org-two.test')], activeAccountId: 'alice' });
const alice = fakeClient('org-alice@example.org');
const dave = fakeClient('org-dave@org-two.test');
useAuthStore.setState({
isAuthenticated: true,
isDemoMode: false,
activeAccountId: 'alice',
client: alice as never,
logoutAll: logoutAll as never,
getAllConnectedClients: (() => new Map([['alice', alice], ['dave', dave]])) as never,
});
render(<SessionTimeoutGuard />);
await advance(0);
// Two minutes (dave's domain), not ten (alice's).
await advance(2 * 60 * SECOND - 1);
expect(logoutAll).not.toHaveBeenCalled();
await advance(1);
expect(logoutAll).toHaveBeenCalledTimes(1);
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 2 });
});
it('gives an account in a scope without a timeout none', async () => {
const policy = policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }, [
{ id: 'two', label: 'org-two.test', match: { domains: ['org-two.test'] }, policy: { organisation: { session: { idleMinutes: 0 } } } },
]);
await signIn(policy, 'org-dave@org-two.test');
await advance(10 * 60 * SECOND);
expect(screen.queryByText('idle_title')).toBeNull();
expect(logoutAll).not.toHaveBeenCalled();
});
it('warns before the maximum session length and signs out at it', async () => {
usePolicyStore.setState({ rawPolicy: policyWith({ idleMinutes: 0, maxHours: 1, warnSeconds: 60 }) });
useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org', START - 50 * 60 * SECOND)], activeAccountId: 'alice' });
useAuthStore.setState({
isAuthenticated: true, isDemoMode: false, activeAccountId: 'alice',
client: fakeClient('org-alice@example.org') as never, logoutAll: logoutAll as never,
getAllConnectedClients: (() => new Map()) as never,
});
render(<SessionTimeoutGuard />);
await advance(0);
// Ten minutes left, ended a minute early for clock skew, warned a minute before that.
await advance(8 * 60 * SECOND - 1);
expect(screen.queryByText('max_title')).toBeNull();
await advance(1);
expect(screen.getByText('max_title')).toBeTruthy();
fireEvent.click(screen.getByText('dismiss'));
expect(screen.queryByText('max_title')).toBeNull();
await advance(60 * SECOND);
await advance(0);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 });
});
it('signs out right away after a reload that follows a pause longer than the limit', async () => {
// Recorded by a tab before the reload, five minutes ago; signed in long before.
localStorage.setItem(LAST_ACTIVITY_KEY, String(START - 5 * 60 * SECOND));
usePolicyStore.setState({ rawPolicy: policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }) });
useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org', START - 60 * 60 * SECOND)], activeAccountId: 'alice' });
useAuthStore.setState({
isAuthenticated: true, isDemoMode: false, activeAccountId: 'alice',
client: fakeClient('org-alice@example.org') as never, logoutAll: logoutAll as never,
});
render(<SessionTimeoutGuard />);
// Moving the mouse while the page loads doesn't bring the session back.
fireEvent.pointerDown(window);
await advance(0);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
});
it('says on the login page when a draft could not be saved', async () => {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
registerComposeFlush(async () => { throw new Error('save refused'); });
await advance(60 * SECOND);
await advance(0);
expect(logoutAll).toHaveBeenCalled();
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1, draftsUnsaved: true });
});
it('does not count scrolling as activity', async () => {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
await advance(20 * SECOND);
fireEvent.scroll(window);
await advance(10 * SECOND);
expect(screen.getByText('idle_title')).toBeTruthy();
});
it('does not count a focused cross-origin frame as activity, and warns above its overlay', async () => {
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true);
// The office editor: a cross-origin frame in a z-[100000] overlay.
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 z-[100000]';
const editor = document.createElement('iframe');
Object.defineProperty(editor, 'contentDocument', { get: () => null });
overlay.appendChild(editor);
document.body.appendChild(overlay);
try {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
editor.focus();
expect(document.activeElement).toBe(editor);
// Left focused, the editor keeps nothing alive: warned at 30 s, signed out at 60 s.
await advance(30 * SECOND);
const warning = screen.getByTestId('session-timeout-dialog');
expect(Number(/z-\[(\d+)\]/.exec(warning.className)?.[1])).toBeGreaterThan(100000);
// Later in the body than the overlay, so it paints on top at a higher z-index too.
expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await advance(30 * SECOND);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
} finally {
overlay.remove();
hasFocus.mockRestore();
}
});
it('asks again for the policy subject of a client that had none', async () => {
const policy = policyWith({ idleMinutes: 0, maxHours: 0, warnSeconds: 30 }, [
{ id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 1 } } } },
]);
let username = '';
const client = { ...fakeClient(''), getUsername: () => username };
usePolicyStore.setState({ rawPolicy: policy });
useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org')], activeAccountId: 'alice' });
useAuthStore.setState({
isAuthenticated: true, isDemoMode: false, activeAccountId: 'alice', connectedAccountsRevision: 1,
client: client as never, logoutAll: logoutAll as never,
});
render(<SessionTimeoutGuard />);
await advance(0);
await advance(2 * 60 * SECOND);
expect(logoutAll).not.toHaveBeenCalled();
// The session is complete now: the subject is known and the domain's minute applies.
username = 'org-alice@example.org';
await act(async () => { useAuthStore.setState({ connectedAccountsRevision: 2 }); });
await advance(0);
fireEvent.keyDown(window, { key: 'a' });
await advance(60 * SECOND);
await advance(0);
expect(logoutAll).toHaveBeenCalled();
});
});
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { Button } from "@/components/ui/button";
import { Clock, Loader2 } from "@/components/icons";
import type { SessionEndKind } from "@/lib/session-policy/session-timer";
interface SessionTimeoutDialogProps {
/** 'ending': the session is over, drafts are being saved. */
mode: SessionEndKind | 'ending';
deadline: number | null;
/** Hours of the maximum session length, for its message. */
maxHours?: number;
onStay: () => void;
onDismiss: () => void;
onSignOut: () => void;
}
function secondsLeft(deadline: number | null): number {
return deadline === null ? 0 : Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
}
/** Screen readers hear the dialog when it opens, then only at these marks, not every second. */
const ANNOUNCE_AT = [30, 10];
/**
* The warning before an organisation session limit signs the user out. For
* the idle timeout it asks whether to stay; the maximum session length
* can't be extended, so that one only says when it ends.
*/
export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismiss, onSignOut }: SessionTimeoutDialogProps) {
const t = useTranslations("session_limits");
const id = useId();
const [seconds, setSeconds] = useState(() => secondsLeft(deadline));
const [announced, setAnnounced] = useState<number | null>(null);
const dialogRef = useFocusTrap({
isActive: true,
onEscape: mode === "idle" ? onStay : mode === "max" ? onDismiss : undefined,
restoreFocus: true,
});
useEffect(() => {
setSeconds(secondsLeft(deadline));
if (mode === "ending") return;
const interval = setInterval(() => setSeconds(secondsLeft(deadline)), 250);
return () => clearInterval(interval);
}, [deadline, mode]);
useEffect(() => {
const mark = ANNOUNCE_AT.find((at) => seconds === at);
if (mark !== undefined) setAnnounced(mark);
}, [seconds]);
if (typeof document === "undefined") return null;
const title = mode === "max" ? t("max_title") : mode === "idle" ? t("idle_title") : t("signing_out");
// Portalled to the end of the body above everything else, the office
// editor (z-100) and plugin dialogs (z-100000) included.
return createPortal(
<div className="fixed inset-0 z-[100001] flex items-center justify-center bg-black/50 p-4 backdrop-blur-[1px]" data-testid="session-timeout-dialog">
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={mode !== "ending" ? `${id}-message` : undefined}
className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl"
>
<div className="flex items-start gap-4 p-6">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
{mode === "ending" ? <Loader2 className="h-5 w-5 animate-spin" /> : <Clock className="h-5 w-5" />}
</div>
<div className="min-w-0 flex-1">
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">{title}</h2>
{mode !== "ending" && (
<p id={`${id}-message`} className="mt-2 text-sm text-muted-foreground">
{mode === "max"
? t("max_message", { hours: maxHours ?? 0, seconds })
: t("idle_message", { seconds })}
</p>
)}
<span className="sr-only" aria-live="polite">
{mode !== "ending" && announced !== null ? t("seconds_left", { seconds: announced }) : ""}
</span>
</div>
</div>
{mode !== "ending" && (
// The safe answer comes first, so the focus trap starts on it and
// Enter keeps the user signed in; row-reverse shows it on the right.
<div className="flex flex-row-reverse flex-wrap items-center justify-start gap-3 px-6 pb-6">
{mode === "idle"
? <Button onClick={onStay}>{t("stay")}</Button>
: <Button onClick={onDismiss}>{t("dismiss")}</Button>}
<Button variant="outline" onClick={onSignOut}>{t("sign_out_now")}</Button>
</div>
)}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,257 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import { policySubjectForClient } from "@/lib/admin/policy-subject";
import type { PolicySubject } from "@/lib/admin/policy-scopes";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { flushComposers } from "@/lib/compose-flush-registry";
import { openActivityChannel, readLastActivity, type ActivityChannel } from "@/lib/session-policy/activity-channel";
import {
NO_SESSION_LIMITS,
sameLimits,
sessionLimits,
setCurrentSessionLimits,
type AccountSession,
type SessionLimits,
} from "@/lib/session-policy/limits";
import { createSessionTimer, type SessionEndKind, type SessionTimer, type SessionTimerState } from "@/lib/session-policy/session-timer";
import { rememberSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
import { SessionTimeoutDialog } from "./session-timeout-dialog";
/** How long the sign-out waits for the open composers to save their drafts (and for sends in flight). */
export const DRAFT_FLUSH_TIMEOUT_MS = 8000;
/** Local activity reaches the timer and the other tabs at most this often. */
const ACTIVITY_THROTTLE_MS = 1000;
/**
* Bubbling input events, caught on the way down. Scrolling and focus moves
* inside the page don't count: the page scrolls and refocuses on its own.
* Input inside a frame (the office editor, plugin panels) never reaches this
* window and doesn't count either: a focused frame left alone would keep the
* session open forever. Users working there see the warning, which sits
* above every overlay, and choose to stay.
*/
const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"] as const;
/** Policy subjects per client and connection round; a failed or empty answer is asked again. */
const subjects = new WeakMap<IJMAPClient, { revision: number; subject: Promise<PolicySubject | null> }>();
function subjectFor(client: IJMAPClient, revision: number): Promise<PolicySubject | null> {
const cached = subjects.get(client);
if (cached && cached.revision === revision) return cached.subject;
const subject = policySubjectForClient(client).catch(() => null);
subjects.set(client, { revision, subject });
void subject.then((value) => {
if (value === null && subjects.get(client)?.subject === subject) subjects.delete(client);
});
return subject;
}
/** The limits of every account signed in here, each with its own domain and group scopes. */
async function collectLimits(revision: number): Promise<SessionLimits> {
const auth = useAuthStore.getState();
const clients = new Map<string, IJMAPClient>(auth.getAllConnectedClients());
if (auth.client && auth.activeAccountId && !clients.has(auth.activeAccountId)) clients.set(auth.activeAccountId, auth.client);
const accounts: AccountSession[] = await Promise.all([...clients.entries()].map(async ([accountId, client]) => {
const subject = await subjectFor(client, revision);
const session = usePolicyStore.getState().resolveFor(subject).organisation?.session;
return { session, signedInAt: signedInAtFor(accountId) };
}));
return sessionLimits(accounts);
}
/**
* When the account signed in. An account signed in before this was recorded
* counts as signed in now, the first time it is seen.
*/
function signedInAtFor(accountId: string): number | undefined {
const store = useAccountStore.getState();
const account = store.getAccountById(accountId);
if (!account) return undefined;
if (typeof account.signedInAt === "number") return account.signedInAt;
const now = Date.now();
store.updateAccount(accountId, { signedInAt: now });
return now;
}
function endReason(kind: SessionEndKind, limits: SessionLimits, draftsUnsaved: boolean): SessionEndReason {
const drafts = draftsUnsaved ? { draftsUnsaved: true } : {};
return kind === "max"
? { kind: "max", hours: limits.max?.hours ?? 0, ...drafts }
: { kind: "idle", minutes: limits.idle?.minutes ?? 0, ...drafts };
}
const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: null };
/**
* The organisation's idle timeout and maximum session length
* (policy.json `organisation.session`). Activity in any Bulwark tab keeps
* every tab signed in; a warning counts down first; at zero the open and
* minimised composers save their drafts and every account signs out, with
* the reason on the login page.
*/
export function SessionTimeoutGuard() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isDemoMode = useAuthStore((s) => s.isDemoMode);
const client = useAuthStore((s) => s.client);
const revision = useAuthStore((s) => s.connectedAccountsRevision);
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const accountsKey = useAccountStore((s) => s.accounts.map((a) => `${a.id}|${a.signedInAt ?? ""}|${a.isConnected}`).join(","));
// The last activity any tab recorded, read once before this tab's own
// listeners can write it: a reload after a pause longer than the limit
// signs out right away, whatever the user does while the page loads.
const [mount] = useState(() => ({ at: Date.now(), lastActivity: readLastActivity() }));
const [limits, setLimits] = useState<SessionLimits>(NO_SESSION_LIMITS);
const [limitsReady, setLimitsReady] = useState(false);
const [state, setState] = useState<SessionTimerState>(IDLE_STATE);
const [ending, setEnding] = useState(false);
const [dismissedMax, setDismissedMax] = useState<number | null>(null);
const timerRef = useRef<SessionTimer | null>(null);
const timersStartedRef = useRef(0);
const channelRef = useRef<ActivityChannel | null>(null);
const limitsRef = useRef(limits);
limitsRef.current = limits;
const limitsReadyRef = useRef(limitsReady);
limitsReadyRef.current = limitsReady;
const endingRef = useRef(false);
const active = isAuthenticated && !isDemoMode;
useEffect(() => {
if (!active) {
setLimits(NO_SESSION_LIMITS);
setLimitsReady(false);
return;
}
let cancelled = false;
void collectLimits(revision).then((next) => {
if (cancelled) return;
setLimits((prev) => (sameLimits(prev, next) ? prev : next));
setLimitsReady(true);
});
return () => { cancelled = true; };
}, [active, client, revision, rawPolicy, accountsKey]);
useEffect(() => {
setCurrentSessionLimits(active ? limits : null);
}, [active, limits]);
const endSession = useCallback(async (kind: SessionEndKind) => {
if (endingRef.current) return;
endingRef.current = true;
setEnding(true);
const current = limitsRef.current;
// Said first, in case another tab's sign-out takes this one to the login page meanwhile.
rememberSessionEnd(endReason(kind, current, false));
const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS);
if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true));
await useAuthStore.getState().logoutAll({ endProviderSession: false });
}, []);
// The clock, while there is a limit to count.
const hasLimits = active && limitsReady && (!!limits.idle || !!limits.max);
useEffect(() => {
if (!hasLimits) {
timerRef.current?.dispose();
timerRef.current = null;
setState(IDLE_STATE);
return;
}
if (timerRef.current) {
timerRef.current.setLimits(limits);
return;
}
// The first clock of this page starts from the activity recorded before
// the page loaded, a fresh sign-in from the sign-in. A clock started
// later (limits turned on meanwhile) from what the tabs recorded since.
const stored = timersStartedRef.current === 0 ? mount.lastActivity ?? mount.at : readLastActivity() ?? Date.now();
const newestSignIn = Math.max(0, ...useAccountStore.getState().accounts.map((a) => a.signedInAt ?? 0));
timersStartedRef.current += 1;
timerRef.current = createSessionTimer({
limits,
lastActivity: Math.max(stored, newestSignIn),
onChange: setState,
onExpire: (kind) => { void endSession(kind); },
});
setState(timerRef.current.state());
}, [hasLimits, limits, endSession, mount]);
useEffect(() => () => {
timerRef.current?.dispose();
timerRef.current = null;
}, []);
useEffect(() => {
if (!active) {
endingRef.current = false;
setEnding(false);
}
}, [active]);
// Activity in this tab and the others.
useEffect(() => {
const channel = openActivityChannel((at) => timerRef.current?.remoteActivity(at));
channelRef.current = channel;
let lastSeen = 0;
const onActivity = () => {
// Nothing counts until the limits of the signed-in accounts are known:
// the clock starts from what was recorded before.
if (endingRef.current || !limitsReadyRef.current) return;
const at = Date.now();
if (at - lastSeen < ACTIVITY_THROTTLE_MS) return;
const timer = timerRef.current;
if (timer) {
timer.activity(at);
// During the idle warning only "Stay signed in" counts.
const s = timer.state();
if (s.phase === "expired" || (s.phase === "warning" && s.kind === "idle")) return;
}
lastSeen = at;
channel.post(at);
};
const onVisibility = () => {
if (document.visibilityState === "visible") timerRef.current?.check();
};
for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onActivity, { capture: true, passive: true });
// The window coming back into focus, not focus moving between elements.
window.addEventListener("focus", onActivity);
document.addEventListener("visibilitychange", onVisibility);
return () => {
for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true });
window.removeEventListener("focus", onActivity);
document.removeEventListener("visibilitychange", onVisibility);
channel.close();
channelRef.current = null;
};
}, []);
const stay = useCallback(() => {
const timer = timerRef.current;
if (!timer) return;
timer.stay();
channelRef.current?.post(timer.lastActivity());
}, []);
const signOutNow = useCallback(() => {
const kind = timerRef.current?.state().kind ?? "idle";
void endSession(kind);
}, [endSession]);
if (!active) return null;
if (ending) {
return <SessionTimeoutDialog mode="ending" deadline={null} onStay={stay} onDismiss={stay} onSignOut={signOutNow} />;
}
if (state.phase !== "warning" || !state.kind) return null;
if (state.kind === "max" && dismissedMax === state.deadline) return null;
return (
<SessionTimeoutDialog
mode={state.kind}
deadline={state.deadline}
maxHours={limits.max?.hours}
onStay={stay}
onDismiss={() => setDismissedMax(state.deadline)}
onSignOut={signOutNow}
/>
);
}
@@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MINIMISED_COMPOSER_REOPEN_MS, useMinimisedComposerFlush } from '../use-minimised-composer-flush';
import { flushComposers, registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
/**
* mail-app keeps a minimised composer's edits in memory only. Before an
* automatic sign-out the hook opens the composer again and lets it save.
*/
describe('minimised composer flush', () => {
beforeEach(() => {
resetComposeFlushRegistry();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('opens the minimised composer again and runs its own save', async () => {
const save = vi.fn(async () => {});
// Opening the composer mounts it, and it registers its flush.
const reopen = vi.fn(() => { registerComposeFlush(save); });
renderHook(() => useMinimisedComposerFlush(true, reopen));
const result = await flushComposers(8000);
expect(reopen).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledTimes(1);
expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false });
});
it('registers nothing without unsaved minimised edits', async () => {
const reopen = vi.fn();
renderHook(() => useMinimisedComposerFlush(false, reopen));
await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 });
expect(reopen).not.toHaveBeenCalled();
});
it('counts a composer that does not come back as a draft not saved', async () => {
renderHook(() => useMinimisedComposerFlush(true, () => {}));
const done = flushComposers(8000);
await vi.advanceTimersByTimeAsync(MINIMISED_COMPOSER_REOPEN_MS);
await expect(done).resolves.toEqual({ total: 1, settled: 1, failed: 1, timedOut: false });
});
it('stops registering once the composer is open again', async () => {
const { rerender } = renderHook(({ minimised }) => useMinimisedComposerFlush(minimised, () => {}), { initialProps: { minimised: true } });
rerender({ minimised: false });
await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 });
});
});
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react';
import { registerComposeFlush, waitForComposeFlush } from '@/lib/compose-flush-registry';
/** How long an automatic sign-out waits for a minimised composer to open again and register. */
export const MINIMISED_COMPOSER_REOPEN_MS = 3000;
/**
* A minimised composer's edits live only in its host's memory. While there
* are unsaved ones, an automatic sign-out opens the composer again (`reopen`)
* and lets it save itself (lib/compose-flush-registry.ts). A composer that
* doesn't come back in time counts as a draft that wasn't saved.
*/
export function useMinimisedComposerFlush(minimisedUnsaved: boolean, reopen: () => void): void {
const reopenRef = useRef(reopen);
reopenRef.current = reopen;
useEffect(() => {
if (!minimisedUnsaved) return;
return registerComposeFlush(async () => {
const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS);
reopenRef.current();
const flush = await reopened;
if (!flush) throw new Error('The minimised message did not open again');
await flush();
});
}, [minimisedUnsaved]);
}
+209
View File
@@ -0,0 +1,209 @@
import { test, expect, type Page } from '@playwright/test';
import {
ORG,
ORG_PASSWORD,
adminContext,
getPolicy,
putPolicy,
requireOrgEnv,
shot,
userPage,
} from './helpers/org';
import { JmapClient } from './helpers/jmap';
import { emailItem, openComposer, setSubject, waitDraftSaved } from './helpers/app';
/**
* Session idle timeout (organisation feature 4), through the real UI on a
* VM 106 test server. The admin sets one minute with a 30-second warning for
* example.org on Organisation > Security, Off for org-two.test, and two
* minutes for everyone else. org-alice gets the warning, stays, then lets it
* run out: Bulwark saves her minimised draft and signs her out with the
* reason on the login page. org-dave's domain has no timeout. The admin's
* policy is put back afterwards.
*/
test.describe.configure({ mode: 'serial' });
const RUN = Date.now().toString(36);
const INBOX_SUBJECT = `Session limits inbox ${RUN}`;
const DRAFT_SUBJECT = `Session limits draft ${RUN}`;
let original: Record<string, unknown>;
let alice: JmapClient;
async function draftSubjects(client: JmapClient): Promise<string[]> {
const drafts = await client.mailboxByRole('drafts');
if (!drafts) throw new Error('No Drafts mailbox');
const r = await client.request([
['Email/query', { accountId: client.accountId, filter: { inMailbox: drafts.id }, sort: [{ property: 'receivedAt', isAscending: false }], limit: 50 }, '0'],
['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject'] }, '1'],
]);
return (r.methodResponses[1][1].list as Array<{ subject: string }>).map((e) => e.subject);
}
async function removeTestMail(client: JmapClient): Promise<void> {
const r = await client.request([
['Email/query', { accountId: client.accountId, filter: { text: RUN }, limit: 50 }, '0'],
]);
const ids = r.methodResponses[0][1].ids as string[];
if (ids.length) await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, '0']]);
}
const warning = (page: Page) => page.getByTestId('session-timeout-dialog');
test.beforeAll(async ({ browser }) => {
requireOrgEnv();
const admin = await adminContext(browser);
original = await getPolicy(admin);
await putPolicy(admin, {
...original,
restrictions: {},
features: {},
organisation: { ...(original.organisation as object), session: { idleMinutes: 0, maxHours: 0, warnSeconds: 60 } },
scopes: [
{ id: 'sessions-example', label: 'Example', match: { domains: ['example.org'] }, policy: {} },
{ id: 'sessions-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} },
],
});
await admin.close();
// A message for org-alice to open while composing, which minimises the composer.
alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
const inbox = await alice.mailboxByRole('inbox');
const r = await alice.request([
['Email/set', {
accountId: alice.accountId,
create: {
m: {
mailboxIds: { [inbox!.id]: true },
keywords: { $seen: true },
from: [{ name: 'Org Bob', email: ORG.bob }],
to: [{ email: ORG.alice }],
subject: INBOX_SUBJECT,
receivedAt: new Date().toISOString(),
bodyValues: { b: { value: 'Open me while writing.' } },
textBody: [{ partId: 'b', type: 'text/plain' }],
},
},
}, '0'],
]);
expect(r.methodResponses[0][1].created?.m, JSON.stringify(r.methodResponses[0][1])).toBeTruthy();
});
test.afterAll(async ({ browser }) => {
if (original) {
const admin = await adminContext(browser);
await putPolicy(admin, original);
await admin.close();
}
if (alice) await removeTestMail(alice);
});
test('the admin sets the idle timeout per domain on Organisation > Security', async ({ browser }) => {
const admin = await adminContext(browser);
const page = await admin.newPage();
await page.goto('/admin/org-security');
const idle = page.getByLabel('Sign out after inactivity (minutes)');
const warn = page.getByLabel('Warning before signing out (seconds)');
await expect(idle).toBeVisible();
// Everyone: two minutes, so org-dave would be warned within the test if his scope didn't apply.
await idle.fill('2');
// example.org: one minute, warned 30 seconds before.
await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Example', exact: true }).click();
await expect(idle).toHaveValue('2');
await expect(page.getByText('From Everyone: 2 minutes')).toBeVisible();
await idle.fill('1');
await warn.fill('30');
// org-two.test: off.
await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Org two', exact: true }).click();
await idle.fill('');
await expect(idle).toHaveAttribute('placeholder', 'Off');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Example', exact: true }).click();
await page.locator('[data-settings-section="sessions"]').scrollIntoViewIfNeeded();
await shot(page, 'sessions-admin-page');
const policy = await getPolicy(admin);
expect((policy.organisation as { session: unknown }).session).toEqual({ idleMinutes: 2, maxHours: 0, warnSeconds: 60 });
const scopes = policy.scopes as Array<{ id: string; policy: { organisation?: { session?: unknown } } }>;
expect(scopes.find((s) => s.id === 'sessions-example')?.policy.organisation?.session).toEqual({ idleMinutes: 1, warnSeconds: 30 });
expect(scopes.find((s) => s.id === 'sessions-two')?.policy.organisation?.session).toEqual({ idleMinutes: 0 });
await admin.close();
});
test('the admin page works at phone width', async ({ browser }) => {
const admin = await adminContext(browser, true);
const page = await admin.newPage();
await page.goto('/admin/org-security');
const idle = page.getByLabel('Sign out after inactivity (minutes)');
await idle.scrollIntoViewIfNeeded();
await expect(idle).toBeVisible();
const box = await idle.boundingBox();
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
await shot(page, 'sessions-admin-page-phone');
await admin.close();
});
test('org-alice is warned, stays, then is signed out with her minimised draft saved; org-dave is not', async ({ browser }) => {
test.setTimeout(5 * 60 * 1000);
const [page, dave] = await Promise.all([userPage(browser, ORG.alice), userPage(browser, ORG.dave)]);
// Write, let the autosave store a first version, write more, then open a
// message: the composer is minimised with the newer text only in memory.
await expect(emailItem(page, INBOX_SUBJECT).first()).toBeVisible({ timeout: 30000 });
await openComposer(page);
await setSubject(page, DRAFT_SUBJECT);
await waitDraftSaved(page);
await setSubject(page, `${DRAFT_SUBJECT} final`);
await emailItem(page, INBOX_SUBJECT).first().click();
await expect(page.getByText('Continue draft')).toBeVisible();
let lastActivity = Date.now();
expect(await draftSubjects(alice)).toContain(DRAFT_SUBJECT);
expect(await draftSubjects(alice)).not.toContain(`${DRAFT_SUBJECT} final`);
// The warning comes 30 seconds before the minute is up.
await expect(warning(page)).toBeVisible({ timeout: 45000 });
const warnedAfter = Date.now() - lastActivity;
expect(warnedAfter).toBeGreaterThan(25000);
expect(warnedAfter).toBeLessThan(40000);
await expect(warning(page)).toContainText('Are you still there?');
await shot(page, 'sessions-idle-warning');
await page.getByRole('button', { name: 'Stay signed in' }).click();
lastActivity = Date.now();
await expect(warning(page)).toBeHidden();
// Left alone: warned again, then signed out a minute after the last activity.
await expect(warning(page)).toBeVisible({ timeout: 45000 });
await expect(page.locator('#username')).toBeVisible({ timeout: 60000 });
const signedOutAfter = Date.now() - lastActivity;
expect(signedOutAfter).toBeGreaterThan(55000);
await expect(page.getByTestId('session-expired-reason')).toHaveText('You were signed out after 1 minute without activity.');
await shot(page, 'sessions-login-reason');
// The minimised composer saved its newer text before the sign-out.
await expect.poll(() => draftSubjects(alice), { timeout: 15000 }).toContain(`${DRAFT_SUBJECT} final`);
// org-two.test turns the timeout off: org-dave, idle just as long, is still in.
await expect(warning(dave)).toHaveCount(0);
await expect(dave.locator('[data-testid="account-switcher"]').first()).toBeVisible();
await page.context().close();
await dave.context().close();
});
test('the warning fits a phone and "Stay signed in" works there', async ({ browser }) => {
test.setTimeout(3 * 60 * 1000);
const page = await userPage(browser, ORG.alice, true);
await expect(warning(page)).toBeVisible({ timeout: 45000 });
const dialog = page.getByRole('alertdialog');
const box = await dialog.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
await shot(page, 'sessions-idle-warning-phone');
await page.getByRole('button', { name: 'Stay signed in' }).tap();
await expect(warning(page)).toBeHidden();
await expect(page.locator('[data-testid="account-switcher"]').first()).toBeVisible();
await page.context().close();
});
+3 -2
View File
@@ -31,12 +31,13 @@ describe('encryptSession / decryptSession', () => {
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 's3cret',
signedInAt: expect.any(Number),
});
});
it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => {
const a = encryptSession('https://x', 'u', 'p');
const b = encryptSession('https://x', 'u', 'p');
const a = encryptSession('https://x', 'u', 'p', 1000);
const b = encryptSession('https://x', 'u', 'p', 1000);
expect(a).not.toBe(b);
expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64
expect(decryptSession(a)).toEqual(decryptSession(b));
@@ -48,6 +48,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: vi.fn(),
setStalwartAuthContextInStore: vi.fn(),
clearStalwartAuthContextInStore: vi.fn(),
readStalwartAuthContextFromStore: () => null,
}));
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() }));
@@ -56,6 +56,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: (...args: unknown[]) => setStalwartAuthContext(...args),
setStalwartAuthContextInStore: (...args: unknown[]) => setStalwartAuthContextInStore(...args),
clearStalwartAuthContextInStore: vi.fn(),
readStalwartAuthContextFromStore: () => null,
}));
const recordLogin = vi.fn();
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
flushComposers,
registerComposeFlush,
resetComposeFlushRegistry,
waitForComposeFlush,
} from '../compose-flush-registry';
describe('compose flush registry', () => {
beforeEach(() => {
resetComposeFlushRegistry();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('runs every registered flush and waits for them', async () => {
const order: string[] = [];
registerComposeFlush(async () => {
await new Promise((r) => setTimeout(r, 100));
order.push('a');
});
registerComposeFlush(async () => { order.push('b'); });
const done = flushComposers(5000);
await vi.advanceTimersByTimeAsync(100);
await expect(done).resolves.toEqual({ total: 2, settled: 2, failed: 0, timedOut: false });
expect(order.sort()).toEqual(['a', 'b']);
});
it('stops waiting for a hanging flush at the timeout', async () => {
const quick = vi.fn(async () => {});
registerComposeFlush(() => new Promise<void>(() => {}));
registerComposeFlush(quick);
let result: Awaited<ReturnType<typeof flushComposers>> | null = null;
void flushComposers(3000).then((r) => { result = r; });
await vi.advanceTimersByTimeAsync(2999);
expect(result).toBeNull();
await vi.advanceTimersByTimeAsync(1);
expect(result).toEqual({ total: 2, settled: 1, failed: 0, timedOut: true });
expect(quick).toHaveBeenCalledTimes(1);
});
it('goes on when a flush fails', async () => {
registerComposeFlush(async () => { throw new Error('save refused'); });
await expect(flushComposers(1000)).resolves.toEqual({ total: 1, settled: 1, failed: 1, timedOut: false });
});
it('forgets a composer that unregistered', async () => {
const flush = vi.fn(async () => {});
const unregister = registerComposeFlush(flush);
unregister();
await expect(flushComposers(1000)).resolves.toEqual({ total: 0, settled: 0, failed: 0, timedOut: false });
expect(flush).not.toHaveBeenCalled();
});
it('hands the next registration to a waiting host, or null after the timeout', async () => {
const waiting = waitForComposeFlush(500);
const flush = vi.fn(async () => {});
registerComposeFlush(flush);
await expect(waiting).resolves.toBe(flush);
const nobody = waitForComposeFlush(500);
await vi.advanceTimersByTimeAsync(500);
await expect(nobody).resolves.toBeNull();
});
});
+1 -1
View File
@@ -110,7 +110,7 @@ describe('impersonation handoff', () => {
const put = await readSession();
expect(put.status).toBe(200);
const body = await put.json();
expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET });
expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET, signedInAt: expect.any(Number) });
expect(JSON.stringify(body)).not.toContain(MASTER_PASSWORD);
expect([...jar.values()].join()).not.toContain(MASTER_PASSWORD);
});
@@ -0,0 +1,297 @@
// @vitest-environment node
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import type { SettingsPolicy } from '@/lib/admin/types';
/**
* The organisation's maximum session length on the server: the routes that
* restore a remembered sign-in or renew a provider token refuse a session
* older than `maxHours` of the account's domain scopes. A cookie or token
* without a sign-in time counts from one fixed time, when the server first
* enforced the limits, so deleting the stamp or keeping an old cookie never
* makes a session younger.
*/
const SECRET = 's'.repeat(64);
vi.mock('@/lib/auth/session-secret', () => ({ getSessionSecret: () => SECRET, hasSessionSecret: () => true }));
vi.mock('@/lib/logger', () => ({ logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} } }));
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: () => {} }));
vi.mock('@/lib/oauth/cookie-config', () => ({
getCookieOptions: () => ({ httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 2592000 }),
}));
const jar = new Map<string, string>();
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
set: (name: string, value: string) => { jar.set(name, value); },
delete: (arg: string | { name: string }) => { jar.delete(typeof arg === 'string' ? arg : arg.name); },
getAll: () => [...jar].map(([name, value]) => ({ name, value })),
}),
}));
let policy: Partial<SettingsPolicy>;
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key === 'jmapServerUrl' ? 'https://mail.example.org' : fallback),
getPolicy: () => policy,
},
}));
vi.mock('@/lib/auth/verify-jmap-auth', () => ({
JmapAuthVerificationError: class extends Error { status = 401; },
resolveJmapIdentity: async (serverUrl: string, _auth: string, username: string) => ({ serverUrl, accountName: username }),
}));
vi.mock('@/lib/oauth/token-exchange', () => ({
exchangeCodeForTokens: async () => ({ access_token: 'first-access', refresh_token: 'refresh-1', expires_in: 300 }),
buildOAuthParams: (params: Record<string, string>) => new URLSearchParams(params),
getMetadata: async () => ({ revocation_endpoint: 'https://id.example.org/revoke' }),
getRequiredConfig: () => ({ clientId: 'webmail' }),
getTokenEndpoint: async () => 'https://id.example.org/token',
defaultClientOptions: async () => ({ fallbackClientId: 'bulwark-webmail' }),
DEFAULT_CLIENT_ID: 'bulwark-webmail',
}));
const stateDir = mkdtempSync(path.join(tmpdir(), 'bw-session-age-'));
process.env.ADMIN_STATE_DIR = stateDir;
afterAll(() => rmSync(stateDir, { recursive: true, force: true }));
const { encryptSession, decryptSession } = await import('@/lib/auth/crypto');
const { readSignInStamp, writeSignInStamp, limitsEnforcedSince, resetLimitsEnforcedSince } = await import('@/lib/auth/session-age');
const sessionRoute = await import('@/app/api/auth/session/route');
const tokenRoute = await import('@/app/api/auth/token/route');
const contextRoute = await import('@/app/api/auth/stalwart-context/route');
const HOUR = 60 * 60 * 1000;
const NOW = new Date('2026-10-28T12:00:00Z').getTime();
const SERVER = 'https://mail.example.org';
const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' };
/** Eight hours for example.org, nothing for everyone else. */
function policyWithDomainMax(hours: number): Partial<SettingsPolicy> {
return {
features: {} as never, restrictions: {}, defaults: {},
scopes: [{ id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: hours } } } }],
};
}
/** A remembered-session cookie as Bulwark wrote it before sign-in times were recorded. */
function legacySessionCookie(username: string, password: string): string {
const key = createHash('sha256').update(SECRET).digest();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(JSON.stringify({ v: 1, serverUrl: SERVER, username, password }), 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64');
}
const store = async () => (await (await import('next/headers')).cookies()) as never;
const restore = () => sessionRoute.PUT(new NextRequest('https://webmail.example/api/auth/session', { method: 'PUT', headers: SAME_ORIGIN }));
const peek = () => sessionRoute.GET(new NextRequest('https://webmail.example/api/auth/session'));
const refresh = () => tokenRoute.PUT(new NextRequest('https://webmail.example/api/auth/token?slot=0&force=true', { method: 'PUT', headers: SAME_ORIGIN }));
const signInWithProvider = () => tokenRoute.POST(new NextRequest('https://webmail.example/api/auth/token?slot=0', {
method: 'POST',
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
body: JSON.stringify({ code: 'c', code_verifier: 'v', redirect_uri: 'https://webmail.example/auth/callback', slot: 0 }),
}));
let issued = 0;
const tokenFetch = vi.fn();
beforeEach(async () => {
jar.clear();
rmSync(path.join(stateDir, 'session-limits-since'), { force: true });
resetLimitsEnforcedSince();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(NOW);
// The server enforces the limits from NOW on.
expect(await limitsEnforcedSince()).toBe(NOW);
policy = policyWithDomainMax(8);
issued = 0;
tokenFetch.mockReset();
tokenFetch.mockImplementation(async (url: string) => {
// The provider rotates the refresh token on every renewal.
if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', refresh_token: `rotated-${++issued}`, expires_in: 300 });
return new Response(null, { status: 200 });
});
vi.stubGlobal('fetch', tokenFetch);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('the time the limits were first enforced', () => {
it('is recorded once and kept across restarts', async () => {
vi.setSystemTime(NOW + 5 * HOUR);
resetLimitsEnforcedSince();
expect(await limitsEnforcedSince()).toBe(NOW);
});
});
describe('remembered sessions (/api/auth/session)', () => {
it('records the sign-in time when the session is remembered, and answers with it', async () => {
const res = await sessionRoute.POST(new NextRequest('https://webmail.example/api/auth/session?slot=0', {
method: 'POST',
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' }),
}));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, signedInAt: NOW });
expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
});
it('restores a session younger than the maximum and says when it signed in', async () => {
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 7 * HOUR));
const res = await restore();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw', signedInAt: NOW - 7 * HOUR });
});
it('refuses a session older than its domain allows and clears its cookies', async () => {
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 8 * HOUR));
jar.set('jmap_stalwart_ctx', 'stale');
const res = await restore();
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
expect(jar.has('jmap_session')).toBe(false);
expect(jar.has('jmap_stalwart_ctx')).toBe(false);
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 9 * HOUR));
expect((await peek()).status).toBe(401);
});
it('leaves an account of another domain alone', async () => {
jar.set('jmap_session', encryptSession(SERVER, 'org-dave@org-two.test', 'pw', NOW - 100 * HOUR));
expect((await restore()).status).toBe(200);
});
it('applies the maximum of Everyone to every domain', async () => {
policy = { ...policyWithDomainMax(8), organisation: { session: { idleMinutes: 0, maxHours: 24, warnSeconds: 60 } } as never };
jar.set('jmap_session', encryptSession(SERVER, 'org-dave@org-two.test', 'pw', NOW - 25 * HOUR));
expect((await restore()).status).toBe(401);
});
it('counts a cookie without a sign-in time from the fixed time, without rewriting it', async () => {
const legacy = legacySessionCookie('org-alice@example.org', 'pw');
jar.set('jmap_session', legacy);
vi.setSystemTime(NOW + 7 * HOUR);
const first = await restore();
expect(first.status).toBe(200);
expect((await first.json()).signedInAt).toBe(NOW);
expect(jar.get('jmap_session')).toBe(legacy);
vi.setSystemTime(NOW + 8 * HOUR);
expect((await restore()).status).toBe(401);
});
it('refuses a kept copy of an old cookie replayed later', async () => {
const legacy = legacySessionCookie('org-alice@example.org', 'pw');
// First seen only now, nine hours after the server started enforcing.
vi.setSystemTime(NOW + 9 * HOUR);
jar.set('jmap_session', legacy);
expect((await restore()).status).toBe(401);
jar.set('jmap_session', legacy);
expect((await peek()).status).toBe(401);
});
});
describe('provider tokens (/api/auth/token)', () => {
it('records the sign-in time with the refresh token when a provider sign-in stores it', async () => {
const res = await signInWithProvider();
expect(res.status).toBe(200);
expect((await res.json()).signed_in_at).toBe(NOW);
expect(readSignInStamp(await store(), 0, 'refresh-1')).toMatchObject({ signedInAt: NOW });
// The stamp belongs to that token only.
expect(readSignInStamp(await store(), 0, 'another-token')).toBeNull();
});
it('refuses to renew a token sign-in older than the maximum and revokes it', async () => {
jar.set('jmap_rt', 'refresh-1');
writeSignInStamp(await store(), 0, { signedInAt: NOW + HOUR, refreshToken: 'refresh-1', account: 'org-alice@example.org' });
vi.setSystemTime(NOW + 9 * HOUR);
const res = await refresh();
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
expect(jar.has('jmap_rt')).toBe(false);
expect(jar.has('jmap_signed_in')).toBe(false);
expect(tokenFetch).toHaveBeenCalledWith('https://id.example.org/revoke', expect.anything());
expect(tokenFetch).not.toHaveBeenCalledWith('https://id.example.org/token', expect.anything());
});
it('renews a younger one, and the stamp follows the rotated token with the same time', async () => {
await signInWithProvider();
vi.setSystemTime(NOW + 3 * HOUR);
const res = await refresh();
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ access_token: 'new-access', signed_in_at: NOW });
expect(jar.get('jmap_rt')).toBe('rotated-1');
expect(readSignInStamp(await store(), 0, 'rotated-1')).toMatchObject({ signedInAt: NOW });
vi.setSystemTime(NOW + 7 * HOUR);
expect((await refresh()).status).toBe(200);
vi.setSystemTime(NOW + 8 * HOUR);
expect((await refresh()).status).toBe(401);
});
it('counts a refresh token without a stamp from the fixed time and binds that time to it', async () => {
jar.set('jmap_rt', 'refresh-legacy');
vi.setSystemTime(NOW + HOUR);
const res = await refresh();
expect(res.status).toBe(200);
expect((await res.json()).signed_in_at).toBe(NOW);
expect(readSignInStamp(await store(), 0, 'rotated-1')).toMatchObject({ signedInAt: NOW });
vi.setSystemTime(NOW + 8 * HOUR);
expect((await refresh()).status).toBe(401);
});
it('does not make a session younger when its stamp is deleted', async () => {
vi.setSystemTime(NOW + 5 * HOUR);
await signInWithProvider();
jar.delete('jmap_signed_in');
vi.setSystemTime(NOW + 8 * HOUR);
const res = await refresh();
expect(res.status).toBe(401);
});
it('applies the domain of the account the stamp names', async () => {
jar.set('jmap_rt', 'refresh-1');
writeSignInStamp(await store(), 0, { signedInAt: NOW, refreshToken: 'refresh-1', account: 'org-dave@org-two.test' });
vi.setSystemTime(NOW + 100 * HOUR);
expect((await refresh()).status).toBe(200);
});
it('does not take a stamp written for another refresh token', async () => {
jar.set('jmap_rt', 'refresh-2');
writeSignInStamp(await store(), 0, { signedInAt: NOW + 7 * HOUR, refreshToken: 'refresh-1' });
vi.setSystemTime(NOW + 8 * HOUR);
expect((await refresh()).status).toBe(401);
});
});
describe('the auth context (/api/auth/stalwart-context)', () => {
it("names the account on a token sign-in's stamp", async () => {
await signInWithProvider();
const res = await contextRoute.POST(new NextRequest('https://webmail.example/api/auth/stalwart-context', {
method: 'POST',
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', authHeader: 'Bearer new', slot: 0 }),
}));
expect(res.status).toBe(200);
expect(readSignInStamp(await store(), 0, 'refresh-1')).toMatchObject({ signedInAt: NOW, account: 'org-alice@example.org' });
});
});
+21 -7
View File
@@ -23,10 +23,13 @@ function getKey(): Buffer {
/**
* A remembered sign-in: the account's password (Basic auth), or the access
* token the user signed in with (Bearer auth, e.g. a Fastmail API token).
* `signedInAt`: when the user signed in (ms), for the organisation's maximum
* session length (lib/auth/session-age.ts); absent in cookies written
* before it was recorded.
*/
export type SessionCredentials =
| { serverUrl: string; username: string; password: string; token?: undefined }
| { serverUrl: string; username: string; token: string; password?: undefined };
| { serverUrl: string; username: string; password: string; token?: undefined; signedInAt?: number }
| { serverUrl: string; username: string; token: string; password?: undefined; signedInAt?: number };
/** The Authorization header remembered credentials sign in with. */
export function sessionAuthHeader(credentials: SessionCredentials): string {
@@ -34,13 +37,18 @@ export function sessionAuthHeader(credentials: SessionCredentials): string {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
export function encryptSession(serverUrl: string, username: string, secret: string | { token: string }): string {
export function encryptSession(
serverUrl: string,
username: string,
secret: string | { token: string },
signedInAt: number = Date.now(),
): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const credential = typeof secret === 'string' ? { password: secret } : { token: secret.token };
const payload = JSON.stringify({ v: 1, serverUrl, username, ...credential });
const payload = JSON.stringify({ v: 1, serverUrl, username, ...credential, signedInAt });
const encrypted = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
@@ -64,11 +72,14 @@ export function decryptSession(token: string): SessionCredentials | null {
const parsed = JSON.parse(decrypted.toString('utf8'));
if (parsed.v !== 1 || !parsed.serverUrl || !parsed.username) return null;
const signedInAt = typeof parsed.signedInAt === 'number' && Number.isFinite(parsed.signedInAt) && parsed.signedInAt > 0
? { signedInAt: parsed.signedInAt as number }
: {};
if (typeof parsed.password === 'string' && parsed.password) {
return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password };
return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password, ...signedInAt };
}
if (typeof parsed.token === 'string' && parsed.token) {
return { serverUrl: parsed.serverUrl, username: parsed.username, token: parsed.token };
return { serverUrl: parsed.serverUrl, username: parsed.username, token: parsed.token, ...signedInAt };
}
return null;
} catch (error) {
@@ -104,7 +115,9 @@ export type PayloadPurpose =
/** The app password an impersonation handoff minted, kept for revocation. */
| 'impersonation-grant'
/** A user's plugin credentials (lib/plugin-secrets/vault.ts). */
| 'plugin-secrets';
| 'plugin-secrets'
/** When a token sign-in happened, for the maximum session length (lib/auth/session-age.ts). */
| 'session-age';
/**
* Whether a blob minted before purposes were bound may be re-admitted for
@@ -125,6 +138,7 @@ const LEGACY_PAYLOAD_MARKERS: Record<PayloadPurpose, (payload: Record<string, un
'wopi-token': (p) => p.t === 'wopi',
'impersonation-grant': () => false,
'plugin-secrets': () => false,
'session-age': () => false,
};
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
+207
View File
@@ -0,0 +1,207 @@
/**
* The organisation's maximum session length (policy.json
* `organisation.session.maxHours`) on the server side.
*
* JMAP goes from the browser straight to Stalwart, so the client's timer
* signs users out on time (components/session/session-timeout-guard.tsx).
* The server's part is to stop a session older than the maximum from coming
* back: the routes that restore a remembered sign-in (/api/auth/session) or
* renew a provider token (/api/auth/token) refuse it and clear its cookies.
*
* The sign-in time travels in the remembered-session cookie (`signedInAt` in
* lib/auth/crypto.ts) and, for token sign-ins, in a stamp cookie of its own
* that is bound to the refresh token by a hash and rewritten with it on
* every rotation. A cookie or token without a sign-in time (one written
* before this existed, or a stamp that was deleted or doesn't match the
* token) counts as signed in at one fixed time: when the server first
* enforced the limits (limitsEnforcedSince). Deleting the stamp therefore
* never makes a session younger.
*
* The server knows an account's address, not its groups, so only Everyone
* and the domain scopes count here; a group scope's value is enforced by the
* client alone.
*/
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import type { cookies } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { logger } from '@/lib/logger';
export { SESSION_TOO_OLD } from '@/lib/session-policy/constants';
type CookieStore = Awaited<ReturnType<typeof cookies>>;
const HOUR_MS = 60 * 60 * 1000;
const positiveHours = (value: unknown): number => (typeof value === 'number' && value > 0 ? value : 0);
/**
* The maximum session length (hours, 0 = none) for an account's address.
* Without an address (a token sign-in whose stamp names no account, which
* includes one whose stamp was deleted) the strictest value of Everyone and
* every domain entry applies, so losing the account never loosens the limit.
*/
export async function maxSessionHoursFor(account: string | null | undefined): Promise<number> {
try {
await configManager.ensureLoaded?.();
const raw = configManager.getPolicy();
const email = account?.trim().toLowerCase() ?? '';
if (email) return positiveHours(resolvePolicy(raw, { email, groups: [] }).policy.organisation?.session?.maxHours);
const values = [
positiveHours(resolvePolicy(raw, null).policy.organisation?.session?.maxHours),
...(raw.scopes ?? [])
.filter((scope) => (scope.match?.domains?.length ?? 0) > 0)
.map((scope) => positiveHours(scope.policy?.organisation?.session?.maxHours)),
].filter((hours) => hours > 0);
return values.length > 0 ? Math.min(...values) : 0;
} catch (error) {
logger.warn('Could not read the maximum session length', { error: error instanceof Error ? error.message : 'Unknown error' });
return 0;
}
}
/** Whether a session signed in at `signedInAt` has outlived `maxHours`. */
export function isPastMaxAge(signedInAt: number, maxHours: number, now = Date.now()): boolean {
if (!maxHours) return false;
return now - signedInAt >= maxHours * HOUR_MS;
}
// ── The fixed time for sessions without a sign-in time ─────────────────────
const ENFORCED_SINCE_FILE = 'session-limits-since';
const PROCESS_START = Date.now() - Math.round(process.uptime() * 1000);
let enforcedSince: Promise<number> | null = null;
function parseTime(text: string): number | null {
const value = Number(text.trim());
return Number.isFinite(value) && value > 0 ? value : null;
}
async function readRecorded(path: string): Promise<number | null> {
try {
return parseTime(await readFile(path, 'utf8'));
} catch {
return null;
}
}
/**
* When this server first enforced the session limits, recorded once in the
* admin state dir. Falls back to the process start when the file can't be
* written (a read-only state dir), which restarts the count on a restart.
*/
export function limitsEnforcedSince(): Promise<number> {
enforcedSince ??= (async () => {
const path = getStatePath(ENFORCED_SINCE_FILE);
const recorded = await readRecorded(path);
if (recorded) return recorded;
const now = Date.now();
try {
await ensureStateDir();
// 'wx': a second process starting at the same moment keeps the first one's time.
await writeFile(path, String(now), { flag: 'wx' });
return now;
} catch {
const other = await readRecorded(path);
if (other) return other;
logger.warn('Could not record when the session limits were first enforced; counting from the process start');
return PROCESS_START;
}
})();
return enforcedSince;
}
/** Test helper. */
export function resetLimitsEnforcedSince(): void {
enforcedSince = null;
}
// ── The sign-in stamp of a token sign-in ───────────────────────────────────
/**
* The sign-in time of a token sign-in, bound to the refresh token it was
* written with. `account` is filled in once the auth context names it.
*/
export interface SignInStamp {
signedInAt: number;
tokenHash: string;
account?: string;
}
export function refreshTokenHash(refreshToken: string): string {
return createHash('sha256').update(refreshToken).digest('base64url');
}
export function signInStampCookieName(slot: number): string {
return slot === 0 ? 'jmap_signed_in' : `jmap_signed_in_${slot}`;
}
/** The slot's stamp, or null when it is missing, unreadable, or written for another refresh token. */
export function readSignInStamp(cookieStore: CookieStore, slot: number, refreshToken: string): SignInStamp | null {
const value = cookieStore.get(signInStampCookieName(slot))?.value;
if (!value) return null;
try {
const payload = decryptPayload(value, 'session-age');
const signedInAt = payload?.signedInAt;
if (typeof signedInAt !== 'number' || !Number.isFinite(signedInAt)) return null;
if (payload?.tokenHash !== refreshTokenHash(refreshToken)) return null;
const account = typeof payload.account === 'string' ? payload.account : undefined;
return { signedInAt, tokenHash: payload.tokenHash, ...(account ? { account } : {}) };
} catch {
return null;
}
}
let missingSecretLogged = false;
/**
* Written whenever the refresh token cookie is, with that cookie's options.
* Without a session secret nothing can be sealed; the client's timer still applies.
*/
export function writeSignInStamp(
cookieStore: CookieStore,
slot: number,
stamp: { signedInAt: number; refreshToken: string; account?: string },
options: Record<string, unknown> = getCookieOptions(),
): void {
if (!hasSessionSecret()) {
if (!missingSecretLogged) {
missingSecretLogged = true;
logger.warn('No session secret: the maximum session length is enforced by the browser only');
}
return;
}
try {
const payload: Record<string, unknown> = { signedInAt: stamp.signedInAt, tokenHash: refreshTokenHash(stamp.refreshToken) };
if (stamp.account) payload.account = stamp.account;
cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), options);
} catch (error) {
logger.warn('Could not record the sign-in time', { error: error instanceof Error ? error.message : 'Unknown error' });
}
}
export function clearSignInStamp(cookieStore: CookieStore, slot: number): void {
const name = signInStampCookieName(slot);
if (cookieStore.get(name)) cookieStore.delete(name);
}
/**
* When a token sign-in happened: its stamp's time, or the fixed time when
* there is no stamp for this refresh token. Null without a session secret,
* when the server can't keep sign-in times at all.
*/
export async function tokenSignedInAt(
cookieStore: CookieStore,
slot: number,
refreshToken: string,
): Promise<{ signedInAt: number; stamp: SignInStamp | null } | null> {
if (!hasSessionSecret()) return null;
const stamp = readSignInStamp(cookieStore, slot, refreshToken);
return { signedInAt: stamp?.signedInAt ?? await limitsEnforcedSince(), stamp };
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Composers that hold text the server may not have yet. An automatic
* sign-out (the organisation's idle timeout or maximum session length) saves
* them as drafts before the credentials go: each open composer registers a
* `flush` that runs its own draft save, and the host of a minimised composer
* (its state lives only in the host's memory) registers one that reopens it
* so the composer can save itself.
*
* A flush that hangs must not keep the user signed in, so flushComposers
* waits at most `timeoutMs` for all of them together.
*/
export type ComposeFlush = () => Promise<void>;
let nextId = 1;
const flushes = new Map<number, ComposeFlush>();
const waiters = new Set<(flush: ComposeFlush) => void>();
/** Register a composer's flush; returns the unregister function. */
export function registerComposeFlush(flush: ComposeFlush): () => void {
const id = nextId++;
flushes.set(id, flush);
for (const waiter of [...waiters]) waiter(flush);
return () => {
flushes.delete(id);
};
}
/**
* The flush of the next composer to register, or null after `timeoutMs`.
* A minimised composer's host reopens it and waits for it here.
*/
export function waitForComposeFlush(timeoutMs: number): Promise<ComposeFlush | null> {
return new Promise((resolve) => {
const waiter = (flush: ComposeFlush) => {
clearTimeout(timer);
waiters.delete(waiter);
resolve(flush);
};
const timer = setTimeout(() => {
waiters.delete(waiter);
resolve(null);
}, timeoutMs);
waiters.add(waiter);
});
}
export interface FlushResult {
/** Flushes that were registered when the sign-out started. */
total: number;
/** Flushes that finished (or failed) in time. */
settled: number;
/** Flushes that reported a save the server didn't take (threw). */
failed: number;
timedOut: boolean;
}
/** Run every registered flush and wait for them, but no longer than `timeoutMs`. */
export async function flushComposers(timeoutMs: number): Promise<FlushResult> {
const pending = [...flushes.values()];
if (pending.length === 0) return { total: 0, settled: 0, failed: 0, timedOut: false };
let settled = 0;
let failed = 0;
const all = Promise.all(pending.map(async (flush) => {
try {
await flush();
} catch {
// The sign-out goes on; the login page says a draft wasn't saved.
failed += 1;
}
settled += 1;
}));
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
all.then(() => false),
new Promise<boolean>((resolve) => { timer = setTimeout(() => resolve(true), timeoutMs); }),
]);
clearTimeout(timer);
return { total: pending.length, settled, failed, timedOut };
}
/** Test helper: forget every registration. */
export function resetComposeFlushRegistry(): void {
flushes.clear();
waiters.clear();
}
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { quickReplyEnvelope, saveQuickReplyDraft } from '../quick-reply-envelope';
import type { Email, Identity } from '@/lib/jmap/types';
const me: Identity = { id: 'id-me', name: 'Org Alice', email: 'org-alice@example.org', mayDelete: false };
const team: Identity = { id: 'id-team', name: 'Org Team', email: 'org-team@example.org', mayDelete: false };
const message = {
id: 'm1',
subject: 'Quarterly numbers',
from: [{ name: 'Bob', email: 'org-bob@example.org' }],
replyTo: [{ name: 'Bob (desk)', email: 'desk@example.org' }],
to: [{ name: 'Team', email: 'org-team@example.org' }],
cc: [],
messageId: ['abc@example.org'],
references: ['root@example.org'],
} as unknown as Email;
describe('quick reply addressing', () => {
it('answers Reply-To from the identity the message was delivered to', () => {
const envelope = quickReplyEnvelope(message, [me, team]);
expect(envelope.recipients).toEqual(['desk@example.org']);
expect(envelope.sendingIdentity?.id).toBe('id-team');
expect(envelope.primaryIdentity?.id).toBe('id-me');
});
});
describe('saving a quick reply as a draft before an automatic sign-out', () => {
it('saves the typed text as a threaded reply draft', async () => {
const createDraft = vi.fn().mockResolvedValue('d1');
await saveQuickReplyDraft({ client: { createDraft } as never, email: message, identities: [me, team], body: 'On it.', replyPrefix: 'Re:' });
expect(createDraft).toHaveBeenCalledTimes(1);
const [to, subject, body, , , identityId, fromEmail, draftId, , fromName, htmlBody, inReplyTo, references] = createDraft.mock.calls[0];
expect(to).toEqual(['desk@example.org']);
expect(subject).toBe('Re: Quarterly numbers');
expect(body).toBe('On it.');
expect(identityId).toBe('id-team');
expect(fromEmail).toBe('org-team@example.org');
expect(fromName).toBe('Org Team');
expect(draftId).toBeUndefined();
expect(htmlBody).toBeUndefined();
expect(inReplyTo).toEqual(['abc@example.org']);
expect(references).toEqual(['root@example.org', 'abc@example.org']);
});
});
+74
View File
@@ -0,0 +1,74 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Identity } from '@/lib/jmap/types';
import { computeReplyThreadingHeaders } from '@/lib/email-threading';
import { findDraftIdentityId, findReplyIdentityId } from '@/lib/reply-identity';
import { buildReplyRecipients, isSelfSent } from '@/lib/reply-recipients';
import { buildReplySubject } from '@/lib/subject-prefix';
type ReplySource = Pick<Email, 'from' | 'replyTo' | 'to' | 'cc' | 'bcc'>;
export interface QuickReplyEnvelope {
recipients: string[];
/** The identity the reply goes out as, the primary one when none matches. */
sendingIdentity: Identity | undefined;
primaryIdentity: Identity | undefined;
}
/**
* Who a quick reply goes to and from whom: the composer's addressing rules
* (Reply-To over From; for our own messages in a thread, the original
* recipients instead of ourselves, #703), from the address the message was
* delivered to, or for our own message the identity it was sent from.
*
* Quick reply resolves the user's OWN identities only. It deliberately does
* NOT take the domain catch-all `From:` rewrite that the full composer
* offers: this surface is a bare text box with no From row, so a rewritten
* From would be applied with nothing on screen to show it - or to correct
* it.
*/
export function quickReplyEnvelope(email: ReplySource, identities: Identity[]): QuickReplyEnvelope {
const ownIdentityEmails = identities.map((i) => i.email).filter(Boolean);
const replySource = { from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc };
const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
.map((r) => r.email)
.filter((address): address is string => Boolean(address));
const primaryIdentity = identities[0];
const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
? findDraftIdentityId(identities, email.from?.[0])
: null;
const sendingIdentityId = selfSentIdentityId ?? findReplyIdentityId(identities, { to: email.to, cc: email.cc, bcc: email.bcc });
const sendingIdentity = (sendingIdentityId ? identities.find((i) => i.id === sendingIdentityId) : undefined) || primaryIdentity;
return { recipients, sendingIdentity, primaryIdentity };
}
/**
* Saves the text typed into a quick reply as a draft reply, before an
* automatic sign-out (lib/compose-flush-registry.ts). Plain text, threaded
* like the reply would be; the signature is added when it is sent.
*/
export async function saveQuickReplyDraft(input: {
client: IJMAPClient;
email: Email;
identities: Identity[];
body: string;
replyPrefix: string;
}): Promise<void> {
const { client, email, identities, body, replyPrefix } = input;
const { recipients, sendingIdentity } = quickReplyEnvelope(email, identities);
const threading = computeReplyThreadingHeaders({ messageId: email.messageId, references: email.references });
await client.createDraft(
recipients,
buildReplySubject(email.subject || '', replyPrefix),
body,
undefined,
undefined,
sendingIdentity?.id,
sendingIdentity?.email,
undefined,
undefined,
sendingIdentity?.name || undefined,
undefined,
threading?.inReplyTo,
threading?.references,
);
}
@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { doAppGetSettings } from '../host-api-app';
import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { setCurrentSessionLimits } from '@/lib/session-policy/limits';
/**
* Plugins read `sessionTimeout` from the settings view. The user setting of
* that name never did anything; the view reports the idle timeout the
* organisation set instead.
*/
describe('plugin settings view: sessionTimeout', () => {
beforeEach(() => {
useSettingsStore.setState({ sessionTimeout: 45 } as never);
usePolicyStore.setState({
rawPolicy: DEFAULT_POLICY,
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, session: { idleMinutes: 20, maxHours: 0, warnSeconds: 60 } },
},
});
setCurrentSessionLimits(null);
});
afterEach(() => {
setCurrentSessionLimits(null);
});
it("reports the active account's idle timeout before the guard has looked at every account", () => {
expect(doAppGetSettings().sessionTimeout).toBe(20);
});
it('reports the strictest idle timeout of the signed-in accounts', () => {
setCurrentSessionLimits({ idle: { minutes: 5, warnSeconds: 60 }, max: null });
expect(doAppGetSettings().sessionTimeout).toBe(5);
});
it('reports 0 when no account has an idle timeout, whatever the old user setting says', () => {
setCurrentSessionLimits({ idle: null, max: null });
expect(doAppGetSettings().sessionTimeout).toBe(0);
});
});
+8
View File
@@ -17,6 +17,7 @@ import { productName } from '@/lib/product-name';
import {
collectSubResults, getByPath, tabKeywords, tabSearchPaths, withAppName, type SettingsSearchTab, type SubResult,
} from '@/lib/settings-search';
import { currentSessionLimits } from '@/lib/session-policy/limits';
import type { AppSettingsView, SettingsSearchResult } from '../plugin-types';
type Messages = Record<string, unknown>;
@@ -144,6 +145,13 @@ export function doAppGetSettings(): AppSettingsView {
if (typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) out[key] = value;
else if (Array.isArray(value) && value.every((v) => typeof v === 'number' || typeof v === 'string')) out[key] = value.slice(0, 20) as number[] | string[];
}
// Minutes without activity before Bulwark signs out (0 = never). The user
// setting of that name was never read; the organisation's idle timeout is
// what applies, the strictest of the accounts signed in here.
const limits = currentSessionLimits();
out.sessionTimeout = limits
? limits.idle?.minutes ?? 0
: usePolicyStore.getState().getOrganisation().session.idleMinutes;
const theme = (useThemeStore.getState() as unknown as { theme?: unknown }).theme;
if (typeof theme === 'string') out.theme = theme;
const locale = useLocaleStore.getState().locale;
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LAST_ACTIVITY_KEY, openActivityChannel, readLastActivity } from '../activity-channel';
/** BroadcastChannel between "tabs" of one test: every instance of a name hears the others. */
class FakeBroadcastChannel {
static instances: FakeBroadcastChannel[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
closed = false;
constructor(public name: string) {
FakeBroadcastChannel.instances.push(this);
}
postMessage(data: unknown) {
for (const other of FakeBroadcastChannel.instances) {
if (other !== this && !other.closed && other.name === this.name) other.onmessage?.({ data } as MessageEvent);
}
}
close() {
this.closed = true;
}
}
describe('activity shared between tabs', () => {
beforeEach(() => {
localStorage.clear();
FakeBroadcastChannel.instances = [];
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('tells the other tabs through a BroadcastChannel and keeps the time', () => {
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
const heardA = vi.fn();
const heardB = vi.fn();
const a = openActivityChannel(heardA);
const b = openActivityChannel(heardB);
a.post(1234);
expect(heardB).toHaveBeenCalledWith(1234);
expect(heardA).not.toHaveBeenCalled();
expect(readLastActivity()).toBe(1234);
b.close();
a.post(5678);
expect(heardB).toHaveBeenCalledTimes(1);
a.close();
});
it('falls back to storage events without BroadcastChannel', () => {
vi.stubGlobal('BroadcastChannel', undefined);
const heard = vi.fn();
const channel = openActivityChannel(heard);
// Another tab's write arrives as a storage event.
window.dispatchEvent(new StorageEvent('storage', { key: LAST_ACTIVITY_KEY, newValue: '4321' }));
window.dispatchEvent(new StorageEvent('storage', { key: 'something-else', newValue: '1' }));
window.dispatchEvent(new StorageEvent('storage', { key: LAST_ACTIVITY_KEY, newValue: 'garbage' }));
expect(heard).toHaveBeenCalledTimes(1);
expect(heard).toHaveBeenCalledWith(4321);
channel.post(9999);
expect(readLastActivity()).toBe(9999);
channel.close();
window.dispatchEvent(new StorageEvent('storage', { key: LAST_ACTIVITY_KEY, newValue: '5555' }));
expect(heard).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { MAX_SKEW_MARGIN_MS, sessionLimits } from '../limits';
const HOUR = 60 * 60 * 1000;
const session = (idleMinutes: number, maxHours: number, warnSeconds = 60) => ({ idleMinutes, maxHours, warnSeconds });
describe('session limits of the signed-in accounts', () => {
it('takes the shortest idle time that is set', () => {
const limits = sessionLimits([
{ session: session(30, 0, 120) },
{ session: session(0, 0) },
{ session: session(10, 0, 45) },
]);
expect(limits.idle).toEqual({ minutes: 10, warnSeconds: 45 });
});
it('ignores accounts that set nothing', () => {
expect(sessionLimits([{ session: session(0, 0) }, { session: undefined }])).toEqual({ idle: null, max: null });
expect(sessionLimits([])).toEqual({ idle: null, max: null });
});
it('ends at the first maximum that runs out, counted from each sign-in, a minute early', () => {
const limits = sessionLimits([
{ session: session(0, 8), signedInAt: 10 * HOUR },
{ session: session(0, 12), signedInAt: 0 },
{ session: session(0, 4, 30), signedInAt: 9 * HOUR },
]);
expect(limits.max).toEqual({ at: 12 * HOUR - 60 * 1000, hours: 12, warnSeconds: 60 });
expect(MAX_SKEW_MARGIN_MS).toBe(60 * 1000);
});
it('needs a sign-in time to count a maximum', () => {
expect(sessionLimits([{ session: session(0, 8) }]).max).toBeNull();
});
});
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSessionTimer, type SessionTimerState } from '../session-timer';
import type { SessionLimits } from '../limits';
const START = new Date('2026-10-28T09:00:00Z').getTime();
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const idleOnly = (minutes: number, warnSeconds: number): SessionLimits => ({ idle: { minutes, warnSeconds }, max: null });
function setup(limits: SessionLimits) {
const states: SessionTimerState[] = [];
const onExpire = vi.fn();
const timer = createSessionTimer({ limits, lastActivity: Date.now(), onChange: (s) => states.push(s), onExpire });
return { timer, states, onExpire };
}
describe('session timer', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(START);
});
afterEach(() => {
vi.useRealTimers();
});
it('warns warnSeconds before the idle time runs out, then signs out at zero', () => {
const { timer, onExpire } = setup(idleOnly(1, 30));
expect(timer.state()).toMatchObject({ phase: 'active', kind: 'idle', deadline: START + MINUTE });
vi.advanceTimersByTime(30 * SECOND - 1);
expect(timer.state().phase).toBe('active');
vi.advanceTimersByTime(1);
expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'idle', deadline: START + MINUTE });
expect(onExpire).not.toHaveBeenCalled();
vi.advanceTimersByTime(30 * SECOND - 1);
expect(onExpire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onExpire).toHaveBeenCalledWith('idle');
expect(timer.state().phase).toBe('expired');
timer.dispose();
});
it('starts the idle time again on activity before the warning', () => {
const { timer, onExpire } = setup(idleOnly(1, 30));
vi.advanceTimersByTime(20 * SECOND);
timer.activity();
// Without the reset the warning would have started at 30 s.
vi.advanceTimersByTime(29 * SECOND);
expect(timer.state().phase).toBe('active');
vi.advanceTimersByTime(1 * SECOND);
expect(timer.state().phase).toBe('warning');
expect(timer.state().deadline).toBe(START + 20 * SECOND + MINUTE);
vi.advanceTimersByTime(30 * SECOND);
expect(onExpire).toHaveBeenCalledTimes(1);
timer.dispose();
});
it('keeps the warning up on local activity until the user chooses to stay', () => {
const { timer, onExpire } = setup(idleOnly(1, 30));
vi.advanceTimersByTime(40 * SECOND);
expect(timer.state().phase).toBe('warning');
timer.activity();
expect(timer.state().phase).toBe('warning');
timer.stay();
expect(timer.state()).toMatchObject({ phase: 'active', deadline: START + 40 * SECOND + MINUTE });
vi.advanceTimersByTime(MINUTE - 1);
expect(onExpire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onExpire).toHaveBeenCalledWith('idle');
timer.dispose();
});
it('ends the warning when another tab reports activity', () => {
const { timer, onExpire } = setup(idleOnly(1, 30));
vi.advanceTimersByTime(45 * SECOND);
expect(timer.state().phase).toBe('warning');
timer.remoteActivity(Date.now());
expect(timer.state().phase).toBe('active');
vi.advanceTimersByTime(MINUTE - 1);
expect(onExpire).not.toHaveBeenCalled();
timer.dispose();
});
it('signs out after a sleep even when the wake-up arrives as activity first', () => {
const { timer, onExpire } = setup(idleOnly(15, 60));
// The machine slept: the clock jumped, no timer ran.
vi.setSystemTime(START + 2 * 60 * MINUTE);
timer.activity();
expect(onExpire).toHaveBeenCalledWith('idle');
expect(timer.state().phase).toBe('expired');
timer.remoteActivity(Date.now());
timer.stay();
expect(onExpire).toHaveBeenCalledTimes(1);
timer.dispose();
});
it('runs no clock without limits', () => {
const { timer, onExpire } = setup({ idle: null, max: null });
expect(timer.state()).toEqual({ phase: 'active', kind: null, deadline: null });
vi.advanceTimersByTime(365 * 24 * 60 * MINUTE);
expect(onExpire).not.toHaveBeenCalled();
timer.dispose();
});
it('follows new limits', () => {
const { timer, onExpire } = setup({ idle: null, max: null });
vi.advanceTimersByTime(10 * MINUTE);
timer.setLimits(idleOnly(5, 60));
// The last activity was ten minutes ago: already past five minutes.
expect(onExpire).toHaveBeenCalledWith('idle');
timer.dispose();
});
it('warns before the maximum session length and does not move it on activity', () => {
const at = START + 2 * 60 * MINUTE;
const { timer, onExpire } = setup({ idle: { minutes: 30, warnSeconds: 60 }, max: { at, hours: 2, warnSeconds: 60 } });
for (let i = 0; i < 119; i++) {
vi.advanceTimersByTime(MINUTE);
timer.activity();
}
expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'max', deadline: at });
timer.stay();
expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'max' });
vi.advanceTimersByTime(MINUTE);
expect(onExpire).toHaveBeenCalledWith('max');
timer.dispose();
});
it('counts a maximum far away without running its timer early', () => {
const at = START + 365 * 24 * 60 * MINUTE;
const { timer, onExpire } = setup({ idle: null, max: { at, hours: 365 * 24, warnSeconds: 60 } });
vi.advanceTimersByTime(30 * 24 * 60 * MINUTE);
expect(onExpire).not.toHaveBeenCalled();
expect(timer.state().phase).toBe('active');
timer.dispose();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Activity shared between Bulwark tabs, so working in one tab keeps the
* others signed in. Tabs tell each other through a BroadcastChannel; where
* there is none, through `storage` events on a localStorage key. The last
* activity is also kept in localStorage, so a tab opened (or reloaded) after
* a long pause starts from it rather than from the page load.
*/
const CHANNEL = 'bulwark-session-activity';
export const LAST_ACTIVITY_KEY = 'bulwark-last-activity';
export interface ActivityChannel {
/** Tell the other tabs about activity at `at` (ms). */
post(at: number): void;
close(): void;
}
function parse(value: unknown): number | null {
const at = typeof value === 'string' ? Number(value) : value;
return typeof at === 'number' && Number.isFinite(at) && at > 0 ? at : null;
}
/** The last activity any tab recorded, or null. */
export function readLastActivity(): number | null {
try {
return parse(window.localStorage.getItem(LAST_ACTIVITY_KEY));
} catch {
return null;
}
}
export function openActivityChannel(onActivity: (at: number) => void): ActivityChannel {
let channel: BroadcastChannel | null = null;
if (typeof BroadcastChannel !== 'undefined') {
try {
channel = new BroadcastChannel(CHANNEL);
channel.onmessage = (event: MessageEvent) => {
const at = parse((event.data as { at?: unknown } | null)?.at);
if (at) onActivity(at);
};
} catch {
channel = null;
}
}
// Without a channel, the localStorage write below reaches the other tabs as a storage event.
const onStorage = (event: StorageEvent) => {
if (event.key !== LAST_ACTIVITY_KEY) return;
const at = parse(event.newValue);
if (at) onActivity(at);
};
if (!channel) window.addEventListener('storage', onStorage);
return {
post(at) {
try {
window.localStorage.setItem(LAST_ACTIVITY_KEY, String(at));
} catch {
/* storage unavailable */
}
try {
channel?.postMessage({ at });
} catch {
/* channel closed */
}
},
close() {
channel?.close();
if (!channel) window.removeEventListener('storage', onStorage);
},
};
}
+6
View File
@@ -0,0 +1,6 @@
/**
* What the sign-in routes answer (`error`) when they refuse a session that
* reached the organisation's maximum length. The server (lib/auth/session-age.ts)
* and the browser (stores/auth-store.ts) both read it from here.
*/
export const SESSION_TOO_OLD = 'session_expired';
+67
View File
@@ -0,0 +1,67 @@
/**
* The organisation's session limits (policy.json `organisation.session`) as
* they apply to this browser. Every account signed in here has its own
* effective policy (its domain and group scopes); the browser follows the
* strictest: the shortest idle time any of them sets, and the first
* maximum session length to run out. 0 is off, so it never wins.
*/
import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
export type SessionPolicy = OrganisationPolicy['session'];
export interface AccountSession {
session: SessionPolicy | undefined;
/** When the account signed in (ms); its maximum session length counts from here. */
signedInAt?: number;
}
export interface SessionLimits {
idle: { minutes: number; warnSeconds: number } | null;
/** `at`: when the first account's maximum session length runs out, a minute early (MAX_SKEW_MARGIN_MS). */
max: { at: number; hours: number; warnSeconds: number } | null;
}
export const NO_SESSION_LIMITS: SessionLimits = { idle: null, max: null };
const HOUR_MS = 60 * 60 * 1000;
/**
* The browser ends a maximum session length this much before the server
* would refuse it, so a clock that runs a little behind the server's still
* warns first.
*/
export const MAX_SKEW_MARGIN_MS = 60 * 1000;
export function sessionLimits(accounts: readonly AccountSession[]): SessionLimits {
let idle: SessionLimits['idle'] = null;
let max: SessionLimits['max'] = null;
for (const account of accounts) {
const session = account.session ?? DEFAULT_ORGANISATION_POLICY.session;
const warnSeconds = session.warnSeconds;
if (session.idleMinutes > 0 && (!idle || session.idleMinutes < idle.minutes)) {
idle = { minutes: session.idleMinutes, warnSeconds };
}
if (session.maxHours > 0 && typeof account.signedInAt === 'number') {
const at = account.signedInAt + session.maxHours * HOUR_MS - MAX_SKEW_MARGIN_MS;
if (!max || at < max.at) max = { at, hours: session.maxHours, warnSeconds };
}
}
return { idle, max };
}
export function sameLimits(a: SessionLimits, b: SessionLimits): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
let current: SessionLimits | null = null;
/** The limits the guard counts with, for readers outside React (the plugin settings view). */
export function setCurrentSessionLimits(limits: SessionLimits | null): void {
current = limits;
}
/** Null before the guard has looked at the signed-in accounts. */
export function currentSessionLimits(): SessionLimits | null {
return current;
}
+139
View File
@@ -0,0 +1,139 @@
/**
* The clock behind the organisation's idle timeout and maximum session
* length. It knows the last activity and the limits, and says when the
* warning starts and when the session ends; the guard component
* (components/session/session-timeout-guard.tsx) feeds it activity and
* does the signing out.
*
* Deadlines are times, not countdowns: a laptop that slept through one
* expires on the first check after waking, and activity that arrives after
* the deadline (the focus event of the wake-up itself) can't bring the
* session back.
*/
import type { SessionLimits } from './limits';
export type SessionEndKind = 'idle' | 'max';
export interface SessionTimerState {
phase: 'active' | 'warning' | 'expired';
/** Which limit the next deadline belongs to; null when no limit is set. */
kind: SessionEndKind | null;
/** When the session ends (ms); null when no limit is set. */
deadline: number | null;
}
export interface SessionTimerOptions {
limits: SessionLimits;
lastActivity: number;
onChange?: (state: SessionTimerState) => void;
onExpire: (kind: SessionEndKind) => void;
now?: () => number;
}
export interface SessionTimer {
/** Activity in this tab. Ignored while the idle warning shows: the user answers it with "Stay signed in". */
activity(at?: number): void;
/** Activity in another tab, where the user may have answered the warning already. */
remoteActivity(at: number): void;
/** "Stay signed in": restarts the idle time, never the maximum. */
stay(): void;
setLimits(limits: SessionLimits): void;
/** Look at the clock now (after a wake-up or a tab becoming visible). */
check(): void;
lastActivity(): number;
state(): SessionTimerState;
dispose(): void;
}
const MINUTE_MS = 60 * 1000;
const MAX_WAIT_MS = 60 * MINUTE_MS;
export function createSessionTimer(options: SessionTimerOptions): SessionTimer {
const now = options.now ?? Date.now;
let limits = options.limits;
let lastActivity = options.lastActivity;
let state: SessionTimerState = { phase: 'active', kind: null, deadline: null };
let timer: ReturnType<typeof setTimeout> | null = null;
let disposed = false;
const clear = () => {
if (timer) clearTimeout(timer);
timer = null;
};
const publish = (next: SessionTimerState) => {
if (next.phase === state.phase && next.kind === state.kind && next.deadline === state.deadline) return;
state = next;
options.onChange?.(state);
};
/** The deadline that comes first, with how long its warning lasts. */
const target = (): { kind: SessionEndKind; deadline: number; warnMs: number } | null => {
const idleMs = limits.idle ? limits.idle.minutes * MINUTE_MS : 0;
const idle = limits.idle
? { kind: 'idle' as const, deadline: lastActivity + idleMs, warnMs: Math.min(limits.idle.warnSeconds * 1000, idleMs) }
: null;
const max = limits.max
? { kind: 'max' as const, deadline: limits.max.at, warnMs: limits.max.warnSeconds * 1000 }
: null;
if (idle && max) return max.deadline <= idle.deadline ? max : idle;
return idle ?? max;
};
const evaluate = () => {
if (disposed || state.phase === 'expired') return;
clear();
const next = target();
if (!next) {
publish({ phase: 'active', kind: null, deadline: null });
return;
}
const at = now();
if (at >= next.deadline) {
publish({ phase: 'expired', kind: next.kind, deadline: next.deadline });
options.onExpire(next.kind);
return;
}
const warnFrom = next.deadline - next.warnMs;
const warning = at >= warnFrom;
publish({ phase: warning ? 'warning' : 'active', kind: next.kind, deadline: next.deadline });
// Capped: setTimeout overflows past 24.8 days, and a long wait is checked again anyway.
timer = setTimeout(evaluate, Math.min((warning ? next.deadline : warnFrom) - at, MAX_WAIT_MS));
};
const record = (at: number) => {
// A deadline that already passed ends the session before the activity counts.
evaluate();
if (state.phase === 'expired') return;
if (at > lastActivity) lastActivity = at;
evaluate();
};
evaluate();
return {
activity(at = now()) {
evaluate();
if (state.phase === 'warning' && state.kind === 'idle') return;
record(at);
},
remoteActivity(at) {
record(at);
},
stay() {
record(now());
},
setLimits(next) {
limits = next;
evaluate();
},
check: evaluate,
lastActivity: () => lastActivity,
state: () => state,
dispose() {
disposed = true;
clear();
},
};
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Why Bulwark signed the user out on its own, for the login page to say.
* Kept in sessionStorage: the tab that signed out shows it once.
*/
export type SessionEndReason =
| { kind: 'idle'; minutes: number; draftsUnsaved?: boolean }
| { kind: 'max'; hours: number; draftsUnsaved?: boolean };
const KEY = 'session_end_reason';
export function rememberSessionEnd(reason: SessionEndReason): void {
try {
window.sessionStorage.setItem(KEY, JSON.stringify(reason));
} catch {
/* storage unavailable */
}
}
/** Takes a reason back: an account is signed in after all. */
export function clearSessionEnd(): void {
try {
window.sessionStorage.removeItem(KEY);
} catch {
/* storage unavailable */
}
}
/** The reason, once; null when the user wasn't signed out by a session limit. */
export function consumeSessionEnd(): SessionEndReason | null {
try {
const raw = window.sessionStorage.getItem(KEY);
window.sessionStorage.removeItem(KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
const drafts = parsed.draftsUnsaved === true ? { draftsUnsaved: true } : {};
if (parsed.kind === 'idle' && typeof parsed.minutes === 'number') return { kind: 'idle', minutes: parsed.minutes, ...drafts };
if (parsed.kind === 'max' && typeof parsed.hours === 'number') return { kind: 'max', hours: parsed.hours, ...drafts };
return null;
} catch {
return null;
}
}
+14
View File
@@ -76,6 +76,20 @@
"back_to_login": "Zurück zur Anmeldung"
}
},
"session_limits": {
"idle_title": "Sind Sie noch da?",
"idle_message": "Sie werden in {seconds, plural, one {# Sekunde} other {# Sekunden}} abgemeldet, weil es keine Aktivität gab. Geöffnete Nachrichten werden als Entwurf gespeichert.",
"stay": "Angemeldet bleiben",
"sign_out_now": "Jetzt abmelden",
"max_title": "Ihre Sitzung endet",
"max_message": "Ihre Organisation beendet Sitzungen {hours, plural, one {# Stunde} other {# Stunden}} nach der Anmeldung. Sie werden in {seconds, plural, one {# Sekunde} other {# Sekunden}} abgemeldet. Geöffnete Nachrichten werden als Entwurf gespeichert.",
"dismiss": "OK",
"signing_out": "Entwürfe werden gespeichert und Sie werden abgemeldet …",
"reason_idle": "Sie wurden nach {minutes, plural, one {# Minute} other {# Minuten}} ohne Aktivität abgemeldet.",
"reason_max": "Sie wurden abgemeldet, weil Ihre Sitzung die Höchstdauer von {hours, plural, one {# Stunde} other {# Stunden}} erreicht hat.",
"drafts_not_saved": "Mindestens eine geöffnete Nachricht konnte nicht als Entwurf gespeichert werden.",
"seconds_left": "Noch {seconds, plural, one {# Sekunde} other {# Sekunden}}"
},
"sidebar": {
"close": "Schließen",
"compose": "Verfassen",
+14
View File
@@ -76,6 +76,20 @@
"back_to_login": "Back to login"
}
},
"session_limits": {
"idle_title": "Are you still there?",
"idle_message": "You'll be signed out in {seconds, plural, one {# second} other {# seconds}} because there was no activity. Open messages are saved as drafts.",
"stay": "Stay signed in",
"sign_out_now": "Sign out now",
"max_title": "Your session is ending",
"max_message": "Your organisation ends sessions {hours, plural, one {# hour} other {# hours}} after sign-in. You'll be signed out in {seconds, plural, one {# second} other {# seconds}}. Open messages are saved as drafts.",
"dismiss": "OK",
"signing_out": "Saving drafts and signing out…",
"reason_idle": "You were signed out after {minutes, plural, one {# minute} other {# minutes}} without activity.",
"reason_max": "You were signed out because your session reached its maximum length of {hours, plural, one {# hour} other {# hours}}.",
"drafts_not_saved": "One or more open messages could not be saved as drafts.",
"seconds_left": "{seconds, plural, one {# second} other {# seconds}} left"
},
"sidebar": {
"close": "Close",
"compose": "Compose",
@@ -381,6 +381,22 @@ describe('signing out of several accounts', () => {
expect(calls).toEqual(['DELETE /api/auth/session?all=true', 'DELETE /api/auth/token?all=true']);
expect(replaceSpy).toHaveBeenCalledWith('/en/login');
});
it('leaves every provider session alone for a sign-out the session limits made', async () => {
signIn([alice, bob, carol], 'carol');
const { calls } = mockFetch({
'DELETE /api/auth/session?all=true': () => ok(),
'DELETE /api/auth/token?all=true': () => ok(),
});
await useAuthStore.getState().logoutAll({ endProviderSession: false });
expect(calls).toEqual(['DELETE /api/auth/session?all=true', 'DELETE /api/auth/token?all=true']);
expect(replaceSpy).toHaveBeenCalledWith('/en/login');
expect(useAccountStore.getState().accounts).toEqual([]);
// The login page shows the form rather than signing straight back in.
expect(consumeSignedOut()).toBe(true);
});
});
// Leaving for the provider is final for the page, so that state is never
@@ -0,0 +1,95 @@
import { beforeEach, expect, it, vi } from 'vitest';
import { createConfig } from '@/lib/__tests__/fixtures/config';
// The server refuses to restore a session older than the organisation's
// maximum session length (lib/auth/session-age.ts). The login page then says
// why the user has to sign in again.
vi.mock('@/lib/jmap/client', () => {
class JMAPClient {
constructor(public serverUrl: string, public username: string, public password: string) {}
static withBearer(serverUrl: string, _token: string, username: string) { return new JMAPClient(serverUrl, username, ''); }
async connect() {}
getAuthHeader() { return 'Basic x'; }
onConnectionChange() {}
onRateLimit() {}
getRateLimitRemainingMs() { return 0; }
disconnect() {}
getUsername() { return this.username; }
async getIdentities() { return []; }
supportsContacts() { return false; }
supportsPrincipals() { return false; }
supportsVacationResponse() { return false; }
supportsCalendars() { return false; }
supportsSieve() { return false; }
getSharedAccounts() { return []; }
getAccountInfo() { return undefined; }
}
class RateLimitError extends Error {}
class RequestTimeoutError extends Error {}
return { JMAPClient, RateLimitError, RequestTimeoutError };
});
import * as nav from '@/lib/browser-navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { consumeSessionEnd } from '@/lib/session-policy/sign-out-reason';
const account = (authMode: 'basic' | 'oauth') => ({
id: 'alice@example.org@mail.example.org', label: 'Alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org', authMode,
rememberMe: true, providerSession: authMode === 'oauth', cookieSlot: 0, displayName: 'Alice', email: 'alice@example.org', isConnected: false,
hasError: false, lastLoginAt: 1, avatarColor: '#000000', isDefault: true,
});
const tooOld = () => new Response(JSON.stringify({ error: 'session_expired', maxHours: 8 }), { status: 401 });
beforeEach(() => {
sessionStorage.clear();
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
const url = String(input);
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
if (url.includes('/api/auth/session') && init?.method === 'PUT') return tooOld();
if (url.includes('/api/auth/token') && init?.method === 'PUT') return tooOld();
return new Response('{}', { status: 200 });
}));
vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(() => {});
});
it('says on the login page that a remembered session reached its maximum length', async () => {
useAccountStore.setState({ accounts: [account('basic')] as never, activeAccountId: account('basic').id, defaultAccountId: account('basic').id });
await useAuthStore.getState().checkAuth();
await vi.waitFor(() => expect(useAccountStore.getState().accounts).toHaveLength(0));
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
});
it('says so for a provider sign-in whose renewal the server refused', async () => {
useAccountStore.setState({ accounts: [account('oauth')] as never, activeAccountId: account('oauth').id, defaultAccountId: account('oauth').id });
await useAuthStore.getState().checkAuth();
await vi.waitFor(() => expect(useAccountStore.getState().accounts).toHaveLength(0));
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
});
it('says nothing when another account comes back, and takes the server sign-in time', async () => {
const bob = {
...account('basic'), id: 'bob@example.org@mail.example.org', username: 'bob@example.org', email: 'bob@example.org', cookieSlot: 1, isDefault: false,
};
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
const url = String(input);
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
if (url.includes('/api/auth/session?slot=1') && init?.method === 'PUT') {
return new Response(JSON.stringify({ serverUrl: 'https://mail.example.org', username: 'bob@example.org', password: 'pw', signedInAt: 1234 }), { status: 200 });
}
if (url.includes('/api/auth/session') && init?.method === 'PUT') return tooOld();
return new Response('{}', { status: 200 });
}));
useAccountStore.setState({ accounts: [account('basic'), bob] as never, activeAccountId: account('basic').id, defaultAccountId: account('basic').id });
await useAuthStore.getState().checkAuth();
await vi.waitFor(() => expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([bob.id]));
await vi.waitFor(() => expect(useAccountStore.getState().accounts[0].signedInAt).toBe(1234));
expect(consumeSessionEnd()).toBeNull();
});
+6
View File
@@ -43,6 +43,12 @@ export interface AccountEntry {
avatarColor: string;
/** Timestamp of last successful login */
lastLoginAt: number;
/**
* When the user last signed in to it with a password, token or provider
* login (not a restore). The organisation's maximum session length counts
* from here. `undefined` = signed in before this was recorded.
*/
signedInAt?: number;
/** Whether this account is currently connected */
isConnected: boolean;
/** Whether this account had a connection error */
+69 -11
View File
@@ -24,6 +24,8 @@ 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';
@@ -108,9 +110,10 @@ interface AuthState {
logout: (options?: LogoutOptions) => Promise<void>;
/**
* Sign out of every account. Ends at most one identity provider session:
* see {@link pickEndSessionSlot}.
* see {@link pickEndSessionSlot}; none with `endProviderSession: false`
* (the organisation's session limits, which sign out without asking).
*/
logoutAll: () => Promise<void>;
logoutAll: (options?: LogoutOptions) => Promise<void>;
removeAccount: (accountId: string) => void;
switchAccount: (accountId: string) => Promise<void>;
checkAuth: () => Promise<void>;
@@ -418,7 +421,7 @@ async function exchangePasswordForTokens(params: {
// 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 });
@@ -458,7 +461,7 @@ async function exchangeOAuthCode(params: {
slot,
...(serverId ? { server_id: serverId } : {}),
}),
});
}).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
@@ -528,19 +531,67 @@ async function persistSession(slot: number, session: RememberedSession): Promise
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. */
async function fetchSlotAccessToken(slot: number, opts: { force?: 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) {
return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal }));
return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { 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).
@@ -562,7 +613,8 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}
/** 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' });
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);
@@ -1267,6 +1319,10 @@ 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 ?? '' });
}
@@ -1915,7 +1971,7 @@ export const useAuthStore = create<AuthState>()(
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, state, slot }),
}),
}).then((res) => noteSessionAnswer(res, slot)),
fetchConfig(),
]);
@@ -2315,12 +2371,14 @@ export const useAuthStore = create<AuthState>()(
clearPluginStorageForAccount(accountId);
},
logoutAll: async () => {
logoutAll: async (options) => {
await beforeLogoutHooks('all');
const accountStore = useAccountStore.getState();
const allAccounts = [...accountStore.accounts];
const endSessionSlot = get().isDemoMode ? null : pickEndSessionSlot(allAccounts, get().activeAccountId);
// `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.
@@ -3092,7 +3150,7 @@ export const useAuthStore = create<AuthState>()(
bindClientStatusHandlers(client, set, get, accountId);
accountStore.updateAccount(accountId, {
awaitingSignIn: false, isConnected: true, hasError: false, errorMessage: undefined,
rememberMe, lastLoginAt: Date.now(),
rememberMe, lastLoginAt: Date.now(), signedInAt: signedInAtForSlot(cookieSlot),
});
void syncAccountDisplayName(accountId, client);
set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 }));