feat(login): configurable logo size + hideable heading/subtitle
Login header customization for white-label deployments, all defaults
preserve current behaviour:
- LOGIN_LOGO_MAX_HEIGHT / LOGIN_LOGO_MAX_WIDTH (any CSS length): the logo
box is otherwise a fixed 64x64 (w-16/h-16), which fits a wide wordmark to
~13px tall. When either is set, the fixed box is dropped and the logo
renders at the configured size.
- LOGIN_SHOW_HEADING / LOGIN_SHOW_SUBTITLE (default true): hide the
{appName} heading and/or the subtitle when the logo already reads as the
brand (e.g. a wordmark) and they'd be redundant.
Applied to the standard login header; wired through the existing config
registry (CONFIG_ENV_MAP) -> /api/config -> useConfig.
Refs #519.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Linus Rath
co-authored by
Claude Opus 4.8
parent
d1da83a5d6
commit
1429a6fe1e
@@ -133,9 +133,16 @@ export default function LoginPage() {
|
|||||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
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, 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, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
|
// Login logo sizing: when a max height/width is configured, drop the fixed
|
||||||
|
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
|
||||||
|
const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth);
|
||||||
|
const loginLogoStyle = hasLogoSize
|
||||||
|
? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -881,19 +888,24 @@ export default function LoginPage() {
|
|||||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||||
{/* Header section with logo */}
|
{/* Header section with logo */}
|
||||||
<div className="px-8 pt-10 pb-6 text-center">
|
<div className="px-8 pt-10 pb-6 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
||||||
<img
|
<img
|
||||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="max-w-16 max-h-16 object-contain"
|
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
||||||
|
style={loginLogoStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
{loginShowHeading && (
|
||||||
{isAddAccountMode ? t("add_account_title") : appName}
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
</h1>
|
{isAddAccountMode ? t("add_account_title") : appName}
|
||||||
<p className="text-sm text-muted-foreground mt-1.5">
|
</h1>
|
||||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
)}
|
||||||
</p>
|
{loginShowSubtitle && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
|
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form section */}
|
{/* Form section */}
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ export async function GET(request: NextRequest) {
|
|||||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||||
|
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
|
||||||
|
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
|
||||||
|
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
|
||||||
|
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
|
||||||
demoMode: configManager.get<boolean>('demoMode', false),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ interface ConfigData {
|
|||||||
loginImprintUrl: string;
|
loginImprintUrl: string;
|
||||||
loginPrivacyPolicyUrl: string;
|
loginPrivacyPolicyUrl: string;
|
||||||
loginWebsiteUrl: string;
|
loginWebsiteUrl: string;
|
||||||
|
loginLogoMaxHeight: string;
|
||||||
|
loginLogoMaxWidth: string;
|
||||||
|
loginShowHeading: boolean;
|
||||||
|
loginShowSubtitle: boolean;
|
||||||
demoMode: boolean;
|
demoMode: boolean;
|
||||||
autoSsoEnabled: boolean;
|
autoSsoEnabled: boolean;
|
||||||
allowCustomJmapEndpoint: boolean;
|
allowCustomJmapEndpoint: boolean;
|
||||||
@@ -105,6 +109,10 @@ export function useConfig(): AppConfig {
|
|||||||
loginImprintUrl: configCache?.loginImprintUrl || '',
|
loginImprintUrl: configCache?.loginImprintUrl || '',
|
||||||
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
||||||
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
||||||
|
loginLogoMaxHeight: configCache?.loginLogoMaxHeight || '',
|
||||||
|
loginLogoMaxWidth: configCache?.loginLogoMaxWidth || '',
|
||||||
|
loginShowHeading: configCache?.loginShowHeading ?? true,
|
||||||
|
loginShowSubtitle: configCache?.loginShowSubtitle ?? true,
|
||||||
demoMode: configCache?.demoMode || false,
|
demoMode: configCache?.demoMode || false,
|
||||||
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||||
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
||||||
@@ -140,6 +148,10 @@ export function useConfig(): AppConfig {
|
|||||||
loginImprintUrl: configCache.loginImprintUrl,
|
loginImprintUrl: configCache.loginImprintUrl,
|
||||||
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
||||||
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
||||||
|
loginLogoMaxHeight: configCache.loginLogoMaxHeight,
|
||||||
|
loginLogoMaxWidth: configCache.loginLogoMaxWidth,
|
||||||
|
loginShowHeading: configCache.loginShowHeading,
|
||||||
|
loginShowSubtitle: configCache.loginShowSubtitle,
|
||||||
demoMode: configCache.demoMode,
|
demoMode: configCache.demoMode,
|
||||||
autoSsoEnabled: configCache.autoSsoEnabled,
|
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||||
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
||||||
@@ -176,6 +188,10 @@ export function useConfig(): AppConfig {
|
|||||||
loginImprintUrl: data.loginImprintUrl,
|
loginImprintUrl: data.loginImprintUrl,
|
||||||
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
||||||
loginWebsiteUrl: data.loginWebsiteUrl,
|
loginWebsiteUrl: data.loginWebsiteUrl,
|
||||||
|
loginLogoMaxHeight: data.loginLogoMaxHeight,
|
||||||
|
loginLogoMaxWidth: data.loginLogoMaxWidth,
|
||||||
|
loginShowHeading: data.loginShowHeading,
|
||||||
|
loginShowSubtitle: data.loginShowSubtitle,
|
||||||
demoMode: data.demoMode,
|
demoMode: data.demoMode,
|
||||||
autoSsoEnabled: data.autoSsoEnabled,
|
autoSsoEnabled: data.autoSsoEnabled,
|
||||||
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
||||||
|
|||||||
@@ -166,6 +166,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
|||||||
loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' },
|
loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' },
|
||||||
loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' },
|
loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' },
|
||||||
loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' },
|
loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' },
|
||||||
|
// Login header customization. The logo box is otherwise a fixed 64×64
|
||||||
|
// (w-16/h-16), which fits a wide wordmark to ~13px tall; set a max height
|
||||||
|
// and/or width (any CSS length, e.g. "230px" or "3rem") to size it. The
|
||||||
|
// heading ({appName}) and subtitle can be hidden when the logo already
|
||||||
|
// reads as the brand (e.g. a wordmark) and they'd be redundant.
|
||||||
|
loginLogoMaxHeight: { envVar: 'LOGIN_LOGO_MAX_HEIGHT', type: 'string', defaultValue: '' },
|
||||||
|
loginLogoMaxWidth: { envVar: 'LOGIN_LOGO_MAX_WIDTH', type: 'string', defaultValue: '' },
|
||||||
|
loginShowHeading: { envVar: 'LOGIN_SHOW_HEADING', type: 'boolean', defaultValue: true },
|
||||||
|
loginShowSubtitle: { envVar: 'LOGIN_SHOW_SUBTITLE', type: 'boolean', defaultValue: true },
|
||||||
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
|
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
|
||||||
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
|
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
|
||||||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||||
|
|||||||
Reference in New Issue
Block a user