feat(login): add LOGIN_SHOW_TOTP and LOGIN_SHOW_VERSION config flags

Two opt-out branding/login flags, both default true (no behaviour change
for existing deployments):

- LOGIN_SHOW_TOTP=false hides the manual "I have a 2FA code" toggle on the
  login form. Deployments that delegate auth to an external directory
  (LDAP/OIDC) where 2FA lives in the IdP have no server-side TOTP, so the
  toggle only ever leads to a failed login. Server-required TOTP
  (totp_required, which auto-shows the field) is unaffected.
- LOGIN_SHOW_VERSION=false hides the build version in the login footer, so
  the exact version isn't disclosed to unauthenticated visitors.

Wired through the existing config registry (CONFIG_ENV_MAP) → /api/config →
useConfig, matching the surrounding LOGIN_* options.

Refs #519.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maarten Draijer
2026-06-29 12:04:10 +00:00
co-authored by Claude Opus 4.8
parent 2704d5dc23
commit 65cb6be8a9
4 changed files with 27 additions and 4 deletions
+9 -4
View File
@@ -133,7 +133,7 @@ export default function LoginPage() {
const isMobileHandoff = Boolean(mobileRedirectUri); const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); 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, 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, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -815,7 +815,7 @@ export default function LoginPage() {
)} )}
</div> </div>
)} )}
<VersionBadge /> {loginShowVersion && <VersionBadge />}
</div> </div>
</div> </div>
</div> </div>
@@ -1142,8 +1142,12 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{/* 2FA toggle / field */} {/* 2FA toggle / field. The manual toggle can be hidden via
LOGIN_SHOW_TOTP (loginShowTotp) for deployments whose mail
server has no per-account TOTP (auth delegated to an
external directory); server-required TOTP still shows. */}
{!showTotpField ? ( {!showTotpField ? (
loginShowTotp ? (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -1155,6 +1159,7 @@ export default function LoginPage() {
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
{t("totp_toggle")} {t("totp_toggle")}
</button> </button>
) : null
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label htmlFor="totp" className="block text-sm font-medium text-foreground"> <label htmlFor="totp" className="block text-sm font-medium text-foreground">
@@ -1349,7 +1354,7 @@ export default function LoginPage() {
)} )}
</div> </div>
)} )}
<VersionBadge /> {loginShowVersion && <VersionBadge />}
</div> </div>
</div> </div>
</div> </div>
+2
View File
@@ -74,6 +74,8 @@ export async function GET(request: NextRequest) {
loginImprintUrl: branded<string>('loginImprintUrl', ''), loginImprintUrl: branded<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''), loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''), loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
demoMode: configManager.get<boolean>('demoMode', false), demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false), allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))), jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
+8
View File
@@ -26,6 +26,8 @@ interface ConfigData {
loginImprintUrl: string; loginImprintUrl: string;
loginPrivacyPolicyUrl: string; loginPrivacyPolicyUrl: string;
loginWebsiteUrl: string; loginWebsiteUrl: string;
loginShowTotp: boolean;
loginShowVersion: boolean;
demoMode: boolean; demoMode: boolean;
autoSsoEnabled: boolean; autoSsoEnabled: boolean;
allowCustomJmapEndpoint: boolean; allowCustomJmapEndpoint: boolean;
@@ -105,6 +107,8 @@ export function useConfig(): AppConfig {
loginImprintUrl: configCache?.loginImprintUrl || '', loginImprintUrl: configCache?.loginImprintUrl || '',
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '', loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
loginWebsiteUrl: configCache?.loginWebsiteUrl || '', loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
loginShowTotp: configCache?.loginShowTotp ?? true,
loginShowVersion: configCache?.loginShowVersion ?? true,
demoMode: configCache?.demoMode || false, demoMode: configCache?.demoMode || false,
autoSsoEnabled: configCache?.autoSsoEnabled || false, autoSsoEnabled: configCache?.autoSsoEnabled || false,
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false, allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
@@ -140,6 +144,8 @@ export function useConfig(): AppConfig {
loginImprintUrl: configCache.loginImprintUrl, loginImprintUrl: configCache.loginImprintUrl,
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl, loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
loginWebsiteUrl: configCache.loginWebsiteUrl, loginWebsiteUrl: configCache.loginWebsiteUrl,
loginShowTotp: configCache.loginShowTotp,
loginShowVersion: configCache.loginShowVersion,
demoMode: configCache.demoMode, demoMode: configCache.demoMode,
autoSsoEnabled: configCache.autoSsoEnabled, autoSsoEnabled: configCache.autoSsoEnabled,
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint, allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
@@ -176,6 +182,8 @@ export function useConfig(): AppConfig {
loginImprintUrl: data.loginImprintUrl, loginImprintUrl: data.loginImprintUrl,
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl, loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
loginWebsiteUrl: data.loginWebsiteUrl, loginWebsiteUrl: data.loginWebsiteUrl,
loginShowTotp: data.loginShowTotp,
loginShowVersion: data.loginShowVersion,
demoMode: data.demoMode, demoMode: data.demoMode,
autoSsoEnabled: data.autoSsoEnabled, autoSsoEnabled: data.autoSsoEnabled,
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint, allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
+8
View File
@@ -166,6 +166,14 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' }, loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' },
loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' }, loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' },
loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' }, loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' },
// Hide the manual "I have a 2FA code" toggle on the login form. Deployments
// that delegate auth to an external directory (LDAP/OIDC) where 2FA lives in
// the IdP have no server-side TOTP, so the toggle only leads to a failed
// login. Server-required TOTP (totp_required) still shows regardless.
loginShowTotp: { envVar: 'LOGIN_SHOW_TOTP', type: 'boolean', defaultValue: true },
// 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 },
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false }, oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false }, oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' }, oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },