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) && (
+
+ )}
+
+ 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",