From 9a3d4773e7dbf8b0a985be0976ca8a6139f38504 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 23 Jul 2026 15:52:18 -0400 Subject: [PATCH 1/2] feat(create-a-container): admin-configurable announcement banner Adds a banner_message setting (admin Settings page) exposed through the unauthenticated /api/v1/health endpoint and rendered as a strip at the top of the app shell. Supports [text](url) links, parsed client-side into anchors (http/https only) so admin-provided content stays XSS-safe. Lets each deployment announce environment-specific links (e.g. a local Ozwell Studio launcher) at runtime, without hardcoding URLs in the repo or requiring a restart. --- .../client/src/app/AppLayout.tsx | 2 + create-a-container/client/src/app/Banner.tsx | 52 +++++++++++++ create-a-container/client/src/lib/auth.ts | 5 ++ create-a-container/client/src/lib/types.ts | 2 + .../src/pages/settings/SettingsPage.tsx | 17 +++++ .../api/v1/__tests__/banner.api.test.js | 75 +++++++++++++++++++ create-a-container/routers/api/v1/index.js | 23 ++++-- create-a-container/routers/api/v1/settings.js | 4 + .../docs/admins/settings.md | 8 ++ 9 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 create-a-container/client/src/app/Banner.tsx create mode 100644 create-a-container/routers/api/v1/__tests__/banner.api.test.js diff --git a/create-a-container/client/src/app/AppLayout.tsx b/create-a-container/client/src/app/AppLayout.tsx index c05f2035..e0a399de 100644 --- a/create-a-container/client/src/app/AppLayout.tsx +++ b/create-a-container/client/src/app/AppLayout.tsx @@ -2,6 +2,7 @@ import { Outlet } from 'react-router'; import { Sidebar, CommandPalette } from '@mieweb/ui'; import { AppSidebar } from './Sidebar'; import { AppTopHeader } from './Header'; +import { AppBanner } from './Banner'; export function AppLayout() { return ( @@ -11,6 +12,7 @@ export function AppLayout() {
+
diff --git a/create-a-container/client/src/app/Banner.tsx b/create-a-container/client/src/app/Banner.tsx new file mode 100644 index 00000000..4d05cba2 --- /dev/null +++ b/create-a-container/client/src/app/Banner.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from 'react'; +import { Megaphone } from 'lucide-react'; +import { useServerInfo } from '@/lib/auth'; + +/** + * Turns "[text](url)" spans into anchors and leaves everything else as plain + * text. Only http(s) URLs are linkified. Parsing into React nodes (instead of + * injecting HTML) keeps the admin-provided message XSS-safe. + */ +function renderMessage(message: string): ReactNode[] { + const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g; + const nodes: ReactNode[] = []; + let cursor = 0; + for (const match of message.matchAll(linkPattern)) { + if (match.index > cursor) nodes.push(message.slice(cursor, match.index)); + nodes.push( + + {match[1]} + , + ); + cursor = match.index + match[0].length; + } + if (cursor < message.length) nodes.push(message.slice(cursor)); + return nodes; +} + +/** + * Deployment-specific announcement strip. The message lives in the Settings + * table (admin Settings page → "Announcement banner") and reaches the client + * through GET /api/v1/health, so each environment controls its own banner + * without any redeploy. Renders nothing when no message is configured. + */ +export function AppBanner() { + const { data } = useServerInfo(); + const message = data?.banner?.trim(); + if (!message) return null; + return ( +
+
+ ); +} diff --git a/create-a-container/client/src/lib/auth.ts b/create-a-container/client/src/lib/auth.ts index 9f846072..c427ddaa 100644 --- a/create-a-container/client/src/lib/auth.ts +++ b/create-a-container/client/src/lib/auth.ts @@ -11,6 +11,11 @@ export interface ServerInfo { isDev: boolean; /** True when an OIDC identity provider is configured for SSO. */ oidcEnabled: boolean; + /** + * Admin-configured announcement shown at the top of the app (set on the + * Settings page). Supports [text](url) links. Null/empty hides the banner. + */ + banner?: string | null; } export const sessionKey = ['session'] as const; diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index 4b228867..fbb7d30d 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -200,6 +200,8 @@ export interface AppSettings { netboxUrl: string; netboxToken: string; defaultContainerEnvVars: { key: string; value: string; description?: string }[]; + /** Announcement banner shown to all users. Supports [text](url) links. */ + bannerMessage: string; } export type ResourceType = 'memory' | 'swap' | 'cpus' | 'rootfs'; diff --git a/create-a-container/client/src/pages/settings/SettingsPage.tsx b/create-a-container/client/src/pages/settings/SettingsPage.tsx index e36f23cc..e873da66 100644 --- a/create-a-container/client/src/pages/settings/SettingsPage.tsx +++ b/create-a-container/client/src/pages/settings/SettingsPage.tsx @@ -20,6 +20,7 @@ import { } from '@mieweb/ui'; import { Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'; import { api, ApiError } from '@/lib/api'; +import { serverInfoKey } from '@/lib/auth'; import { keys, queries } from '@/lib/queries'; import type { AppSettings } from '@/lib/types'; @@ -35,6 +36,7 @@ const schema = z.object({ defaultContainerEnvVars: z.array(envVarSchema), netboxUrl: z.string(), netboxToken: z.string(), + bannerMessage: z.string(), }); type FormData = z.infer; @@ -51,6 +53,7 @@ export function SettingsPage() { defaultContainerEnvVars: [], netboxUrl: '', netboxToken: '', + bannerMessage: '', }, }); const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' }); @@ -64,6 +67,9 @@ export function SettingsPage() { onSuccess: () => { toast.success('Settings saved'); qc.invalidateQueries({ queryKey: keys.settings() }); + // The banner is served via /api/v1/health (cached forever by + // useServerInfo), so refetch it for the change to show without a reload. + qc.invalidateQueries({ queryKey: serverInfoKey }); }, onError: (err: ApiError) => toast.error(err.message), }); @@ -130,6 +136,17 @@ export function SettingsPage() { )} +
+

Announcement banner

+ +
+

NetBox

{ + let app; + let adminKey; // Bearer credential for an admin user + let userKey; // Bearer credential for a regular user + + beforeAll(async () => { + // Order matters: resetDb() first (see tests/helpers/app.js). + await resetDb(); + app = buildApp(); + // First user created is auto-promoted to sysadmins (User.afterCreate). + const admin = await createUser({ uid: 'bannermin' }); + const user = await createUser({ uid: 'banneruser' }); + adminKey = (await createApiKey(admin, 'admin key')).plainKey; + userKey = (await createApiKey(user, 'user key')).plainKey; + }); + + afterAll(async () => { + await closeDb(); + }); + + test('health reports no banner by default', async () => { + const res = await request(app).get('/api/v1/health'); + expect(res.status).toBe(200); + expect(res.body.data.banner).toBeNull(); + }); + + test('non-admins cannot write settings', async () => { + const res = await request(app) + .put('/api/v1/settings') + .set(...bearer(userKey)) + .send({ bannerMessage: 'nope' }); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('forbidden'); + }); + + test('admin sets the banner; health exposes it unauthenticated', async () => { + const message = 'Try [Ozwell Studio](https://example.test/studio).'; + const put = await request(app) + .put('/api/v1/settings') + .set(...bearer(adminKey)) + .send({ bannerMessage: ` ${message} ` }); // stored trimmed + expect(put.status).toBe(200); + + const settings = await request(app) + .get('/api/v1/settings') + .set(...bearer(adminKey)); + expect(settings.status).toBe(200); + expect(settings.body.data.bannerMessage).toBe(message); + + const health = await request(app).get('/api/v1/health'); + expect(health.status).toBe(200); + expect(health.body.data.banner).toBe(message); + }); + + test('clearing the banner hides it again', async () => { + const put = await request(app) + .put('/api/v1/settings') + .set(...bearer(adminKey)) + .send({ bannerMessage: '' }); + expect(put.status).toBe(200); + + const health = await request(app).get('/api/v1/health'); + expect(health.body.data.banner).toBeNull(); + }); +}); diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index 815f0e8c..e485c499 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -34,16 +34,27 @@ router.get('/csrf-token', (req, res) => { }); // Health check (unauthenticated). Exposes `isDev` so the SPA can render -// non-production helpers like one-click dev login buttons, and `oidcEnabled` -// so the login screen can auto-redirect to the configured identity provider. +// non-production helpers like one-click dev login buttons, `oidcEnabled` +// so the login screen can auto-redirect to the configured identity provider, +// and `banner` — an admin-configured announcement (Settings page) shown at +// the top of the app. Supports [text](url) links, rendered by the client. const { isOidcEnabled } = require('../../../utils/oidc'); -router.get('/health', (_req, res) => - ok(res, { +const { Setting } = require('../../../models'); +router.get('/health', async (_req, res) => { + // The banner is cosmetic — never let a DB hiccup fail the health check. + let banner = null; + try { + banner = (await Setting.get('banner_message'))?.trim() || null; + } catch { + /* Settings unavailable — omit the banner */ + } + return ok(res, { status: 'ok', isDev: process.env.NODE_ENV !== 'production', oidcEnabled: isOidcEnabled(), - }), -); + banner, + }); +}); // OpenAPI v1 spec (unauthenticated) router.get('/openapi.json', (_req, res) => res.json(openapiSpec)); diff --git a/create-a-container/routers/api/v1/settings.js b/create-a-container/routers/api/v1/settings.js index b4cd6f70..e69ead83 100644 --- a/create-a-container/routers/api/v1/settings.js +++ b/create-a-container/routers/api/v1/settings.js @@ -16,6 +16,7 @@ const KEYS = [ 'default_container_env_vars', 'netbox_url', 'netbox_token', + 'banner_message', ]; router.get( @@ -34,6 +35,7 @@ router.get( defaultContainerEnvVars, netboxUrl: settings.netbox_url || '', netboxToken: settings.netbox_token || '', + bannerMessage: settings.banner_message || '', }); }), ); @@ -47,6 +49,7 @@ router.put( defaultContainerEnvVars, netboxUrl, netboxToken, + bannerMessage, } = req.body || {}; const envVars = []; @@ -67,6 +70,7 @@ router.put( await Setting.set('default_container_env_vars', JSON.stringify(envVars)); await Setting.set('netbox_url', netboxUrl || ''); await Setting.set('netbox_token', netboxToken || ''); + await Setting.set('banner_message', (bannerMessage || '').trim()); return ok(res, { saved: true }); }), diff --git a/mie-opensource-landing/docs/admins/settings.md b/mie-opensource-landing/docs/admins/settings.md index 632a24c3..a66c466e 100644 --- a/mie-opensource-landing/docs/admins/settings.md +++ b/mie-opensource-landing/docs/admins/settings.md @@ -17,6 +17,14 @@ When configured, users can reset passwords via "Forgot your password?" on the lo !!! warning Without SMTP, password resets require manual admin intervention. +## Announcement Banner + +Display a one-line announcement to all users at the top of the app — useful for deployment-specific notices like linking to a companion app hosted in the same environment. + +- **Banner message**: plain text, with optional links using `[text](url)` syntax, e.g. `Try [Ozwell Studio](https://ozwell-studio.example.org).` +- Stored per deployment in the database; no redeploy or restart needed. +- Leave empty to hide the banner. + ## Authentication The Manager authenticates users with internal username/password by default. To delegate authentication (and MFA) to an external identity provider, configure single sign-on — see [OIDC Single Sign-On](oidc.md). When OIDC is enabled, internal password login and self-registration are disabled. From d3885793fe99e1e06da600b74866488cfaaecfcb Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Fri, 24 Jul 2026 08:26:14 -0400 Subject: [PATCH 2/2] fix(create-a-container): wrap async /health handler in asyncHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on PR #424: route async handlers through the project's asyncHandler so any unexpected rejection is forwarded to the JSON error handler. Express 5 does forward rejected promises natively, but every other async route here uses the wrapper — stay consistent and future-proof. --- create-a-container/routers/api/v1/index.js | 34 ++++++++++++---------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index e485c499..b345b1dc 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -12,6 +12,7 @@ const { generateCsrfToken, jsonErrorHandler, apiAuth, + asyncHandler, ok, } = require('../../../middlewares/api'); @@ -40,21 +41,24 @@ router.get('/csrf-token', (req, res) => { // the top of the app. Supports [text](url) links, rendered by the client. const { isOidcEnabled } = require('../../../utils/oidc'); const { Setting } = require('../../../models'); -router.get('/health', async (_req, res) => { - // The banner is cosmetic — never let a DB hiccup fail the health check. - let banner = null; - try { - banner = (await Setting.get('banner_message'))?.trim() || null; - } catch { - /* Settings unavailable — omit the banner */ - } - return ok(res, { - status: 'ok', - isDev: process.env.NODE_ENV !== 'production', - oidcEnabled: isOidcEnabled(), - banner, - }); -}); +router.get( + '/health', + asyncHandler(async (_req, res) => { + // The banner is cosmetic — never let a DB hiccup fail the health check. + let banner = null; + try { + banner = (await Setting.get('banner_message'))?.trim() || null; + } catch { + /* Settings unavailable — omit the banner */ + } + return ok(res, { + status: 'ok', + isDev: process.env.NODE_ENV !== 'production', + oidcEnabled: isOidcEnabled(), + banner, + }); + }), +); // OpenAPI v1 spec (unauthenticated) router.get('/openapi.json', (_req, res) => res.json(openapiSpec));