diff --git a/.env.example b/.env.example index b358013e1..c6eee95c6 100644 --- a/.env.example +++ b/.env.example @@ -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. # diff --git a/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx index 2695161a8..ec200e05b 100644 --- a/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx +++ b/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx @@ -18,6 +18,8 @@ vi.mock('@/i18n/navigation', () => ({ Link: ({ children }: { children: React.ReactNode }) => children, })); +const configOverrides = vi.hoisted(() => ({ value: {} as Record })); + 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(); + expect(await screen.findByLabelText('username_label', {}, { timeout: 10000 })) + .toHaveAttribute('placeholder', 'username_placeholder'); + unmount(); + + configOverrides.value = { loginUsernamePlaceholder: 'max.mustermann@acme.example' }; + try { + render(); + expect(await screen.findByLabelText('username_label', {}, { timeout: 10000 })) + .toHaveAttribute('placeholder', 'max.mustermann@acme.example'); + } finally { + configOverrides.value = {}; + } + }, 30_000); }); describe('login page notices', () => { diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index f0a50a57a..e633a0ce4 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -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" diff --git a/app/(main)/admin/_pages/branding.tsx b/app/(main)/admin/_pages/branding.tsx index ba368f872..634edb936 100644 --- a/app/(main)/admin/_pages/branding.tsx +++ b/app/(main)/admin/_pages/branding.tsx @@ -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() { - {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'))} ('loginImprintUrl', ''), loginPrivacyPolicyUrl: branded('loginPrivacyPolicyUrl', ''), loginWebsiteUrl: branded('loginWebsiteUrl', ''), + loginUsernamePlaceholder: branded('loginUsernamePlaceholder', ''), loginLogoMaxHeight: configManager.get('loginLogoMaxHeight', ''), loginLogoMaxWidth: configManager.get('loginLogoMaxWidth', ''), loginShowHeading: configManager.get('loginShowHeading', true), diff --git a/hooks/use-config.ts b/hooks/use-config.ts index f41a4a50d..c6fe4908c 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -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, diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts index 0fa859971..5b88569bf 100644 --- a/lib/__tests__/config-route.test.ts +++ b/lib/__tests__/config-route.test.ts @@ -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([ diff --git a/lib/__tests__/fixtures/config.ts b/lib/__tests__/fixtures/config.ts index 02f2ab39f..c764c4e05 100644 --- a/lib/__tests__/fixtures/config.ts +++ b/lib/__tests__/fixtures/config.ts @@ -24,6 +24,7 @@ export function createConfig(overrides: Partial = {}): ConfigData { loginImprintUrl: '', loginPrivacyPolicyUrl: '', loginWebsiteUrl: '', + loginUsernamePlaceholder: '', loginLogoMaxHeight: '', loginLogoMaxWidth: '', loginShowHeading: true, diff --git a/lib/admin/domain-branding.ts b/lib/admin/domain-branding.ts index 2f84c4fd0..9e878d31f 100644 --- a/lib/admin/domain-branding.ts +++ b/lib/admin/domain-branding.ts @@ -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; diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 6d4110539..09246ba32 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -263,6 +263,9 @@ export const CONFIG_ENV_MAP: Record