mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: make the login email placeholder configurable #1423
This commit is contained in:
+6
-1
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -24,6 +24,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
|
||||
loginImprintUrl: '',
|
||||
loginPrivacyPolicyUrl: '',
|
||||
loginWebsiteUrl: '',
|
||||
loginUsernamePlaceholder: '',
|
||||
loginLogoMaxHeight: '',
|
||||
loginLogoMaxWidth: '',
|
||||
loginShowHeading: true,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -48,6 +48,7 @@ const configValidators = {
|
||||
loginImprintUrl: isString,
|
||||
loginPrivacyPolicyUrl: isString,
|
||||
loginWebsiteUrl: isString,
|
||||
loginUsernamePlaceholder: isString,
|
||||
loginLogoMaxHeight: isString,
|
||||
loginLogoMaxWidth: isString,
|
||||
loginShowHeading: isBoolean,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user