Skip to content

Commit e13057c

Browse files
Yerazeclaude
andauthored
feat(branding): customize login page title and logo via env vars (#3773) (#3781)
* feat(branding): customize login page title and logo via env vars (#3773) Add CUSTOM_TITLE and CUSTOM_LOGO_URL environment variables so operators deploying MeshMonitor for a community/organization can rebrand the login page without forking and rebuilding the image. - environment.ts: parse both vars; validate CUSTOM_LOGO_URL scheme (http(s)/data:image/relative only, rejecting javascript: etc.) - authRoutes /status: expose branding (null when unset) pre-auth so the login page can read it - AuthContext: optional customTitle/customLogoUrl on AuthStatus - LoginPage: render custom logo image + title, falling back to the built-in SVG and "MeshMonitor" heading when unset - docs + .env.example: document the new variables - tests: cover default/null, set values, and unsafe-scheme rejection Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4 * address review: drop stray files, reject SVG data URIs, tighten types/tests - Remove three unrelated files that `git add -A` swept into the branch (.claude/agent-memory/* and docs/internal/dev-notes/MCP_SERVER_PLAN.md) - CUSTOM_LOGO_URL: reject data:image/svg+xml (SVG can embed scripts); whitelist raster data: URIs (png/jpeg/gif/webp/avif) only - AuthStatus.customTitle/customLogoUrl are now non-optional (string | null) to match the runtime guarantee that /status always sends them - Add edge-case tests: whitespace-only title, SVG data URI rejected, raster data URI accepted - Docs/.env.example note the SVG exclusion and relative-path origin Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4 * address review: remove branch-only stray files from PR These were untracked working-tree artifacts at branch creation that a prior `git add -A` swept in. Untrack them (kept on disk locally). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4 * address review: drop redundant client-side trim on branding values The server already trims and normalizes CUSTOM_TITLE/CUSTOM_LOGO_URL (null when unset), so the client-side .trim() was dead code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 35bf160 commit e13057c

9 files changed

Lines changed: 220 additions & 25 deletions

File tree

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ ALLOWED_ORIGINS=http://localhost:8080
150150
# Default: false (anonymous access allowed)
151151
# DISABLE_ANONYMOUS=true
152152

153+
# Branding Configuration (login page)
154+
# CUSTOM_TITLE: Overrides the "MeshMonitor" heading shown on the login page.
155+
# Useful when deploying as a dashboard for a specific community or organization.
156+
# Default: "MeshMonitor"
157+
# CUSTOM_TITLE=My Organization Base
158+
#
159+
# CUSTOM_LOGO_URL: Overrides the default logo on the login page with your own image.
160+
# Accepts an http(s) URL, a raster data:image URI (png/jpeg/gif/webp/avif), or a
161+
# same-origin relative path. data:image/svg+xml is rejected (SVG can embed scripts).
162+
# Falls back to the built-in logo when unset or invalid.
163+
# CUSTOM_LOGO_URL=https://example.com/logo.png
164+
153165
# Logging Configuration
154166
# LOG_LEVEL: Controls the verbosity of log output
155167
# Valid values: debug, info, warn, error

docs/configuration/index.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,15 @@ See the [Push Notifications guide](/features/notifications) for setup instructio
194194

195195
**Note**: `LOG_LEVEL` controls log output independently of `NODE_ENV`. This lets you enable debug logging in production Docker deployments without changing rate limits, cookie warnings, or other `NODE_ENV`-dependent behavior.
196196

197+
### Branding Variables
198+
199+
| Variable | Description | Default |
200+
|----------|-------------|---------|
201+
| `CUSTOM_TITLE` | Overrides the "MeshMonitor" heading on the login page | `MeshMonitor` |
202+
| `CUSTOM_LOGO_URL` | Overrides the login-page logo with your own image (http(s), raster `data:image` — png/jpeg/gif/webp/avif, or same-origin relative URL) | built-in SVG |
203+
204+
**Note**: Both are optional and apply only to the login page. When unset — or when `CUSTOM_LOGO_URL` uses an unsupported scheme such as `javascript:` or a `data:image/svg+xml` URI (rejected because SVG can embed scripts) — MeshMonitor falls back to the default title and logo. Relative paths are resolved against the server's own origin.
205+
197206
### System Management Variables
198207

199208
| Variable | Description | Default |

src/components/LoginPage.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@
4040
margin-bottom: 10px;
4141
}
4242

43+
.login-logo-image {
44+
width: 120px;
45+
height: 120px;
46+
object-fit: contain;
47+
margin-bottom: 10px;
48+
}
49+
4350
.login-logo h1 {
4451
font-size: 32px;
4552
font-weight: 700;

src/components/LoginPage.tsx

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ const LoginPage: React.FC = () => {
3030
const localAuthDisabled = authStatus?.localAuthDisabled ?? false;
3131
const oidcEnabled = authStatus?.oidcEnabled ?? false;
3232

33+
// Optional login-page branding from CUSTOM_TITLE / CUSTOM_LOGO_URL env vars.
34+
// The server already trims/validates these and sends null when unset, so we
35+
// just fall back to the default MeshMonitor title and inline SVG here.
36+
const customTitle = authStatus?.customTitle || 'MeshMonitor';
37+
const customLogoUrl = authStatus?.customLogoUrl || null;
38+
3339
const handleLocalLogin = async (e: React.FormEvent) => {
3440
e.preventDefault();
3541
setError(null);
@@ -114,30 +120,40 @@ const LoginPage: React.FC = () => {
114120
return (
115121
<div className="login-page">
116122
<div className="login-container">
117-
{/* MeshMonitor Logo */}
123+
{/* Logo — custom image when CUSTOM_LOGO_URL is set, otherwise the default SVG */}
118124
<div className="login-logo">
119-
<svg
120-
width="120"
121-
height="120"
122-
viewBox="0 0 24 24"
123-
fill="none"
124-
xmlns="http://www.w3.org/2000/svg"
125-
>
126-
<path
127-
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"
128-
fill="currentColor"
129-
/>
130-
<circle cx="7" cy="12" r="1.5" fill="currentColor" />
131-
<circle cx="12" cy="7" r="1.5" fill="currentColor" />
132-
<circle cx="17" cy="12" r="1.5" fill="currentColor" />
133-
<circle cx="12" cy="17" r="1.5" fill="currentColor" />
134-
<path
135-
d="M7 12L12 7M12 7L17 12M17 12L12 17M12 17L7 12"
136-
stroke="currentColor"
137-
strokeWidth="1"
125+
{customLogoUrl ? (
126+
<img
127+
src={customLogoUrl}
128+
alt={customTitle}
129+
width="120"
130+
height="120"
131+
className="login-logo-image"
138132
/>
139-
</svg>
140-
<h1>MeshMonitor</h1>
133+
) : (
134+
<svg
135+
width="120"
136+
height="120"
137+
viewBox="0 0 24 24"
138+
fill="none"
139+
xmlns="http://www.w3.org/2000/svg"
140+
>
141+
<path
142+
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"
143+
fill="currentColor"
144+
/>
145+
<circle cx="7" cy="12" r="1.5" fill="currentColor" />
146+
<circle cx="12" cy="7" r="1.5" fill="currentColor" />
147+
<circle cx="17" cy="12" r="1.5" fill="currentColor" />
148+
<circle cx="12" cy="17" r="1.5" fill="currentColor" />
149+
<path
150+
d="M7 12L12 7M12 7L17 12M17 12L12 17M12 17L7 12"
151+
stroke="currentColor"
152+
strokeWidth="1"
153+
/>
154+
</svg>
155+
)}
156+
<h1>{customTitle}</h1>
141157
</div>
142158

143159
{/* Login Form */}

src/contexts/AuthContext.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ const mockAuthStatus = {
6969
oidcEnabled: false,
7070
localAuthDisabled: false,
7171
anonymousDisabled: false,
72+
customTitle: null,
73+
customLogoUrl: null,
7274
};
7375

7476
const wrapper = ({ children }: { children: React.ReactNode }) => (

src/contexts/AuthContext.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export interface AuthStatus {
4242
oidcEnabled: boolean;
4343
localAuthDisabled: boolean;
4444
anonymousDisabled: boolean;
45+
/**
46+
* Login-page branding from CUSTOM_TITLE / CUSTOM_LOGO_URL env vars.
47+
* Always present in the /status response (null when unset).
48+
*/
49+
customTitle: string | null;
50+
customLogoUrl: string | null;
4551
}
4652

4753
export interface LoginResult {
@@ -123,6 +129,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
123129
oidcEnabled: false,
124130
localAuthDisabled: false,
125131
anonymousDisabled: false,
132+
customTitle: null,
133+
customLogoUrl: null,
126134
});
127135
} finally {
128136
setLoading(false);

src/server/config/environment.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,12 @@ export interface EnvironmentConfig {
311311
// Logging
312312
logLevel: 'debug' | 'info' | 'warn' | 'error';
313313
logLevelProvided: boolean;
314+
315+
// Branding (login page customization)
316+
customTitle: string | undefined;
317+
customTitleProvided: boolean;
318+
customLogoUrl: string | undefined;
319+
customLogoUrlProvided: boolean;
314320
}
315321

316322
/**
@@ -693,6 +699,35 @@ export function loadEnvironmentConfig(): EnvironmentConfig {
693699
const logLevelDefault: 'debug' | 'info' | 'warn' | 'error' = nodeEnv.value === 'development' ? 'debug' : 'info';
694700
const logLevel = parseEnum('LOG_LEVEL', process.env.LOG_LEVEL?.toLowerCase(), ['debug', 'info', 'warn', 'error'] as const, logLevelDefault);
695701

702+
// Branding (login page customization)
703+
// CUSTOM_TITLE overrides the "MeshMonitor" heading on the login page.
704+
// CUSTOM_LOGO_URL overrides the inline SVG logo with an external/relative image.
705+
const customTitleRaw = process.env.CUSTOM_TITLE?.trim();
706+
const customTitle = {
707+
value: customTitleRaw || undefined,
708+
wasProvided: !!customTitleRaw
709+
};
710+
711+
const customLogoUrlRaw = process.env.CUSTOM_LOGO_URL?.trim();
712+
let customLogoUrl = { value: customLogoUrlRaw || undefined, wasProvided: !!customLogoUrlRaw };
713+
// Only allow safe URL schemes for the logo image. http(s), protocol-relative,
714+
// same-origin relative paths, and raster data:image URIs are accepted.
715+
// data:image/svg+xml is intentionally rejected — SVG can embed <script>, and
716+
// anything else (e.g. javascript:) is rejected to avoid surfacing a hostile
717+
// URL in markup.
718+
if (customLogoUrl.value) {
719+
const v = customLogoUrl.value;
720+
const safe =
721+
/^https?:\/\//i.test(v) ||
722+
/^\/\//.test(v) ||
723+
/^data:image\/(png|jpe?g|gif|webp|avif)[;,]/i.test(v) ||
724+
v.startsWith('/');
725+
if (!safe) {
726+
logger.warn(`⚠️ Invalid CUSTOM_LOGO_URL value: "${v}". Expected an http(s), raster data:image (png/jpeg/gif/webp/avif — not svg), or relative URL. Ignoring.`);
727+
customLogoUrl = { value: undefined, wasProvided: false };
728+
}
729+
}
730+
696731
// Log effective environment configuration at startup
697732
const src = (provided: boolean) => provided ? 'env' : 'default';
698733
logger.info('📋 Environment configuration:');
@@ -770,6 +805,11 @@ export function loadEnvironmentConfig(): EnvironmentConfig {
770805
logger.info(` ACCESS_LOG_PATH: ${accessLogPath.value} (${src(accessLogPath.wasProvided)})`);
771806
logger.info(` ACCESS_LOG_FORMAT: ${accessLogFormat.value} (${src(accessLogFormat.wasProvided)})`);
772807
}
808+
if (customTitle.wasProvided || customLogoUrl.wasProvided) {
809+
logger.info(' --- Branding ---');
810+
if (customTitle.wasProvided) logger.info(` CUSTOM_TITLE: ${customTitle.value}`);
811+
if (customLogoUrl.wasProvided) logger.info(` CUSTOM_LOGO_URL: ${customLogoUrl.value}`);
812+
}
773813

774814
return {
775815
// Node environment
@@ -904,7 +944,13 @@ export function loadEnvironmentConfig(): EnvironmentConfig {
904944

905945
// Logging
906946
logLevel: logLevel.value,
907-
logLevelProvided: logLevel.wasProvided
947+
logLevelProvided: logLevel.wasProvided,
948+
949+
// Branding (login page customization)
950+
customTitle: customTitle.value,
951+
customTitleProvided: customTitle.wasProvided,
952+
customLogoUrl: customLogoUrl.value,
953+
customLogoUrlProvided: customLogoUrl.wasProvided
908954
};
909955
}
910956

src/server/routes/authRoutes.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,4 +1083,89 @@ describe('Authentication Routes', () => {
10831083
expect(response.status).toBe(400);
10841084
});
10851085
});
1086+
1087+
describe('Login Branding (CUSTOM_TITLE / CUSTOM_LOGO_URL)', () => {
1088+
let originalTitle: string | undefined;
1089+
let originalLogo: string | undefined;
1090+
1091+
beforeEach(() => {
1092+
originalTitle = process.env.CUSTOM_TITLE;
1093+
originalLogo = process.env.CUSTOM_LOGO_URL;
1094+
});
1095+
1096+
afterEach(async () => {
1097+
if (originalTitle !== undefined) process.env.CUSTOM_TITLE = originalTitle;
1098+
else delete process.env.CUSTOM_TITLE;
1099+
if (originalLogo !== undefined) process.env.CUSTOM_LOGO_URL = originalLogo;
1100+
else delete process.env.CUSTOM_LOGO_URL;
1101+
1102+
const { resetEnvironmentConfig } = await import('../config/environment.js');
1103+
resetEnvironmentConfig();
1104+
});
1105+
1106+
it('returns null branding by default', async () => {
1107+
delete process.env.CUSTOM_TITLE;
1108+
delete process.env.CUSTOM_LOGO_URL;
1109+
const { resetEnvironmentConfig } = await import('../config/environment.js');
1110+
resetEnvironmentConfig();
1111+
1112+
const response = await request(app).get('/api/auth/status').expect(200);
1113+
1114+
expect(response.body.customTitle).toBeNull();
1115+
expect(response.body.customLogoUrl).toBeNull();
1116+
});
1117+
1118+
it('exposes CUSTOM_TITLE and CUSTOM_LOGO_URL when set', async () => {
1119+
process.env.CUSTOM_TITLE = 'My Organization Base';
1120+
process.env.CUSTOM_LOGO_URL = 'https://example.com/logo.png';
1121+
const { resetEnvironmentConfig } = await import('../config/environment.js');
1122+
resetEnvironmentConfig();
1123+
1124+
const response = await request(app).get('/api/auth/status').expect(200);
1125+
1126+
expect(response.body.customTitle).toBe('My Organization Base');
1127+
expect(response.body.customLogoUrl).toBe('https://example.com/logo.png');
1128+
});
1129+
1130+
it('rejects an unsafe logo URL scheme', async () => {
1131+
process.env.CUSTOM_LOGO_URL = 'javascript:alert(1)';
1132+
const { resetEnvironmentConfig } = await import('../config/environment.js');
1133+
resetEnvironmentConfig();
1134+
1135+
const response = await request(app).get('/api/auth/status').expect(200);
1136+
1137+
expect(response.body.customLogoUrl).toBeNull();
1138+
});
1139+
1140+
it('treats a whitespace-only CUSTOM_TITLE as unset', async () => {
1141+
process.env.CUSTOM_TITLE = ' ';
1142+
const { resetEnvironmentConfig } = await import('../config/environment.js');
1143+
resetEnvironmentConfig();
1144+
1145+
const response = await request(app).get('/api/auth/status').expect(200);
1146+
1147+
expect(response.body.customTitle).toBeNull();
1148+
});
1149+
1150+
it('rejects an SVG data: URI logo (can embed scripts)', async () => {
1151+
process.env.CUSTOM_LOGO_URL = 'data:image/svg+xml,<svg onload="alert(1)"></svg>';
1152+
const { resetEnvironmentConfig } = await import('../config/environment.js');
1153+
resetEnvironmentConfig();
1154+
1155+
const response = await request(app).get('/api/auth/status').expect(200);
1156+
1157+
expect(response.body.customLogoUrl).toBeNull();
1158+
});
1159+
1160+
it('accepts a raster data: URI logo', async () => {
1161+
const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
1162+
process.env.CUSTOM_LOGO_URL = png;
1163+
const { resetEnvironmentConfig } = await import('../config/environment.js');
1164+
resetEnvironmentConfig();
1165+
1166+
const response = await request(app).get('/api/auth/status').expect(200);
1167+
1168+
expect(response.body.customLogoUrl).toBe(png);
1169+
});
1170+
});
10861171
});

src/server/routes/authRoutes.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,15 @@ function regenerateSession(req: Request): Promise<void> {
4343
// Get authentication status
4444
router.get('/status', async (req: Request, res: Response) => {
4545
try {
46-
const localAuthDisabled = getEnvironmentConfig().disableLocalAuth;
47-
const anonymousDisabled = getEnvironmentConfig().disableAnonymous;
46+
const envConfig = getEnvironmentConfig();
47+
const localAuthDisabled = envConfig.disableLocalAuth;
48+
const anonymousDisabled = envConfig.disableAnonymous;
49+
50+
// Public branding config for the login page (safe to expose pre-auth).
51+
const branding = {
52+
customTitle: envConfig.customTitle ?? null,
53+
customLogoUrl: envConfig.customLogoUrl ?? null,
54+
};
4855

4956
if (!req.session.userId) {
5057
// Check if the session cookie exists at all
@@ -76,6 +83,7 @@ router.get('/status', async (req: Request, res: Response) => {
7683
oidcEnabled: isOIDCEnabled(),
7784
localAuthDisabled,
7885
anonymousDisabled,
86+
...branding,
7987
});
8088
}
8189

@@ -101,6 +109,7 @@ router.get('/status', async (req: Request, res: Response) => {
101109
oidcEnabled: isOIDCEnabled(),
102110
localAuthDisabled,
103111
anonymousDisabled,
112+
...branding,
104113
});
105114
}
106115

@@ -117,6 +126,7 @@ router.get('/status', async (req: Request, res: Response) => {
117126
oidcEnabled: isOIDCEnabled(),
118127
localAuthDisabled,
119128
anonymousDisabled,
129+
...branding,
120130
});
121131
} catch (error) {
122132
logger.error('Error getting auth status:', error);

0 commit comments

Comments
 (0)