mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
* 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>
130 lines
5.0 KiB
TypeScript
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 }))
|
|
}
|