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..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.