From 2249741c61fddd4d55aa873808a09086442f27fa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:31:56 +0100 Subject: [PATCH] feat: add login page customization options and corresponding translations --- .env.example | 16 ++++ app/[locale]/login/page.tsx | 51 +++++++++- app/api/config/route.ts | 4 + hooks/use-config.ts | 16 ++++ lib/__tests__/config-route.test.ts | 145 +++++++++++++++++++++++++++++ locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/fr/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/nl/common.json | 3 + locales/pt/common.json | 3 + 13 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 lib/__tests__/config-route.test.ts diff --git a/.env.example b/.env.example index 8cdabdc4..69564160 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,19 @@ LOG_LEVEL=info # Directory for storing encrypted settings files (default: ./data/settings) # For Docker, mount a persistent volume at this path. # SETTINGS_DATA_DIR=./data/settings + +# ============================================================================= +# Login Page Customization +# ============================================================================= + +# Company or organization name displayed above the version on the login page +# LOGIN_COMPANY_NAME=My Company + +# URL for the imprint/legal notice link on the login page +# LOGIN_IMPRINT_URL=https://example.com/imprint + +# URL for the privacy policy link on the login page +# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy + +# URL for the company website link on the login page +# LOGIN_WEBSITE_URL=https://example.com diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index c28900f7..7ffe5cf6 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -29,7 +29,7 @@ export default function LoginPage() { const params = useParams(); const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(); - const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, isLoading: configLoading, error: configError } = useConfig(); + const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig(); const [formData, setFormData] = useState({ username: "", @@ -699,10 +699,51 @@ export default function LoginPage() { - {/* Version number - below card */} -

- v{APP_VERSION} -

+ {/* Company name & links - below card */} +
+ {loginCompanyName && ( +

+ {loginCompanyName} +

+ )} + {(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && ( +
+ {loginWebsiteUrl && ( + + {t("website")} + + )} + {loginImprintUrl && ( + + {t("imprint")} + + )} + {loginPrivacyPolicyUrl && ( + + {t("privacy_policy")} + + )} +
+ )} +

+ v{APP_VERSION} +

+
); diff --git a/app/api/config/route.ts b/app/api/config/route.ts index abbbc769..239c4f2e 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -25,5 +25,9 @@ export async function GET() { settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET, stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false', devMode: process.env.DEV_MOCK_JMAP === 'true', + loginCompanyName: process.env.LOGIN_COMPANY_NAME || '', + loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '', + loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '', + loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '', }); } diff --git a/hooks/use-config.ts b/hooks/use-config.ts index 906b903c..bddb7935 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -12,6 +12,10 @@ interface ConfigData { settingsSyncEnabled: boolean; stalwartFeaturesEnabled: boolean; devMode: boolean; + loginCompanyName: string; + loginImprintUrl: string; + loginPrivacyPolicyUrl: string; + loginWebsiteUrl: string; } interface AppConfig extends ConfigData { @@ -71,6 +75,10 @@ export function useConfig(): AppConfig { settingsSyncEnabled: configCache?.settingsSyncEnabled || false, stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true, devMode: configCache?.devMode || false, + loginCompanyName: configCache?.loginCompanyName || '', + loginImprintUrl: configCache?.loginImprintUrl || '', + loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '', + loginWebsiteUrl: configCache?.loginWebsiteUrl || '', isLoading: !configCache, error: null, }); @@ -88,6 +96,10 @@ export function useConfig(): AppConfig { settingsSyncEnabled: configCache.settingsSyncEnabled, stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled, devMode: configCache.devMode, + loginCompanyName: configCache.loginCompanyName, + loginImprintUrl: configCache.loginImprintUrl, + loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl, + loginWebsiteUrl: configCache.loginWebsiteUrl, isLoading: false, error: null, }); @@ -106,6 +118,10 @@ export function useConfig(): AppConfig { settingsSyncEnabled: data.settingsSyncEnabled, stalwartFeaturesEnabled: data.stalwartFeaturesEnabled, devMode: data.devMode, + loginCompanyName: data.loginCompanyName, + loginImprintUrl: data.loginImprintUrl, + loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl, + loginWebsiteUrl: data.loginWebsiteUrl, isLoading: false, error: null, }); diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts new file mode 100644 index 00000000..a63747a2 --- /dev/null +++ b/lib/__tests__/config-route.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock NextResponse before importing the route +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown) => ({ json: async () => data }), + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { debug: vi.fn() }, +})); + +describe('config API route', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear all relevant env vars before each test + delete process.env.APP_NAME; + delete process.env.NEXT_PUBLIC_APP_NAME; + delete process.env.JMAP_SERVER_URL; + delete process.env.NEXT_PUBLIC_JMAP_SERVER_URL; + delete process.env.OAUTH_ENABLED; + delete process.env.OAUTH_CLIENT_ID; + delete process.env.OAUTH_ISSUER_URL; + delete process.env.SESSION_SECRET; + delete process.env.SETTINGS_SYNC_ENABLED; + delete process.env.STALWART_FEATURES; + delete process.env.DEV_MOCK_JMAP; + delete process.env.LOGIN_COMPANY_NAME; + delete process.env.LOGIN_IMPRINT_URL; + delete process.env.LOGIN_PRIVACY_POLICY_URL; + delete process.env.LOGIN_WEBSITE_URL; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + async function getConfig() { + // Re-import to pick up env changes + const { GET } = await import('@/app/api/config/route'); + const response = await GET(); + return response.json(); + } + + it('should return defaults when no env vars are set', async () => { + const config = await getConfig(); + + expect(config.appName).toBe('Webmail'); + expect(config.jmapServerUrl).toBe(''); + expect(config.oauthEnabled).toBe(false); + expect(config.oauthClientId).toBe(''); + expect(config.oauthIssuerUrl).toBe(''); + expect(config.rememberMeEnabled).toBe(false); + expect(config.settingsSyncEnabled).toBe(false); + expect(config.stalwartFeaturesEnabled).toBe(true); + expect(config.devMode).toBe(false); + expect(config.loginCompanyName).toBe(''); + expect(config.loginImprintUrl).toBe(''); + expect(config.loginPrivacyPolicyUrl).toBe(''); + expect(config.loginWebsiteUrl).toBe(''); + }); + + it('should use runtime env vars over defaults', async () => { + process.env.APP_NAME = 'My Mail'; + process.env.JMAP_SERVER_URL = 'https://mail.example.com'; + + const config = await getConfig(); + + expect(config.appName).toBe('My Mail'); + expect(config.jmapServerUrl).toBe('https://mail.example.com'); + }); + + it('should fall back to NEXT_PUBLIC_ vars when runtime vars are unset', async () => { + process.env.NEXT_PUBLIC_APP_NAME = 'Legacy Mail'; + process.env.NEXT_PUBLIC_JMAP_SERVER_URL = 'https://legacy.example.com'; + + const config = await getConfig(); + + expect(config.appName).toBe('Legacy Mail'); + expect(config.jmapServerUrl).toBe('https://legacy.example.com'); + }); + + it('should prefer runtime vars over NEXT_PUBLIC_ vars', async () => { + process.env.APP_NAME = 'Runtime'; + process.env.NEXT_PUBLIC_APP_NAME = 'BuildTime'; + + const config = await getConfig(); + + expect(config.appName).toBe('Runtime'); + }); + + it('should return login page customization values', async () => { + process.env.LOGIN_COMPANY_NAME = 'Acme Corp'; + process.env.LOGIN_IMPRINT_URL = 'https://acme.com/imprint'; + process.env.LOGIN_PRIVACY_POLICY_URL = 'https://acme.com/privacy'; + process.env.LOGIN_WEBSITE_URL = 'https://acme.com'; + + const config = await getConfig(); + + expect(config.loginCompanyName).toBe('Acme Corp'); + expect(config.loginImprintUrl).toBe('https://acme.com/imprint'); + expect(config.loginPrivacyPolicyUrl).toBe('https://acme.com/privacy'); + expect(config.loginWebsiteUrl).toBe('https://acme.com'); + }); + + it('should handle partial login customization', async () => { + process.env.LOGIN_COMPANY_NAME = 'Partial Corp'; + // Leave URLs unset + + const config = await getConfig(); + + expect(config.loginCompanyName).toBe('Partial Corp'); + expect(config.loginImprintUrl).toBe(''); + expect(config.loginPrivacyPolicyUrl).toBe(''); + expect(config.loginWebsiteUrl).toBe(''); + }); + + it('should enable rememberMe when SESSION_SECRET is set', async () => { + process.env.SESSION_SECRET = 'test-secret'; + + const config = await getConfig(); + + expect(config.rememberMeEnabled).toBe(true); + }); + + it('should enable settingsSync only when both SESSION_SECRET and SETTINGS_SYNC_ENABLED are set', async () => { + process.env.SETTINGS_SYNC_ENABLED = 'true'; + const config1 = await getConfig(); + expect(config1.settingsSyncEnabled).toBe(false); + + process.env.SESSION_SECRET = 'test-secret'; + const config2 = await getConfig(); + expect(config2.settingsSyncEnabled).toBe(true); + }); + + it('should disable stalwart features when explicitly set to false', async () => { + process.env.STALWART_FEATURES = 'false'; + + const config = await getConfig(); + + expect(config.stalwartFeaturesEnabled).toBe(false); + }); +}); diff --git a/locales/de/common.json b/locales/de/common.json index 2d0dd30c..b101ef3c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -33,6 +33,9 @@ "dismiss": "Schließen", "or": "oder", "sign_in_sso": "Mit SSO anmelden", + "website": "Webseite", + "imprint": "Impressum", + "privacy_policy": "Datenschutz", "oauth_completing": "Anmeldung wird abgeschlossen...", "oauth_error": { "title": "Authentifizierung fehlgeschlagen", diff --git a/locales/en/common.json b/locales/en/common.json index 1eecd2d3..255415cc 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -34,6 +34,9 @@ "dismiss": "Dismiss", "or": "or", "sign_in_sso": "Sign in with SSO", + "website": "Website", + "imprint": "Imprint", + "privacy_policy": "Privacy Policy", "oauth_completing": "Completing sign in...", "oauth_error": { "title": "Authentication Failed", diff --git a/locales/es/common.json b/locales/es/common.json index 181d5b7f..1fe411b4 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -33,6 +33,9 @@ "dismiss": "Cerrar", "or": "o", "sign_in_sso": "Iniciar sesión con SSO", + "website": "Sitio web", + "imprint": "Aviso legal", + "privacy_policy": "Política de privacidad", "oauth_completing": "Completando inicio de sesión...", "oauth_error": { "title": "Error de autenticación", diff --git a/locales/fr/common.json b/locales/fr/common.json index d656f446..dc7ece2f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -33,6 +33,9 @@ "hide_password": "Masquer le mot de passe", "or": "ou", "sign_in_sso": "Se connecter avec SSO", + "website": "Site web", + "imprint": "Mentions légales", + "privacy_policy": "Politique de confidentialité", "oauth_completing": "Connexion en cours...", "oauth_error": { "title": "Échec de l'authentification", diff --git a/locales/it/common.json b/locales/it/common.json index 3a77568e..80103dea 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -33,6 +33,9 @@ "dismiss": "Chiudi", "or": "o", "sign_in_sso": "Accedi con SSO", + "website": "Sito web", + "imprint": "Note legali", + "privacy_policy": "Informativa sulla privacy", "oauth_completing": "Completamento dell'accesso...", "oauth_error": { "title": "Autenticazione non riuscita", diff --git a/locales/ja/common.json b/locales/ja/common.json index 622273b4..da3e8171 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -33,6 +33,9 @@ "hide_password": "パスワードを隠す", "or": "または", "sign_in_sso": "SSOでサインイン", + "website": "ウェブサイト", + "imprint": "サイト運営者情報", + "privacy_policy": "プライバシーポリシー", "oauth_completing": "サインイン処理中...", "oauth_error": { "title": "認証に失敗しました", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3d8fc97a..f2a7ee57 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -33,6 +33,9 @@ "dismiss": "Sluiten", "or": "of", "sign_in_sso": "Inloggen met SSO", + "website": "Website", + "imprint": "Colofon", + "privacy_policy": "Privacybeleid", "oauth_completing": "Aanmelding voltooien...", "oauth_error": { "title": "Authenticatie mislukt", diff --git a/locales/pt/common.json b/locales/pt/common.json index 495a994a..cce73079 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -33,6 +33,9 @@ "dismiss": "Fechar", "or": "ou", "sign_in_sso": "Entrar com SSO", + "website": "Site", + "imprint": "Informações legais", + "privacy_policy": "Política de privacidade", "oauth_completing": "Concluindo login...", "oauth_error": { "title": "Falha na autenticação",