feat: add login page customization options and corresponding translations

This commit is contained in:
Linus Rath
2026-03-12 02:31:56 +01:00
parent ab72fc06ff
commit 2249741c61
13 changed files with 251 additions and 5 deletions
+16
View File
@@ -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
+46 -5
View File
@@ -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() {
</div>
</div>
{/* Version number - below card */}
<p className="text-center text-xs text-muted-foreground/40 mt-6">
v{APP_VERSION}
</p>
{/* Company name & links - below card */}
<div className="mt-6 flex flex-col items-center gap-2">
{loginCompanyName && (
<p className="text-center text-xs text-muted-foreground/60 font-medium">
{loginCompanyName}
</p>
)}
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
<div className="flex items-center gap-3 flex-wrap justify-center">
{loginWebsiteUrl && (
<a
href={loginWebsiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{t("website")}
</a>
)}
{loginImprintUrl && (
<a
href={loginImprintUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{t("imprint")}
</a>
)}
{loginPrivacyPolicyUrl && (
<a
href={loginPrivacyPolicyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{t("privacy_policy")}
</a>
)}
</div>
)}
<p className="text-center text-xs text-muted-foreground/40">
v{APP_VERSION}
</p>
</div>
</div>
</div>
);
+4
View File
@@ -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 || '',
});
}
+16
View File
@@ -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,
});
+145
View File
@@ -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);
});
});
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -33,6 +33,9 @@
"hide_password": "パスワードを隠す",
"or": "または",
"sign_in_sso": "SSOでサインイン",
"website": "ウェブサイト",
"imprint": "サイト運営者情報",
"privacy_policy": "プライバシーポリシー",
"oauth_completing": "サインイン処理中...",
"oauth_error": {
"title": "認証に失敗しました",
+3
View File
@@ -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",
+3
View File
@@ -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",