feat: add login page customization options and corresponding translations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 || '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"hide_password": "パスワードを隠す",
|
||||
"or": "または",
|
||||
"sign_in_sso": "SSOでサインイン",
|
||||
"website": "ウェブサイト",
|
||||
"imprint": "サイト運営者情報",
|
||||
"privacy_policy": "プライバシーポリシー",
|
||||
"oauth_completing": "サインイン処理中...",
|
||||
"oauth_error": {
|
||||
"title": "認証に失敗しました",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user