Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions create-a-container/client/src/app/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -11,6 +12,7 @@ export function AppLayout() {
</Sidebar>
<div className="flex min-w-0 flex-1 flex-col">
<AppTopHeader />
<AppBanner />
<main className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-6 sm:px-6 lg:px-8">
<Outlet />
</main>
Expand Down
52 changes: 52 additions & 0 deletions create-a-container/client/src/app/Banner.tsx
Original file line number Diff line number Diff line change
@@ -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(
<a
key={match.index}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
className="font-semibold underline underline-offset-2 hover:opacity-80"
>
{match[1]}
</a>,
);
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 (
<div
role="status"
className="flex items-center justify-center gap-2 bg-[var(--mieweb-primary-700,#1786b3)] px-4 py-2 text-center text-sm text-white"
>
<Megaphone className="size-4 shrink-0" aria-hidden="true" />
<span>{renderMessage(message)}</span>
</div>
);
}
5 changes: 5 additions & 0 deletions create-a-container/client/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
17 changes: 17 additions & 0 deletions create-a-container/client/src/pages/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<typeof schema>;

Expand All @@ -51,6 +53,7 @@ export function SettingsPage() {
defaultContainerEnvVars: [],
netboxUrl: '',
netboxToken: '',
bannerMessage: '',
},
});
const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' });
Expand All @@ -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),
});
Expand Down Expand Up @@ -130,6 +136,17 @@ export function SettingsPage() {
)}
</section>

<section className="grid gap-4">
<h2 className="text-lg font-semibold">Announcement banner</h2>
<Input
label="Banner message"
placeholder="Try [My New App](https://app.example.com)."
helperText="Shown to all users at the top of the app. Link syntax: [text](url). Leave empty to hide the banner."
autoCorrect="off"
{...register('bannerMessage')}
/>
</section>

<section className="grid gap-4">
<h2 className="text-lg font-semibold">NetBox</h2>
<Input
Expand Down
75 changes: 75 additions & 0 deletions create-a-container/routers/api/v1/__tests__/banner.api.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Integration tests for the announcement banner: an admin-editable
* `banner_message` Setting, written via PUT /api/v1/settings and exposed to
* everyone (including unauthenticated visitors) via GET /api/v1/health.
*/

const request = require('supertest');
const { buildApp, bearer } = require('../../../../tests/helpers/app');
const { resetDb, closeDb, createUser, createApiKey } = require('../../../../tests/helpers/db');

describe('announcement banner', () => {
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();
});
});
29 changes: 22 additions & 7 deletions create-a-container/routers/api/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
generateCsrfToken,
jsonErrorHandler,
apiAuth,
asyncHandler,
ok,
} = require('../../../middlewares/api');

Expand All @@ -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,
});
}),
);

Expand Down
4 changes: 4 additions & 0 deletions create-a-container/routers/api/v1/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const KEYS = [
'default_container_env_vars',
'netbox_url',
'netbox_token',
'banner_message',
];

router.get(
Expand All @@ -34,6 +35,7 @@ router.get(
defaultContainerEnvVars,
netboxUrl: settings.netbox_url || '',
netboxToken: settings.netbox_token || '',
bannerMessage: settings.banner_message || '',
});
}),
);
Expand All @@ -47,6 +49,7 @@ router.put(
defaultContainerEnvVars,
netboxUrl,
netboxToken,
bannerMessage,
} = req.body || {};

const envVars = [];
Expand All @@ -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 });
}),
Expand Down
8 changes: 8 additions & 0 deletions mie-opensource-landing/docs/admins/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading