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 (
+
+
+ {renderMessage(message)}
+
+ );
+}
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() {
)}
+
+
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..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');
@@ -34,14 +35,28 @@ 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, {
- status: 'ok',
- isDev: process.env.NODE_ENV !== 'production',
- oidcEnabled: isOidcEnabled(),
+const { Setting } = require('../../../models');
+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,
+ });
}),
);
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.