> | 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();
+ 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();
+ 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();
+ view.unmount();
+
+ await expect(flushComposers(1000)).resolves.toMatchObject({ total: 0 });
+ });
+});
diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx
index 27250067a..fb791f74a 100644
--- a/components/email/__tests__/viewer-send-failures.test.tsx
+++ b/components/email/__tests__/viewer-send-failures.test.tsx
@@ -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(
+ ,
+ );
+ 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',
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 01ac2feb7..6da50a5b9 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -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";
* 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 = () => ({
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 54a8409d6..7ab8f5dac 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -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;
+ /** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */
+ onSaveQuickReplyDraft?: (body: string) => Promise;
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);
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index e5bfaf3c9..30bdb9dd7 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -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}
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 255f9fa39..8c8288b87 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -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()}
diff --git a/components/session/__tests__/session-timeout-dialog.test.tsx b/components/session/__tests__/session-timeout-dialog.test.tsx
new file mode 100644
index 000000000..ad4603516
--- /dev/null
+++ b/components/session/__tests__/session-timeout-dialog.test.tsx
@@ -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(
+
+
+
,
+ );
+ 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();
+ 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();
+ const dialog = screen.getByRole('alertdialog');
+ const described = dialog.getAttribute('aria-describedby');
+ expect(described && document.getElementById(described)).toBeTruthy();
+
+ rerender();
+ expect(screen.getByRole('alertdialog').hasAttribute('aria-describedby')).toBe(false);
+ });
+});
diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx
new file mode 100644
index 000000000..90b040cd3
--- /dev/null
+++ b/components/session/__tests__/session-timeout-guard.test.tsx
@@ -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;
+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();
+ // 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(() => {}));
+
+ 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();
+ 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();
+ 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();
+ // 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();
+ 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();
+ });
+});
diff --git a/components/session/session-timeout-dialog.tsx b/components/session/session-timeout-dialog.tsx
new file mode 100644
index 000000000..edcd06e6c
--- /dev/null
+++ b/components/session/session-timeout-dialog.tsx
@@ -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(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(
+
+
+
+
+ {mode === "ending" ? : }
+
+
+
{title}
+ {mode !== "ending" && (
+
+ {mode === "max"
+ ? t("max_message", { hours: maxHours ?? 0, seconds })
+ : t("idle_message", { seconds })}
+
+ )}
+
+ {mode !== "ending" && announced !== null ? t("seconds_left", { seconds: announced }) : ""}
+
+
+
+ {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.
+
+ {mode === "idle"
+ ?
+ : }
+
+
+ )}
+
+
,
+ document.body,
+ );
+}
diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx
new file mode 100644
index 000000000..f8a5e6006
--- /dev/null
+++ b/components/session/session-timeout-guard.tsx
@@ -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 }>();
+function subjectFor(client: IJMAPClient, revision: number): Promise {
+ 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 {
+ const auth = useAuthStore.getState();
+ const clients = new Map(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(NO_SESSION_LIMITS);
+ const [limitsReady, setLimitsReady] = useState(false);
+ const [state, setState] = useState(IDLE_STATE);
+ const [ending, setEnding] = useState(false);
+ const [dismissedMax, setDismissedMax] = useState(null);
+ const timerRef = useRef(null);
+ const timersStartedRef = useRef(0);
+ const channelRef = useRef(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 ;
+ }
+ if (state.phase !== "warning" || !state.kind) return null;
+ if (state.kind === "max" && dismissedMax === state.deadline) return null;
+ return (
+ setDismissedMax(state.deadline)}
+ onSignOut={signOutNow}
+ />
+ );
+}
diff --git a/hooks/__tests__/use-minimised-composer-flush.test.tsx b/hooks/__tests__/use-minimised-composer-flush.test.tsx
new file mode 100644
index 000000000..61031d1ae
--- /dev/null
+++ b/hooks/__tests__/use-minimised-composer-flush.test.tsx
@@ -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 });
+ });
+});
diff --git a/hooks/use-minimised-composer-flush.ts b/hooks/use-minimised-composer-flush.ts
new file mode 100644
index 000000000..28b1ccccd
--- /dev/null
+++ b/hooks/use-minimised-composer-flush.ts
@@ -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]);
+}
diff --git a/integration/tests/19-org-sessions.spec.ts b/integration/tests/19-org-sessions.spec.ts
new file mode 100644
index 000000000..58b5e3203
--- /dev/null
+++ b/integration/tests/19-org-sessions.spec.ts
@@ -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;
+let alice: JmapClient;
+
+async function draftSubjects(client: JmapClient): Promise {
+ 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 {
+ 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();
+});
diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts
index 2fa3c854d..5d296c0e1 100644
--- a/lib/__tests__/auth-crypto.test.ts
+++ b/lib/__tests__/auth-crypto.test.ts
@@ -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));
diff --git a/lib/__tests__/auth-login-routes-budget.test.ts b/lib/__tests__/auth-login-routes-budget.test.ts
index 793cc52f5..1a228c07a 100644
--- a/lib/__tests__/auth-login-routes-budget.test.ts
+++ b/lib/__tests__/auth-login-routes-budget.test.ts
@@ -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() }));
diff --git a/lib/__tests__/auth-routes-credential-check.test.ts b/lib/__tests__/auth-routes-credential-check.test.ts
index 3c49cbe32..f286fc472 100644
--- a/lib/__tests__/auth-routes-credential-check.test.ts
+++ b/lib/__tests__/auth-routes-credential-check.test.ts
@@ -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();
diff --git a/lib/__tests__/compose-flush-registry.test.ts b/lib/__tests__/compose-flush-registry.test.ts
new file mode 100644
index 000000000..c87cbad79
--- /dev/null
+++ b/lib/__tests__/compose-flush-registry.test.ts
@@ -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(() => {}));
+ registerComposeFlush(quick);
+
+ let result: Awaited> | 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();
+ });
+});
diff --git a/lib/__tests__/impersonation-session.test.ts b/lib/__tests__/impersonation-session.test.ts
index b70abd176..aa44a2171 100644
--- a/lib/__tests__/impersonation-session.test.ts
+++ b/lib/__tests__/impersonation-session.test.ts
@@ -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);
});
diff --git a/lib/__tests__/session-max-age-routes.test.ts b/lib/__tests__/session-max-age-routes.test.ts
new file mode 100644
index 000000000..8048e2a28
--- /dev/null
+++ b/lib/__tests__/session-max-age-routes.test.ts
@@ -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();
+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;
+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) => 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 {
+ 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' });
+ });
+});
diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts
index 1dfc768ab..89b2545a9 100644
--- a/lib/auth/crypto.ts
+++ b/lib/auth/crypto.ts
@@ -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 p.t === 'wopi',
'impersonation-grant': () => false,
'plugin-secrets': () => false,
+ 'session-age': () => false,
};
export function encryptPayload(payload: Record, purpose: PayloadPurpose): string {
diff --git a/lib/auth/session-age.ts b/lib/auth/session-age.ts
new file mode 100644
index 000000000..ba7f97e8a
--- /dev/null
+++ b/lib/auth/session-age.ts
@@ -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>;
+
+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 {
+ 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 | 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 {
+ 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 {
+ 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 = 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 = { 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 };
+}
diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts
new file mode 100644
index 000000000..d9ea445f8
--- /dev/null
+++ b/lib/compose-flush-registry.ts
@@ -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;
+
+let nextId = 1;
+const flushes = new Map();
+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 {
+ 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 {
+ 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 | undefined;
+ const timedOut = await Promise.race([
+ all.then(() => false),
+ new Promise((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();
+}
diff --git a/lib/outgoing/__tests__/quick-reply-envelope.test.ts b/lib/outgoing/__tests__/quick-reply-envelope.test.ts
new file mode 100644
index 000000000..cf6d32a44
--- /dev/null
+++ b/lib/outgoing/__tests__/quick-reply-envelope.test.ts
@@ -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']);
+ });
+});
diff --git a/lib/outgoing/quick-reply-envelope.ts b/lib/outgoing/quick-reply-envelope.ts
new file mode 100644
index 000000000..9b863865c
--- /dev/null
+++ b/lib/outgoing/quick-reply-envelope.ts
@@ -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;
+
+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 {
+ 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,
+ );
+}
diff --git a/lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts b/lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts
new file mode 100644
index 000000000..a371e4192
--- /dev/null
+++ b/lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts
@@ -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);
+ });
+});
diff --git a/lib/plugin-sandbox/host-api-app.ts b/lib/plugin-sandbox/host-api-app.ts
index 86f7f58ad..b3ddc8968 100644
--- a/lib/plugin-sandbox/host-api-app.ts
+++ b/lib/plugin-sandbox/host-api-app.ts
@@ -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;
@@ -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;
diff --git a/lib/session-policy/__tests__/activity-channel.test.ts b/lib/session-policy/__tests__/activity-channel.test.ts
new file mode 100644
index 000000000..beabf28e6
--- /dev/null
+++ b/lib/session-policy/__tests__/activity-channel.test.ts
@@ -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);
+ });
+});
diff --git a/lib/session-policy/__tests__/limits.test.ts b/lib/session-policy/__tests__/limits.test.ts
new file mode 100644
index 000000000..341d3d31c
--- /dev/null
+++ b/lib/session-policy/__tests__/limits.test.ts
@@ -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();
+ });
+});
diff --git a/lib/session-policy/__tests__/session-timer.test.ts b/lib/session-policy/__tests__/session-timer.test.ts
new file mode 100644
index 000000000..f5fef76d2
--- /dev/null
+++ b/lib/session-policy/__tests__/session-timer.test.ts
@@ -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();
+ });
+});
diff --git a/lib/session-policy/activity-channel.ts b/lib/session-policy/activity-channel.ts
new file mode 100644
index 000000000..283560c89
--- /dev/null
+++ b/lib/session-policy/activity-channel.ts
@@ -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);
+ },
+ };
+}
diff --git a/lib/session-policy/constants.ts b/lib/session-policy/constants.ts
new file mode 100644
index 000000000..dab0c03a7
--- /dev/null
+++ b/lib/session-policy/constants.ts
@@ -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';
diff --git a/lib/session-policy/limits.ts b/lib/session-policy/limits.ts
new file mode 100644
index 000000000..d748d6088
--- /dev/null
+++ b/lib/session-policy/limits.ts
@@ -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;
+}
diff --git a/lib/session-policy/session-timer.ts b/lib/session-policy/session-timer.ts
new file mode 100644
index 000000000..b9d54b8e0
--- /dev/null
+++ b/lib/session-policy/session-timer.ts
@@ -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 | 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();
+ },
+ };
+}
diff --git a/lib/session-policy/sign-out-reason.ts b/lib/session-policy/sign-out-reason.ts
new file mode 100644
index 000000000..370b3ebb1
--- /dev/null
+++ b/lib/session-policy/sign-out-reason.ts
@@ -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;
+ 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;
+ }
+}
diff --git a/locales/de/common.json b/locales/de/common.json
index ffdd16235..6d6b42629 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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",
diff --git a/locales/en/common.json b/locales/en/common.json
index 95ec87fa3..81fc096fe 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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",
diff --git a/stores/__tests__/auth-store-provider-logout.test.ts b/stores/__tests__/auth-store-provider-logout.test.ts
index 237773b58..7a22f3f79 100644
--- a/stores/__tests__/auth-store-provider-logout.test.ts
+++ b/stores/__tests__/auth-store-provider-logout.test.ts
@@ -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
diff --git a/stores/__tests__/auth-store-restore-session-too-old.test.ts b/stores/__tests__/auth-store-restore-session-too-old.test.ts
new file mode 100644
index 000000000..b2ff21a38
--- /dev/null
+++ b/stores/__tests__/auth-store-restore-session-too-old.test.ts
@@ -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();
+});
diff --git a/stores/account-store.ts b/stores/account-store.ts
index 2a6a37059..7522cf24d 100644
--- a/stores/account-store.ts
+++ b/stores/account-store.ts
@@ -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 */
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index 6a7421f7e..03e20a93c 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -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;
/**
* 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;
+ logoutAll: (options?: LogoutOptions) => Promise;
removeAccount: (accountId: string) => void;
switchAccount: (accountId: string) => Promise;
checkAuth: () => Promise;
@@ -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();
+
+/**
+ * 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 {
+ 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 {
// 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 {
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()(
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()(
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()(
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 }));