feat: remember every sign-in unless the admin shows the remember me box

This commit is contained in:
Linus Rath
2026-10-13 13:13:56 +02:00
parent 5f2d26f2e1
commit 3da46b5a0d
12 changed files with 42 additions and 10 deletions
+4
View File
@@ -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
+10 -6
View File
@@ -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.
+1
View File
@@ -132,6 +132,7 @@ export function SettingsTab() {
<ToggleSetting label="Show Version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show 2FA Code Option" description="Show the &quot;I have a 2FA code&quot; toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Access Token Sign-In" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Remember Me" description="Let people choose whether to stay signed in. Off (the default) remembers every sign-in. Either way this needs SESSION_SECRET to be set." configKey="loginShowRememberMe" value={currentValue('loginShowRememberMe') as boolean} source={config.loginShowRememberMe?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Heading" description="Show the application name below the login logo" configKey="loginShowHeading" value={currentValue('loginShowHeading') as boolean} source={config.loginShowHeading?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Subtitle" description="Show the subtitle below the login heading" configKey="loginShowSubtitle" value={currentValue('loginShowSubtitle') as boolean} source={config.loginShowSubtitle?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
+1
View File
@@ -85,6 +85,7 @@ export async function GET(request: NextRequest) {
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
loginShowTokenLogin: configManager.get<boolean>('loginShowTokenLogin', false),
loginShowRememberMe: configManager.get<boolean>('loginShowRememberMe', false),
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
+4
View File
@@ -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,
+9 -1
View File
@@ -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();
+1
View File
@@ -30,6 +30,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
loginShowSubtitle: true,
loginShowTotp: true,
loginShowTokenLogin: false,
loginShowRememberMe: false,
loginShowVersion: true,
demoMode: false,
autoSsoEnabled: false,
+1
View File
@@ -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');
+3
View File
@@ -242,6 +242,9 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
// password. For JMAP servers that hand out API tokens, such as Fastmail,
// whose API accepts no passwords at all.
loginShowTokenLogin: { envVar: 'LOGIN_SHOW_TOKEN_LOGIN', type: 'boolean', defaultValue: false },
// Show the "Remember me" box on the login form. Off (the default) remembers
// every sign-in, as long as a SESSION_SECRET makes that possible.
loginShowRememberMe: { envVar: 'LOGIN_SHOW_REMEMBER_ME', type: 'boolean', defaultValue: false },
// Show the build version in the login footer. Off keeps the exact version
// from being disclosed to unauthenticated visitors.
loginShowVersion: { envVar: 'LOGIN_SHOW_VERSION', type: 'boolean', defaultValue: true },
+1
View File
@@ -54,6 +54,7 @@ const configValidators = {
loginShowSubtitle: isBoolean,
loginShowTotp: isBoolean,
loginShowTokenLogin: isBoolean,
loginShowRememberMe: isBoolean,
loginShowVersion: isBoolean,
demoMode: isBoolean,
autoSsoEnabled: isBoolean,
+5 -2
View File
@@ -38,6 +38,7 @@ export const LITE_CONFIG_KEYS = [
'loginShowSubtitle',
'loginShowTotp',
'loginShowTokenLogin',
'loginShowRememberMe',
'loginShowVersion',
'embeddedMode',
'parentOrigin',
@@ -79,8 +80,9 @@ function trimUrl(value: string): string {
* - `jmapServerUrl` is trimmed of trailing slashes.
* - `allowCustomJmapEndpoint` defaults to *on* when no server is configured,
* so an unedited download still lets people type their server.
* - `rememberMeEnabled` defaults to on; the login page still hides the box for
* servers without Stalwart's token login (see lib/auth/lite-tokens.ts).
* - `rememberMeEnabled` defaults to on, and sign-ins are remembered without
* asking unless `loginShowRememberMe` shows the box. Servers without
* Stalwart's token login are never remembered (see lib/auth/lite-tokens.ts).
* - `defaults.jmapServerUrl` (the Stalwart build passes the page origin) fills
* an empty server URL, and the server field then stays hidden unless
* config.json explicitly allows it.
@@ -124,6 +126,7 @@ export function applyLiteConfig(raw: unknown, defaults?: { jmapServerUrl?: strin
loginShowSubtitle: bool(input.loginShowSubtitle, true),
loginShowTotp: bool(input.loginShowTotp, true),
loginShowTokenLogin: bool(input.loginShowTokenLogin, false),
loginShowRememberMe: bool(input.loginShowRememberMe, false),
loginShowVersion: bool(input.loginShowVersion, true),
demoMode: bool(input.demoMode, false),
allowCustomJmapEndpoint: bool(input.allowCustomJmapEndpoint, jmapServerUrl === '' && jmapServers.length === 0),
+2 -1
View File
@@ -653,7 +653,8 @@ ${demoMode ? "Demo mode is ON: the login page offers a built-in demo account and
- \`allowCustomJmapEndpoint\` (optional): \`true\` always shows a server field
on the login page, \`false\` never does. Left out, the field shows only
while \`jmapServerUrl\` is empty.
- \`rememberMeEnabled\`: \`false\` hides "remember me" (sessions then end with the tab).
- \`rememberMeEnabled\`: \`false\` never remembers a sign-in (sessions then end with the tab).
Sign-ins are otherwise remembered; \`loginShowRememberMe: true\` lets people choose.
Optional keys: \`demoMode\`, \`jmapServers\`, \`jmapServerAutoPickByDomain\`, the login logo/company/link keys and \`loginShow*\` toggles (same names as the Docker env vars, camelCased).
3. Allow the browser to talk to the mail server (CORS). In Stalwart: