feat: make the login email placeholder configurable #1423

This commit is contained in:
Linus Rath
2026-10-29 20:24:26 +01:00
parent be28fb8ed7
commit 0b4e501142
12 changed files with 60 additions and 4 deletions
+6 -1
View File
@@ -314,6 +314,11 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the company website link on the login page.
LOGIN_WEBSITE_URL=https://bulwarkmail.org
# Placeholder of the email field on the login page, for example your own
# address format. It replaces the translated example in every language.
# Unset uses the translation.
# LOGIN_USERNAME_PLACEHOLDER=user@company.example
# Where users get the source code of this build, linked from Settings >
# About. Bulwark is AGPL-3.0-only: if you run a modified build, set this to
# the source of exactly what you deploy (your fork, at the deployed commit).
@@ -375,7 +380,7 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# Overridable keys: appName, appShortName, appDescription, faviconUrl,
# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl,
# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName,
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl,
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginUsernamePlaceholder,
# accessibilityStatementUrl, accessibilityFeedbackUrl, helpUrl, easyReadUrl,
# signLanguageUrl.
#
@@ -18,6 +18,8 @@ vi.mock('@/i18n/navigation', () => ({
Link: ({ children }: { children: React.ReactNode }) => children,
}));
const configOverrides = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
vi.mock('@/hooks/use-config', () => ({
useConfig: () => ({
appName: 'Webmail',
@@ -30,6 +32,7 @@ vi.mock('@/hooks/use-config', () => ({
loginShowVersion: true,
loginShowTotp: true,
loginShowRememberMe: true,
...configOverrides.value,
}),
}));
@@ -63,6 +66,22 @@ describe('login page translations', () => {
act(() => { fireEvent.keyDown(document, { key: 'Escape' }); });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
}, 30_000);
it('uses the translated email placeholder unless one is configured (#1423)', async () => {
const { unmount } = render(<LoginPage />);
expect(await screen.findByLabelText('username_label', {}, { timeout: 10000 }))
.toHaveAttribute('placeholder', 'username_placeholder');
unmount();
configOverrides.value = { loginUsernamePlaceholder: 'max.mustermann@acme.example' };
try {
render(<LoginPage />);
expect(await screen.findByLabelText('username_label', {}, { timeout: 10000 }))
.toHaveAttribute('placeholder', 'max.mustermann@acme.example');
} finally {
configOverrides.value = {};
}
}, 30_000);
});
describe('login page notices', () => {
+2 -2
View File
@@ -200,7 +200,7 @@ function LoginPageContent() {
usePrefetchMailView(!isMobileHandoff);
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, loginShowRememberMe, 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, loginUsernamePlaceholder, 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);
const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked);
@@ -1520,7 +1520,7 @@ function LoginPageContent() {
onFocus={handleUsernameFocus}
onKeyDown={handleKeyDown}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("username_placeholder")}
placeholder={loginUsernamePlaceholder || t("username_placeholder")}
required={!signInWithToken}
// Password managers fill and save the pair by this (WCAG 3.3.8).
autoComplete="username"
+2 -1
View File
@@ -43,6 +43,7 @@ const TEXT_FIELDS = [
{ key: 'loginImprintUrl', label: 'Imprint URL' },
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy policy URL' },
{ key: 'loginWebsiteUrl', label: 'Company website URL' },
{ key: 'loginUsernamePlaceholder', label: 'Login email placeholder', placeholder: 'user@company.example' },
] as const;
// Accessibility and help links (OP-01): the app shows them on the login
@@ -565,7 +566,7 @@ export function BrandingPage() {
</SettingsSection>
<SettingsSection id="company" title="Company information">
{TEXT_FIELDS.map(field => textRow(field, field.key.includes('Url') ? 'https://...' : 'Enter value'))}
{TEXT_FIELDS.map(field => textRow(field, 'placeholder' in field ? field.placeholder : field.key.includes('Url') ? 'https://...' : 'Enter value'))}
</SettingsSection>
<SettingsSection
+1
View File
@@ -80,6 +80,7 @@ export async function GET(request: NextRequest) {
loginImprintUrl: branded<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
loginUsernamePlaceholder: branded<string>('loginUsernamePlaceholder', ''),
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
+4
View File
@@ -31,6 +31,7 @@ export interface ConfigData {
loginImprintUrl: string;
loginPrivacyPolicyUrl: string;
loginWebsiteUrl: string;
loginUsernamePlaceholder: string;
loginLogoMaxHeight: string;
loginLogoMaxWidth: string;
loginShowHeading: boolean;
@@ -182,6 +183,7 @@ export function useConfig(): AppConfig {
loginImprintUrl: configCache?.loginImprintUrl || '',
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
loginUsernamePlaceholder: configCache?.loginUsernamePlaceholder || '',
loginLogoMaxHeight: configCache?.loginLogoMaxHeight || '',
loginLogoMaxWidth: configCache?.loginLogoMaxWidth || '',
loginShowHeading: configCache?.loginShowHeading ?? true,
@@ -233,6 +235,7 @@ export function useConfig(): AppConfig {
loginImprintUrl: configCache.loginImprintUrl,
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
loginWebsiteUrl: configCache.loginWebsiteUrl,
loginUsernamePlaceholder: configCache.loginUsernamePlaceholder,
loginLogoMaxHeight: configCache.loginLogoMaxHeight,
loginLogoMaxWidth: configCache.loginLogoMaxWidth,
loginShowHeading: configCache.loginShowHeading,
@@ -285,6 +288,7 @@ export function useConfig(): AppConfig {
loginImprintUrl: data.loginImprintUrl,
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
loginWebsiteUrl: data.loginWebsiteUrl,
loginUsernamePlaceholder: data.loginUsernamePlaceholder,
loginLogoMaxHeight: data.loginLogoMaxHeight,
loginLogoMaxWidth: data.loginLogoMaxWidth,
loginShowHeading: data.loginShowHeading,
+18
View File
@@ -36,6 +36,7 @@ const MANAGED_ENV = [
'LOGIN_IMPRINT_URL', 'LOGIN_PRIVACY_POLICY_URL', 'LOGIN_WEBSITE_URL', 'DOMAIN_BRANDING',
'JMAP_SERVERS', 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', 'SOURCE_CODE_URL', 'LOGIN_SHOW_REMEMBER_ME',
'ACCESSIBILITY_STATEMENT_URL', 'ACCESSIBILITY_FEEDBACK_URL', 'HELP_URL', 'EASY_READ_URL', 'SIGN_LANGUAGE_URL',
'LOGIN_USERNAME_PLACEHOLDER',
] as const;
describe('config API route', () => {
@@ -163,6 +164,13 @@ describe('config API route', () => {
expect(config.loginWebsiteUrl).toBe('https://acme.com');
});
it('publishes the login email placeholder, empty when unset (#1423)', async () => {
expect((await getConfig()).loginUsernamePlaceholder).toBe('');
vi.stubEnv('LOGIN_USERNAME_PLACEHOLDER', 'max.mustermann@acme.example');
expect((await getConfig()).loginUsernamePlaceholder).toBe('max.mustermann@acme.example');
});
it('publishes the source code URL for the About card (#1161)', async () => {
vi.stubEnv('SOURCE_CODE_URL', 'https://git.example.com/acme/webmail/tree/v1.2.3');
@@ -310,6 +318,16 @@ describe('config API route', () => {
expect(config.helpUrl).toBe('https://default.example/help');
});
it('overrides the login email placeholder per domain (#1423)', async () => {
vi.stubEnv('LOGIN_USERNAME_PLACEHOLDER', 'name@default.example');
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
{ host: 'mail1.example.com', loginUsernamePlaceholder: 'name@one.example' },
]));
expect((await getConfig({ host: 'mail1.example.com' })).loginUsernamePlaceholder).toBe('name@one.example');
expect((await getConfig({ host: 'other.example.com' })).loginUsernamePlaceholder).toBe('name@default.example');
});
it('falls through to the global value when the host has no entry', async () => {
vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co');
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
+1
View File
@@ -24,6 +24,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
loginImprintUrl: '',
loginPrivacyPolicyUrl: '',
loginWebsiteUrl: '',
loginUsernamePlaceholder: '',
loginLogoMaxHeight: '',
loginLogoMaxWidth: '',
loginShowHeading: true,
+2
View File
@@ -27,6 +27,7 @@ export const BRANDING_OVERRIDE_KEYS = [
'loginImprintUrl',
'loginPrivacyPolicyUrl',
'loginWebsiteUrl',
'loginUsernamePlaceholder',
'accessibilityStatementUrl',
'accessibilityFeedbackUrl',
'helpUrl',
@@ -61,6 +62,7 @@ export interface DomainBrandingEntry {
loginImprintUrl?: string;
loginPrivacyPolicyUrl?: string;
loginWebsiteUrl?: string;
loginUsernamePlaceholder?: string;
accessibilityStatementUrl?: string;
accessibilityFeedbackUrl?: string;
helpUrl?: string;
+3
View File
@@ -263,6 +263,9 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' },
loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' },
loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' },
// Placeholder of the email field on the login page, e.g. the company's own
// address format. Empty uses the translation (login.username_placeholder).
loginUsernamePlaceholder: { envVar: 'LOGIN_USERNAME_PLACEHOLDER', type: 'string', defaultValue: '' },
// Where users get the source of the build they are running (AGPL-3.0 §13).
// Operators of a modified build point it at their fork; the About card then
// links there instead of the upstream repository (#1161). Empty = upstream.
+1
View File
@@ -48,6 +48,7 @@ const configValidators = {
loginImprintUrl: isString,
loginPrivacyPolicyUrl: isString,
loginWebsiteUrl: isString,
loginUsernamePlaceholder: isString,
loginLogoMaxHeight: isString,
loginLogoMaxWidth: isString,
loginShowHeading: isBoolean,
+1
View File
@@ -81,6 +81,7 @@ export function applyLiteConfig(raw: unknown, defaults?: { jmapServerUrl?: strin
loginImprintUrl: str(input.loginImprintUrl, ''),
loginPrivacyPolicyUrl: str(input.loginPrivacyPolicyUrl, ''),
loginWebsiteUrl: str(input.loginWebsiteUrl, ''),
loginUsernamePlaceholder: str(input.loginUsernamePlaceholder, ''),
loginLogoMaxHeight: str(input.loginLogoMaxHeight, ''),
loginLogoMaxWidth: str(input.loginLogoMaxWidth, ''),
loginShowHeading: bool(input.loginShowHeading, true),