Files
windmill/frontend/src/lib/components/instanceBanner.ts
T
Ruben FiszelandClaude Opus 5 abf4c6c234 feat: add a dismissible instance-wide announcement banner (#11037)
* feat: add a dismissible instance-wide announcement banner

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs

* fix: harden instance banner validation and mandatory-banner visibility

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs

* fix: sequence instance banner loads and match the backend character cap

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs

* fix: gate the settings save on a valid instance banner link

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs

* feat: restrict the announcement banner to the managed cloud

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExBp57hUoB8hQm36bJuUEs

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 11:20:25 +02:00

130 lines
5.0 KiB
TypeScript

import { SettingService } from '$lib/gen'
/** Drives the banner palette and icon; the subset of `AlertType` that fits an announcement. */
export type InstanceBannerSeverity = 'info' | 'warning' | 'error'
/** Stored shape of the `instance_banner` global setting. Every field is optional: a
* stored value can predate a field this code knows about. */
export interface InstanceBanner {
enabled?: boolean
message?: string
severity?: InstanceBannerSeverity
/** Whether a viewer may dismiss the banner for themselves. Absent means yes. */
dismissible?: boolean
link?: string
link_label?: string
}
export const INSTANCE_BANNER_SETTING = 'instance_banner'
/** Mirror `INSTANCE_BANNER_MESSAGE_MAX_LEN` / `INSTANCE_BANNER_LINK_LABEL_MAX_LEN` in
* backend/windmill-common/src/global_settings.rs, which reject longer values at write time. */
export const INSTANCE_BANNER_MESSAGE_MAX_LEN = 500
export const INSTANCE_BANNER_LINK_LABEL_MAX_LEN = 60
export type ResolvedInstanceBanner = {
message: string
severity: InstanceBannerSeverity
dismissible: boolean
link?: string
linkLabel: string
/** Dismissal token: a viewer who dismissed one announcement sees the next one,
* because editing any displayed part of the banner changes this string. */
fingerprint: string
}
/**
* Read a stored banner field as a string.
*
* The setting is a raw `global_settings` row, so its shape is only ever as good as the
* writer that last touched it — and anything here that calls `.trim()` on a number throws,
* taking the whole settings form down with it.
*/
export function bannerString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/**
* Truncate to `max` code points, matching the backend's `chars().count()` cap.
*
* `String.slice` counts UTF-16 code units, so it would cut a 500-emoji message the backend
* accepted in half — and the two sides must agree on what "500 characters" means.
*/
function truncateChars(value: string, max: number): string {
const chars = [...value]
return chars.length > max ? chars.slice(0, max).join('') : value
}
export function isHttpUrl(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
/**
* Turn the raw setting into what the banner renders, or `undefined` for "show nothing".
*
* The scheme check repeats the one the writers run on purpose. The link becomes the href of
* an anchor shown to every user of the instance, and this is the last place that can refuse
* it — a row predating the validator, or written straight to the table, reaches here having
* passed nothing.
*/
export function resolveInstanceBanner(raw: unknown): ResolvedInstanceBanner | undefined {
if (!raw || typeof raw !== 'object') return undefined
const banner = raw as InstanceBanner
const message = bannerString(banner.message).trim()
if (banner.enabled !== true || message === '') return undefined
const severity: InstanceBannerSeverity =
banner.severity === 'warning' || banner.severity === 'error' ? banner.severity : 'info'
const rawLink = bannerString(banner.link).trim()
const link = isHttpUrl(rawLink) ? rawLink : undefined
const linkLabel =
truncateChars(bannerString(banner.link_label).trim(), INSTANCE_BANNER_LINK_LABEL_MAX_LEN) ||
'Learn more'
return {
message: truncateChars(message, INSTANCE_BANNER_MESSAGE_MAX_LEN),
severity,
dismissible: banner.dismissible !== false,
link,
linkLabel,
fingerprint: JSON.stringify([message, severity, link ?? '', link ? linkLabel : ''])
}
}
/**
* The reason the banner form cannot be saved, or `undefined` when it can.
*
* Shared with the setting's `isValid` so the Save button and the inline message agree: the
* backend refuses a bad link, and a category save fires its settings concurrently, so a Save
* that got this far would persist the other Core settings and fail only the banner.
*/
export function instanceBannerFormError(value: unknown): string | undefined {
if (!value || typeof value !== 'object') return undefined
const link = bannerString((value as InstanceBanner).link).trim()
return link !== '' && !isHttpUrl(link) ? 'Link must be an absolute http(s) URL' : undefined
}
/**
* Whether a viewer holding `dismissedFingerprint` should see this announcement.
*
* A non-dismissible announcement ignores stored dismissals entirely: an admin escalating an
* existing notice to mandatory must reach the people who already dismissed it, and the
* fingerprint deliberately does not cover `dismissible`, so nothing else would bring it back.
*/
export function isInstanceBannerVisible(
banner: ResolvedInstanceBanner | undefined,
dismissedFingerprint: string
): banner is ResolvedInstanceBanner {
if (banner == undefined) return false
return !banner.dismissible || dismissedFingerprint !== banner.fingerprint
}
export async function fetchInstanceBanner(): Promise<ResolvedInstanceBanner | undefined> {
return resolveInstanceBanner(await SettingService.getGlobal({ key: INSTANCE_BANNER_SETTING }))
}