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