From 3da46b5a0dc8a6b9d2b6e131579eadef17b4d9cb Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 13 Oct 2026 13:13:56 +0200
Subject: [PATCH] feat: remember every sign-in unless the admin shows the
remember me box
---
.env.example | 4 ++++
app/(main)/[locale]/login/page.tsx | 16 ++++++++++------
app/(main)/admin/_tabs/settings.tsx | 1 +
app/api/config/route.ts | 1 +
hooks/use-config.ts | 4 ++++
lib/__tests__/config-route.test.ts | 10 +++++++++-
lib/__tests__/fixtures/config.ts | 1 +
lib/__tests__/lite-config.test.ts | 1 +
lib/admin/types.ts | 3 +++
lib/config-validation.ts | 1 +
lib/lite-config.ts | 7 +++++--
scripts/lite/lib.mjs | 3 ++-
12 files changed, 42 insertions(+), 10 deletions(-)
diff --git a/.env.example b/.env.example
index baf9a0043..dd3e00b8e 100644
--- a/.env.example
+++ b/.env.example
@@ -327,6 +327,10 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# as Fastmail (JMAP_SERVER_URL=https://api.fastmail.com).
# LOGIN_SHOW_TOKEN_LOGIN=true
#
+# Show the "Remember me" box (default false). Without it every sign-in is
+# remembered, provided SESSION_SECRET is set.
+# LOGIN_SHOW_REMEMBER_ME=true
+#
# Hide the version number, so it isn't disclosed to unauthenticated visitors.
# LOGIN_SHOW_VERSION=false
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index 413ab869a..19490bda6 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -157,7 +157,7 @@ function LoginPageContent() {
const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
- const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
+ const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
// Login logo sizing: when a max height/width is configured, drop the fixed
@@ -258,7 +258,11 @@ function LoginPageContent() {
clearTimeout(timer);
};
}, [probeTarget]);
- const showRememberMe = rememberMeEnabled && (!IS_LITE || liteTokenLoginSupported !== false);
+ const canRememberSession = rememberMeEnabled && (!IS_LITE || liteTokenLoginSupported !== false);
+ // Sign-ins are remembered without asking unless the admin offers the box
+ // (LOGIN_SHOW_REMEMBER_ME).
+ const showRememberMe = canRememberSession && loginShowRememberMe;
+ const persistSession = canRememberSession && (!loginShowRememberMe || rememberMe);
// Lite on Stalwart: which provider signs the typed account in
// (lib/auth/lite-oauth.ts). A domain Stalwart delegates to an external
@@ -696,7 +700,7 @@ function LoginPageContent() {
tokenEndpoint: discovery.metadata.token_endpoint,
clientId,
redirectUri,
- persistent: rememberMeEnabled && rememberMe,
+ persistent: persistSession,
revocationEndpoint: discovery.metadata.revocation_endpoint,
});
@@ -813,7 +817,7 @@ function LoginPageContent() {
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
if (signInWithToken) {
- if (await loginWithToken(effectiveServerUrl, accessToken, rememberMeEnabled && rememberMe)) {
+ if (await loginWithToken(effectiveServerUrl, accessToken, persistSession)) {
router.push('/');
}
return;
@@ -847,13 +851,13 @@ function LoginPageContent() {
// Only persist credentials when the server actually supports it
// (a SESSION_SECRET is configured). Prevents a broken cookie write
// when the remember-me feature is disabled server-side.
- rememberMeEnabled && rememberMe
+ persistSession
);
if (success) {
// Suggested again on this browser only when the user did not decline
// being remembered: on a shared computer the next person sees the list.
- if (!rememberMeEnabled || rememberMe) saveUsername(formData.username);
+ if (!rememberMeEnabled || persistSession) saveUsername(formData.username);
if (isMobileHandoff) {
// The isAuthenticated effect handles the redirect; nothing else to
// do here. Don't push to / - that would race the deep link.
diff --git a/app/(main)/admin/_tabs/settings.tsx b/app/(main)/admin/_tabs/settings.tsx
index 2fa1e71a7..9870d4ffa 100644
--- a/app/(main)/admin/_tabs/settings.tsx
+++ b/app/(main)/admin/_tabs/settings.tsx
@@ -132,6 +132,7 @@ export function SettingsTab() {
+
diff --git a/app/api/config/route.ts b/app/api/config/route.ts
index 1f01fa83e..1faf393c3 100644
--- a/app/api/config/route.ts
+++ b/app/api/config/route.ts
@@ -85,6 +85,7 @@ export async function GET(request: NextRequest) {
loginShowSubtitle: configManager.get('loginShowSubtitle', true),
loginShowTotp: configManager.get('loginShowTotp', true),
loginShowTokenLogin: configManager.get('loginShowTokenLogin', false),
+ loginShowRememberMe: configManager.get('loginShowRememberMe', false),
loginShowVersion: configManager.get('loginShowVersion', true),
demoMode: configManager.get('demoMode', false),
allowCustomJmapEndpoint: configManager.get('allowCustomJmapEndpoint', false),
diff --git a/hooks/use-config.ts b/hooks/use-config.ts
index 55811caa2..5813858c4 100644
--- a/hooks/use-config.ts
+++ b/hooks/use-config.ts
@@ -36,6 +36,7 @@ export interface ConfigData {
loginShowSubtitle: boolean;
loginShowTotp: boolean;
loginShowTokenLogin: boolean;
+ loginShowRememberMe: boolean;
loginShowVersion: boolean;
demoMode: boolean;
autoSsoEnabled: boolean;
@@ -176,6 +177,7 @@ export function useConfig(): AppConfig {
loginShowSubtitle: configCache?.loginShowSubtitle ?? true,
loginShowTotp: configCache?.loginShowTotp ?? true,
loginShowTokenLogin: configCache?.loginShowTokenLogin ?? false,
+ loginShowRememberMe: configCache?.loginShowRememberMe ?? false,
loginShowVersion: configCache?.loginShowVersion ?? true,
demoMode: configCache?.demoMode || false,
autoSsoEnabled: configCache?.autoSsoEnabled || false,
@@ -220,6 +222,7 @@ export function useConfig(): AppConfig {
loginShowSubtitle: configCache.loginShowSubtitle,
loginShowTotp: configCache.loginShowTotp,
loginShowTokenLogin: configCache.loginShowTokenLogin,
+ loginShowRememberMe: configCache.loginShowRememberMe,
loginShowVersion: configCache.loginShowVersion,
demoMode: configCache.demoMode,
autoSsoEnabled: configCache.autoSsoEnabled,
@@ -265,6 +268,7 @@ export function useConfig(): AppConfig {
loginShowSubtitle: data.loginShowSubtitle,
loginShowTotp: data.loginShowTotp,
loginShowTokenLogin: data.loginShowTokenLogin,
+ loginShowRememberMe: data.loginShowRememberMe,
loginShowVersion: data.loginShowVersion,
demoMode: data.demoMode,
autoSsoEnabled: data.autoSsoEnabled,
diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts
index 25069c263..862b8fd9a 100644
--- a/lib/__tests__/config-route.test.ts
+++ b/lib/__tests__/config-route.test.ts
@@ -34,7 +34,7 @@ const MANAGED_ENV = [
'SESSION_SECRET_FILE', 'SETTINGS_SYNC_ENABLED', 'STALWART_FEATURES', 'DEV_MOCK_JMAP',
'FAVICON_URL', 'APP_LOGO_LIGHT_URL', 'APP_LOGO_DARK_URL', 'LOGIN_COMPANY_NAME',
'LOGIN_IMPRINT_URL', 'LOGIN_PRIVACY_POLICY_URL', 'LOGIN_WEBSITE_URL', 'DOMAIN_BRANDING',
- 'JMAP_SERVERS', 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', 'SOURCE_CODE_URL',
+ 'JMAP_SERVERS', 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', 'SOURCE_CODE_URL', 'LOGIN_SHOW_REMEMBER_ME',
] as const;
describe('config API route', () => {
@@ -205,6 +205,14 @@ describe('config API route', () => {
expect(config.rememberMeEnabled).toBe(true);
});
+ it('hides the remember-me box unless LOGIN_SHOW_REMEMBER_ME turns it on', async () => {
+ vi.stubEnv('SESSION_SECRET', 'test-secret');
+ expect((await getConfig()).loginShowRememberMe).toBe(false);
+
+ vi.stubEnv('LOGIN_SHOW_REMEMBER_ME', 'true');
+ expect((await getConfig()).loginShowRememberMe).toBe(true);
+ });
+
it('should enable settingsSync only when both SESSION_SECRET and SETTINGS_SYNC_ENABLED are set', async () => {
vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true');
const config1 = await getConfig();
diff --git a/lib/__tests__/fixtures/config.ts b/lib/__tests__/fixtures/config.ts
index 5983ab035..b2824f5d1 100644
--- a/lib/__tests__/fixtures/config.ts
+++ b/lib/__tests__/fixtures/config.ts
@@ -30,6 +30,7 @@ export function createConfig(overrides: Partial = {}): ConfigData {
loginShowSubtitle: true,
loginShowTotp: true,
loginShowTokenLogin: false,
+ loginShowRememberMe: false,
loginShowVersion: true,
demoMode: false,
autoSsoEnabled: false,
diff --git a/lib/__tests__/lite-config.test.ts b/lib/__tests__/lite-config.test.ts
index 508be6a11..bad915048 100644
--- a/lib/__tests__/lite-config.test.ts
+++ b/lib/__tests__/lite-config.test.ts
@@ -8,6 +8,7 @@ describe('applyLiteConfig (config.json for the static build)', () => {
expect(config.appName).toBe('Webmail');
expect(config.jmapServerUrl).toBe('');
expect(config.rememberMeEnabled).toBe(true);
+ expect(config.loginShowRememberMe).toBe(false);
expect(config.loginShowTotp).toBe(true);
expect(config.faviconUrl).toBe('/branding/Bulwark_Favicon.svg');
expect(config.loginLogoLightUrl).toBe('/branding/Bulwark_Logo_Color.svg');
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 807adbf5d..9ff06655d 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -242,6 +242,9 @@ export const CONFIG_ENV_MAP: Record