From ad8d7a955638a9c2dd0b75108f47efa4305af9b3 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 20 Jul 2026 17:59:50 -0700 Subject: [PATCH 01/19] feat(announcements): add persistent BANNER notice type for maintenance and outage info --- .../src/components/AnnouncementBanner.tsx | 44 +++++++++++++++++++ frontend/src/components/App.tsx | 2 + frontend/src/models/announcements.ts | 4 ++ frontend/src/selectors/announcements.ts | 21 ++++++--- frontend/src/types.d.ts | 4 +- 5 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/AnnouncementBanner.tsx diff --git a/frontend/src/components/AnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner.tsx new file mode 100644 index 000000000..c7d19fdd3 --- /dev/null +++ b/frontend/src/components/AnnouncementBanner.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { Box, Button } from '@mui/material' +import { State } from '../store' +import { selectBannerAnnouncements } from '../selectors/announcements' +import { Notice } from './Notice' + +export const AnnouncementBanner: React.FC = () => { + const banners = useSelector((state: State) => selectBannerAnnouncements(state)) + + // Re-check `until` on every render rather than in the memoized selector. The server already + // filters expired notices, but `announcements` is persisted, so an offline or failed fetch + // would otherwise leave an expired banner pinned with no way to dismiss it. + const now = new Date() + const active = banners.filter(banner => !banner.until || banner.until > now) + + if (!active.length) return null + + return ( + + {active.map(banner => ( + + Learn more + + ) : undefined + } + > + {banner.preview ? ( + + ) : ( + {banner.title} + )} + + ))} + + ) +} diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 5838f41d7..0d315efa3 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -31,6 +31,7 @@ import { Page } from '../pages/Page' import { Logo } from '@common/brand/Logo' import { ViewAsBanner } from './ViewAsBanner' import { AnnouncementDialog } from './AnnouncementDialog' +import { AnnouncementBanner } from './AnnouncementBanner' export const App: React.FC = () => { const { insets } = useSafeArea() @@ -110,6 +111,7 @@ export const App: React.FC = () => { return ( + }> ()({ id title body + preview image link type modified + until read } }` @@ -47,10 +49,12 @@ export default createModel()({ id: n.id, title: n.title, body: n.body, + preview: n.preview, image: n.image, link: n.link, type: n.type, modified: new Date(n.modified), + until: n.until ? new Date(n.until) : undefined, read: n.read ? new Date(n.read) : undefined, })) }, diff --git a/frontend/src/selectors/announcements.ts b/frontend/src/selectors/announcements.ts index 694eff0e7..0060ec10f 100644 --- a/frontend/src/selectors/announcements.ts +++ b/frontend/src/selectors/announcements.ts @@ -1,13 +1,20 @@ import { createSelector } from 'reselect' import { getAnnouncements, optionalParam } from './state' -export const selectAnnouncements = createSelector( - [getAnnouncements, optionalParam], - (announcements, unread?: boolean) => announcements.filter(a => !unread || !a.read) +// Banners are presented as a persistent bar at the top of the app rather than as cards, so they +// are excluded from the announcements list, the unread badge and the full-screen presentation. +const isBanner = (announcement: IAnnouncement) => announcement.type === 'BANNER' + +export const selectAnnouncements = createSelector([getAnnouncements, optionalParam], (announcements, unread?: boolean) => + announcements.filter(a => !isBanner(a) && (!unread || !a.read)) +) + +export const selectBannerAnnouncements = createSelector([getAnnouncements], announcements => + announcements.filter(isBanner) ) export const selectLatestAnnouncement = createSelector([getAnnouncements], announcements => - getLatestAnnouncement(announcements) + getLatestAnnouncement(announcements.filter(a => !isBanner(a))) ) export const selectLatestUnreadAnnouncement = createSelector( @@ -16,7 +23,11 @@ export const selectLatestUnreadAnnouncement = createSelector( getLatestAnnouncement( announcements.filter(announcement => { const modified = announcement.modified?.getTime() || 0 - return !announcement.read && (presentedThrough === undefined || modified > presentedThrough) + return ( + !isBanner(announcement) && + !announcement.read && + (presentedThrough === undefined || modified > presentedThrough) + ) }) ) ) diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index c12d30d9e..5f4f7a06c 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -180,11 +180,13 @@ declare global { link: string image: string body: string | React.ReactNode + preview?: string modified?: Date + until?: Date read?: Date } - type INoticeType = 'GENERIC' | 'SYSTEM' | 'RELEASE' | 'COMMUNICATION' | 'SECURITY' + type INoticeType = 'GENERIC' | 'SYSTEM' | 'RELEASE' | 'COMMUNICATION' | 'SECURITY' | 'BANNER' type IPurchase = { checkout?: boolean From 5a08b9f078c84694f033870a3d13219e90b101d4 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 20 Jul 2026 18:21:29 -0700 Subject: [PATCH 02/19] fix(announcements): render banner as full-bleed bar with structured title and subtitle --- .../src/components/AnnouncementBanner.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/AnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner.tsx index c7d19fdd3..07a8d50e5 100644 --- a/frontend/src/components/AnnouncementBanner.tsx +++ b/frontend/src/components/AnnouncementBanner.tsx @@ -24,19 +24,27 @@ export const AnnouncementBanner: React.FC = () => { severity="warning" fullWidth solid + // Square corners so it reads as a bar rather than a card — the app panel in + // RemoteHeader is `overflow: hidden` and rounded, so it clips the top corners for us. + sx={{ borderRadius: 0 }} button={ banner.link ? ( - ) : undefined } > - {banner.preview ? ( - - ) : ( - {banner.title} - )} + {/* + Bind the two lines structurally rather than relying on the notice author to hand-write + ``/`` inside `preview`. `title` is NOT NULL so a banner always has a + heading, and both columns are varchar(255) — so this spends none of that budget on + markup. Notice styles `strong` as the title and `em` as a smaller block subtitle. + */} + {banner.title} + {banner.preview && } ))} From 600fc19d9e6aa6381474d9cb15a5edb9f626c475 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 09:21:22 -0700 Subject: [PATCH 03/19] feat(admin): add notice management pages for creating and scheduling notices --- frontend/src/components/AdminSidebarNav.tsx | 11 + frontend/src/models/adminNotices.ts | 89 +++++++ frontend/src/models/auth.ts | 1 + frontend/src/models/index.ts | 3 + .../AdminNoticesPage/AdminNoticeForm.tsx | 217 ++++++++++++++++++ .../AdminNoticesPage/AdminNoticePage.tsx | 60 +++++ .../AdminNoticesPage/AdminNoticesListPage.tsx | 161 +++++++++++++ frontend/src/routers/Router.tsx | 12 + frontend/src/services/graphQLMutation.ts | 29 +++ frontend/src/services/graphQLRequest.ts | 22 ++ frontend/src/types.d.ts | 32 +++ 11 files changed, 637 insertions(+) create mode 100644 frontend/src/models/adminNotices.ts create mode 100644 frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx create mode 100644 frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx create mode 100644 frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx diff --git a/frontend/src/components/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx index 4c27ea005..a5a3ba236 100644 --- a/frontend/src/components/AdminSidebarNav.tsx +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -87,6 +87,17 @@ export const AdminSidebarNav: React.FC = () => { + + handleNavClick('/admin/notices')} + > + + + + + ) } diff --git a/frontend/src/models/adminNotices.ts b/frontend/src/models/adminNotices.ts new file mode 100644 index 000000000..db22a51d0 --- /dev/null +++ b/frontend/src/models/adminNotices.ts @@ -0,0 +1,89 @@ +import { createModel } from '@rematch/core' +import { graphQLAllNotices } from '../services/graphQLRequest' +import { graphQLCreateNotice, graphQLDeleteNotice, graphQLUpdateNotice } from '../services/graphQLMutation' +import type { RootModel } from '.' + +type AdminNoticesState = { + notices: IAdminNotice[] + loading: boolean + saving: boolean + initialized: boolean +} + +const defaultState: AdminNoticesState = { + notices: [], + loading: false, + saving: false, + initialized: false, +} + +export default createModel()({ + state: defaultState, + effects: dispatch => ({ + async fetch() { + dispatch.adminNotices.set({ loading: true }) + const response = await graphQLAllNotices() + if (response === 'ERROR') { + dispatch.adminNotices.set({ loading: false }) + return + } + const notices = parse(response?.data?.data?.allNotices) + dispatch.adminNotices.set({ notices, loading: false, initialized: true }) + }, + + async create(notice: INoticeInput) { + dispatch.adminNotices.set({ saving: true }) + const response = await graphQLCreateNotice(notice) + dispatch.adminNotices.set({ saving: false }) + if (response === 'ERROR') return false + await dispatch.adminNotices.fetch() + return true + }, + + async update(params: { id: string; notice: INoticeInput }) { + dispatch.adminNotices.set({ saving: true }) + const response = await graphQLUpdateNotice(params.id, params.notice) + dispatch.adminNotices.set({ saving: false }) + if (response === 'ERROR') return false + await dispatch.adminNotices.fetch() + return true + }, + + async remove(id: string) { + dispatch.adminNotices.set({ saving: true }) + const response = await graphQLDeleteNotice(id) + dispatch.adminNotices.set({ saving: false }) + if (response === 'ERROR') return false + await dispatch.adminNotices.fetch() + return true + }, + }), + reducers: { + reset() { + return { ...defaultState } + }, + set(state, params: Partial) { + Object.keys(params).forEach(key => (state[key] = params[key])) + return state + }, + }, +}) + +function parse(all?: any[]): IAdminNotice[] { + if (!all) return [] + return all.map(n => ({ + id: n.id, + type: n.type, + title: n.title, + body: n.body || '', + preview: n.preview || undefined, + image: n.image || undefined, + link: n.link || undefined, + stage: n.stage || undefined, + language: n.language || undefined, + enabled: !!n.enabled, + from: n.from ? new Date(n.from) : undefined, + until: n.until ? new Date(n.until) : undefined, + modified: n.modified ? new Date(n.modified) : undefined, + })) +} diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 5380c0de6..514e9cbbc 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -277,6 +277,7 @@ export default createModel()({ dispatch.adminUsers.reset() dispatch.adminPartners.reset() dispatch.adminEnterpriseLicenses.reset() + dispatch.adminNotices.reset() cloudSync.reset() dispatch.accounts.set({ activeId: undefined }) diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index 0abaf0d19..a67846cb1 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -4,6 +4,7 @@ import agents from './agents' import { adminPartners } from './adminPartners' import { adminUsers } from './adminUsers' import { adminEnterpriseLicenses } from './adminEnterpriseLicenses' +import adminNotices from './adminNotices' import announcements from './announcements' import applicationTypes from './applicationTypes' import auth from './auth' @@ -39,6 +40,7 @@ export interface RootModel extends Models { adminPartners: typeof adminPartners adminUsers: typeof adminUsers adminEnterpriseLicenses: typeof adminEnterpriseLicenses + adminNotices: typeof adminNotices announcements: typeof announcements applicationTypes: typeof applicationTypes auth: typeof auth @@ -75,6 +77,7 @@ export const models: RootModel = { adminPartners, adminUsers, adminEnterpriseLicenses, + adminNotices, announcements, applicationTypes, auth, diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx new file mode 100644 index 000000000..6a9292948 --- /dev/null +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx @@ -0,0 +1,217 @@ +import React, { useState } from 'react' +import { Button, List, ListItem, MenuItem, TextField, Typography } from '@mui/material' +import { fieldSx } from '../../components/ServiceForm' +import { ListItemCheckbox } from '../../components/ListItemCheckbox' +import { Gutters } from '../../components/Gutters' +import { Notice } from '../../components/Notice' + +const NOTICE_TYPES: INoticeType[] = ['GENERIC', 'SYSTEM', 'SECURITY', 'RELEASE', 'COMMUNICATION', 'BANNER'] + +// `datetime-local` needs `YYYY-MM-DDTHH:mm` in local time — toISOString() would shift to UTC. +const toInputValue = (date?: Date | string | null) => { + if (!date) return '' + const value = typeof date === 'string' ? new Date(date) : date + const offset = value.getTimezoneOffset() * 60000 + return new Date(value.getTime() - offset).toISOString().slice(0, 16) +} + +const fromInputValue = (value: string) => (value ? new Date(value).toISOString() : null) + +const initialForm = (notice?: IAdminNotice): INoticeInput => ({ + type: notice?.type || 'BANNER', + title: notice?.title || '', + body: notice?.body || '', + preview: notice?.preview || '', + link: notice?.link || '', + stage: notice?.stage || '', + enabled: notice?.enabled ?? false, + from: notice?.from ? notice.from.toISOString() : null, + until: notice?.until ? notice.until.toISOString() : null, +}) + +type Props = { + notice?: IAdminNotice + saving?: boolean + onCancel: () => void + onSave: (notice: INoticeInput) => void +} + +export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onSave }) => { + const [form, setForm] = useState(() => initialForm(notice)) + + const isBanner = form.type === 'BANNER' + const change = (values: Partial) => setForm(f => ({ ...f, ...values })) + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + // The API preserves explicit nulls (that is how you clear a field) but ignores undefined. + // Send null rather than '' so cleared optional fields don't persist as empty strings. + const blankToNull = (value?: string | null) => (value ? value : null) + onSave({ + ...form, + preview: blankToNull(form.preview), + link: blankToNull(form.link), + stage: blankToNull(form.stage), + body: form.body || '', + } as INoticeInput) + } + + return ( +
+ + + change({ type: e.target.value as INoticeType })} + > + {NOTICE_TYPES.map(type => ( + + {type} + + ))} + + + {isBanner ? ( + <> + Shown as a persistent bar at the top of the app. Banners cannot be dismissed. + + ) : ( + 'Shown as an announcement card.' + )} + + + + + change({ title: e.target.value })} + /> + + {isBanner ? 'Bold first line of the banner.' : 'Notice heading.'} Required. Max 255 characters. + + + + + change({ preview: e.target.value })} + /> + + {isBanner ? 'Smaller second line under the title. ' : 'Short summary. '} + Leave blank for title only. Max 255 characters. + + + + {!isBanner && ( + + change({ body: e.target.value })} + /> + HTML shown on the announcement card. Not used by banners. + + )} + + + change({ link: e.target.value })} + /> + + {isBanner ? 'Adds a "Learn more" button to the banner.' : 'Optional link.'} + + + + + change({ stage: e.target.value })} + /> + + Limit to one deployment stage to test before going live. Blank targets every stage. + + + + + change({ from: fromInputValue(e.target.value) })} + /> + When the notice starts showing. Blank shows it immediately. + + + + change({ until: fromInputValue(e.target.value) })} + /> + + When the notice disappears.{' '} + {isBanner && Strongly recommended — a banner cannot be dismissed by the user.} + + + + change({ enabled: checked })} + /> + + + {isBanner && ( + + + PREVIEW + + + {form.title || 'Title'} + {form.preview ? {form.preview} : null} + + + )} + + {isBanner && !form.until && ( + + + No end date set + Without an "Until" date this banner stays up until someone disables it by hand. + + + )} + + + + + +
+ ) +} diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx new file mode 100644 index 000000000..cd1401378 --- /dev/null +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx @@ -0,0 +1,60 @@ +import React, { useEffect } from 'react' +import { useHistory, useParams } from 'react-router-dom' +import { useDispatch, useSelector } from 'react-redux' +import { Dispatch, State } from '../../store' +import { Gutters } from '../../components/Gutters' +import { ListItemBack } from '../../components/ListItemBack' +import { LoadingMessage } from '../../components/LoadingMessage' +import { Notice } from '../../components/Notice' +import { AdminNoticeForm } from './AdminNoticeForm' + +const LIST_ROUTE = '/admin/notices' + +export const AdminNoticePage: React.FC = () => { + const { noticeId } = useParams<{ noticeId?: string }>() + const history = useHistory() + const dispatch = useDispatch() + const notices = useSelector((state: State) => state.adminNotices.notices) + const saving = useSelector((state: State) => state.adminNotices.saving) + const initialized = useSelector((state: State) => state.adminNotices.initialized) + + const isNew = noticeId === 'new' + const notice = isNew ? undefined : notices.find(n => n.id === noticeId) + + // Deep linking straight to an edit URL has no list to read from yet. + useEffect(() => { + if (!initialized) dispatch.adminNotices.fetch() + }, [initialized]) + + const handleSave = async (input: INoticeInput) => { + const saved = isNew + ? await dispatch.adminNotices.create(input) + : await dispatch.adminNotices.update({ id: noticeId as string, notice: input }) + if (saved) history.push(LIST_ROUTE) + } + + if (!isNew && !notice) + return initialized ? ( + + + + Notice not found + It may have been deleted by someone else. + + + ) : ( + + ) + + return ( + + + history.push(LIST_ROUTE)} + onSave={handleSave} + /> + + ) +} diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx new file mode 100644 index 000000000..2d0703909 --- /dev/null +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useState } from 'react' +import { useHistory } from 'react-router-dom' +import { useDispatch, useSelector } from 'react-redux' +import { + Box, + Button, + Chip, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material' +import { Dispatch, State } from '../../store' +import { Container } from '../../components/Container' +import { Gutters } from '../../components/Gutters' +import { Confirm } from '../../components/Confirm' +import { Icon } from '../../components/Icon' +import { LoadingMessage } from '../../components/LoadingMessage' +import { Notice } from '../../components/Notice' + +const dateLabel = (date?: Date) => (date ? date.toLocaleString() : '—') + +// Mirrors the server's Notice.visible() filter so admins can see at a glance whether a notice is +// actually reaching users right now, rather than just whether it is enabled. +const liveNow = (notice: IAdminNotice) => { + const now = new Date() + if (!notice.enabled) return false + if (notice.from && notice.from > now) return false + if (notice.until && notice.until <= now) return false + return true +} + +export const AdminNoticesListPage: React.FC = () => { + const dispatch = useDispatch() + const notices = useSelector((state: State) => state.adminNotices.notices) + const loading = useSelector((state: State) => state.adminNotices.loading) + const initialized = useSelector((state: State) => state.adminNotices.initialized) + + const history = useHistory() + const [removeTarget, setRemoveTarget] = useState() + + useEffect(() => { + dispatch.adminNotices.fetch() + }, []) + + const handleNew = () => history.push('/admin/notices/new') + const handleEdit = (notice: IAdminNotice) => history.push(`/admin/notices/${notice.id}`) + + const handleRemove = async () => { + if (removeTarget) await dispatch.adminNotices.remove(removeTarget.id) + setRemoveTarget(undefined) + } + + if (loading && !initialized) return + + return ( + + + Notices + + + + } + > + {!notices.length ? ( + + + No notices yet + Create one to announce maintenance, an outage, or a release. + + + ) : ( + + + + + Status + Type + Title + Stage + From + Until + Actions + + + + {notices.map(notice => ( + + + {liveNow(notice) ? ( + + ) : ( + + )} + + {notice.type} + + {notice.title} + {notice.preview && ( + + {notice.preview} + + )} + + {notice.stage || 'all'} + {dateLabel(notice.from)} + + {notice.type === 'BANNER' && !notice.until ? ( + + + none + + + ) : ( + dateLabel(notice.until) + )} + + + + handleEdit(notice)}> + + + + + setRemoveTarget(notice)}> + + + + + + ))} + +
+
+ )} + + setRemoveTarget(undefined)} + action="Delete" + color="error" + > + + "{removeTarget?.title}" will be permanently deleted. This cannot be undone. + + +
+ ) +} diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index 361aef4ba..d236baa51 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -62,6 +62,8 @@ import { AdminConfirmPage } from '../pages/AdminConfirmPage' import { AdminAdminsPage } from '../pages/AdminAdminsPage/AdminAdminsPage' import { AdminPartnersPage } from '../pages/AdminPartnersPage/AdminPartnersPage' import { AdminEnterpriseLicensesListPage } from '../pages/AdminEnterpriseLicensesPage/AdminEnterpriseLicensesListPage' +import { AdminNoticesListPage } from '../pages/AdminNoticesPage/AdminNoticesListPage' +import { AdminNoticePage } from '../pages/AdminNoticesPage/AdminNoticePage' import { PartnerStatsPage } from '../pages/PartnerStatsPage/PartnerStatsPage' import browser, { getOs } from '../services/browser' import analytics from '../services/analytics' @@ -438,6 +440,16 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { + + + + + + + + + + diff --git a/frontend/src/services/graphQLMutation.ts b/frontend/src/services/graphQLMutation.ts index 9f45d5d1e..2e2234b19 100644 --- a/frontend/src/services/graphQLMutation.ts +++ b/frontend/src/services/graphQLMutation.ts @@ -725,3 +725,32 @@ export async function graphQLAdminDeleteUser(id: string, force: boolean = false) { id, force } ) } + +const NOTICE_FIELDS = ` id type title body preview image link stage language enabled from until modified ` + +export async function graphQLCreateNotice(notice: INoticeInput) { + return await graphQLBasicRequest( + ` mutation CreateNotice($notice: NoticeInput!) { + createNotice(notice: $notice) {${NOTICE_FIELDS}} + }`, + { notice } + ) +} + +export async function graphQLUpdateNotice(id: string, notice: INoticeInput) { + return await graphQLBasicRequest( + ` mutation UpdateNotice($id: String!, $notice: NoticeInput!) { + updateNotice(id: $id, notice: $notice) {${NOTICE_FIELDS}} + }`, + { id, notice } + ) +} + +export async function graphQLDeleteNotice(id: string) { + return await graphQLBasicRequest( + ` mutation DeleteNotice($id: String!) { + deleteNotice(id: $id) + }`, + { id } + ) +} diff --git a/frontend/src/services/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index 36d51c2a0..d867c5da3 100644 --- a/frontend/src/services/graphQLRequest.ts +++ b/frontend/src/services/graphQLRequest.ts @@ -967,3 +967,25 @@ export async function graphQLPartnerEntities(accountId?: string) { { accountId } ) } + +export async function graphQLAllNotices() { + return await graphQLBasicRequest( + ` query AllNotices { + allNotices { + id + type + title + body + preview + image + link + stage + language + enabled + from + until + modified + } + }` + ) +} diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 5f4f7a06c..4c167dc7c 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -188,6 +188,38 @@ declare global { type INoticeType = 'GENERIC' | 'SYSTEM' | 'RELEASE' | 'COMMUNICATION' | 'SECURITY' | 'BANNER' + // Admin-only view of a notice — includes the scheduling and targeting fields that the + // user-facing `notices` query filters on server side and therefore never returns. + type IAdminNotice = { + id: string + type: INoticeType + title: string + body: string + preview?: string + image?: string + link?: string + stage?: string + language?: string + enabled: boolean + from?: Date + until?: Date + modified?: Date + } + + type INoticeInput = { + type?: INoticeType + title?: string + body?: string + preview?: string + image?: string + link?: string + stage?: string + language?: string + enabled?: boolean + from?: Date | string | null + until?: Date | string | null + } + type IPurchase = { checkout?: boolean planId?: string From 51566632a59bd4cb81893066760007c37389b8d9 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:12:10 -0700 Subject: [PATCH 04/19] fix(admin): use standard back button header on notice detail page --- .../AdminNoticesPage/AdminNoticePage.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx index cd1401378..c65ecfd61 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx @@ -1,11 +1,15 @@ import React, { useEffect } from 'react' import { useHistory, useParams } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' +import { Box, Typography } from '@mui/material' import { Dispatch, State } from '../../store' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { IconButton } from '../../buttons/IconButton' import { Gutters } from '../../components/Gutters' -import { ListItemBack } from '../../components/ListItemBack' import { LoadingMessage } from '../../components/LoadingMessage' import { Notice } from '../../components/Notice' +import { spacing } from '../../styling' import { AdminNoticeForm } from './AdminNoticeForm' const LIST_ROUTE = '/admin/notices' @@ -26,35 +30,51 @@ export const AdminNoticePage: React.FC = () => { if (!initialized) dispatch.adminNotices.fetch() }, [initialized]) + const handleBack = () => history.push(LIST_ROUTE) + const handleSave = async (input: INoticeInput) => { const saved = isNew ? await dispatch.adminNotices.create(input) : await dispatch.adminNotices.update({ id: noticeId as string, notice: input }) - if (saved) history.push(LIST_ROUTE) + if (saved) handleBack() } + const header = ( + + + + + + {isNew ? 'New notice' : 'Edit notice'} + + + ) + if (!isNew && !notice) return initialized ? ( - - - - Notice not found - It may have been deleted by someone else. - - + + + + Notice not found + It may have been deleted by someone else. + + + ) : ( ) return ( - - - history.push(LIST_ROUTE)} - onSave={handleSave} - /> - + + + ) } From c89529433466addf12bdee8826cc37a254b6901e Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:18:45 -0700 Subject: [PATCH 05/19] fix(admin): show notices detail in a resizable side panel and use global back --- ...icePage.tsx => AdminNoticeDetailPanel.tsx} | 68 ++++++-- .../AdminNoticesPage/AdminNoticesListPage.tsx | 159 +++++------------- .../AdminNoticesPage/AdminNoticesPage.tsx | 95 +++++++++++ .../adminNoticeAttributes.tsx | 49 ++++++ frontend/src/routers/Router.tsx | 14 +- frontend/src/routers/routeParents.ts | 1 + 6 files changed, 244 insertions(+), 142 deletions(-) rename frontend/src/pages/AdminNoticesPage/{AdminNoticePage.tsx => AdminNoticeDetailPanel.tsx} (54%) create mode 100644 frontend/src/pages/AdminNoticesPage/AdminNoticesPage.tsx create mode 100644 frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticeDetailPanel.tsx similarity index 54% rename from frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx rename to frontend/src/pages/AdminNoticesPage/AdminNoticeDetailPanel.tsx index c65ecfd61..4ced9bbf3 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticePage.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticeDetailPanel.tsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux' import { Box, Typography } from '@mui/material' import { Dispatch, State } from '../../store' import { Container } from '../../components/Container' +import { Confirm } from '../../components/Confirm' import { Title } from '../../components/Title' import { IconButton } from '../../buttons/IconButton' import { Gutters } from '../../components/Gutters' @@ -14,13 +15,20 @@ import { AdminNoticeForm } from './AdminNoticeForm' const LIST_ROUTE = '/admin/notices' -export const AdminNoticePage: React.FC = () => { +type Props = { + // Only shown when the list panel is hidden — otherwise the list is already on screen and the + // global header back button covers going up. + showBackArrow?: boolean +} + +export const AdminNoticeDetailPanel: React.FC = ({ showBackArrow }) => { const { noticeId } = useParams<{ noticeId?: string }>() const history = useHistory() const dispatch = useDispatch() const notices = useSelector((state: State) => state.adminNotices.notices) const saving = useSelector((state: State) => state.adminNotices.saving) const initialized = useSelector((state: State) => state.adminNotices.initialized) + const [removeOpen, setRemoveOpen] = React.useState(false) const isNew = noticeId === 'new' const notice = isNew ? undefined : notices.find(n => n.id === noticeId) @@ -39,22 +47,34 @@ export const AdminNoticePage: React.FC = () => { if (saved) handleBack() } + const handleRemove = async () => { + setRemoveOpen(false) + if (notice && (await dispatch.adminNotices.remove(notice.id))) handleBack() + } + const header = ( - - + {showBackArrow && ( + + + + )} + + + {isNew ? 'New notice' : 'Edit notice'} + + {!isNew && notice && ( + setRemoveOpen(true)} size="md" /> + )} - - {isNew ? 'New notice' : 'Edit notice'} - ) @@ -74,7 +94,25 @@ export const AdminNoticePage: React.FC = () => { return ( - + + setRemoveOpen(false)} + action="Delete" + color="error" + > + + "{notice?.title}" will be permanently deleted. This cannot be undone. + + ) } diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx index 2d0703909..69631a8f1 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx @@ -1,71 +1,45 @@ -import React, { useEffect, useState } from 'react' -import { useHistory } from 'react-router-dom' +import React, { useEffect } from 'react' +import { useHistory, useLocation } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' -import { - Box, - Button, - Chip, - IconButton, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Tooltip, - Typography, -} from '@mui/material' +import { Box, Button, Stack, Typography } from '@mui/material' import { Dispatch, State } from '../../store' import { Container } from '../../components/Container' +import { GridList } from '../../components/GridList' +import { GridListItem } from '../../components/GridListItem' import { Gutters } from '../../components/Gutters' -import { Confirm } from '../../components/Confirm' import { Icon } from '../../components/Icon' import { LoadingMessage } from '../../components/LoadingMessage' import { Notice } from '../../components/Notice' - -const dateLabel = (date?: Date) => (date ? date.toLocaleString() : '—') - -// Mirrors the server's Notice.visible() filter so admins can see at a glance whether a notice is -// actually reaching users right now, rather than just whether it is enabled. -const liveNow = (notice: IAdminNotice) => { - const now = new Date() - if (!notice.enabled) return false - if (notice.from && notice.from > now) return false - if (notice.until && notice.until <= now) return false - return true -} +import { adminNoticeAttributes, noticeStatus } from './adminNoticeAttributes' export const AdminNoticesListPage: React.FC = () => { const dispatch = useDispatch() + const history = useHistory() + const location = useLocation() const notices = useSelector((state: State) => state.adminNotices.notices) const loading = useSelector((state: State) => state.adminNotices.loading) const initialized = useSelector((state: State) => state.adminNotices.initialized) - - const history = useHistory() - const [removeTarget, setRemoveTarget] = useState() + const columnWidths = useSelector((state: State) => state.ui.columnWidths) useEffect(() => { dispatch.adminNotices.fetch() }, []) - const handleNew = () => history.push('/admin/notices/new') - const handleEdit = (notice: IAdminNotice) => history.push(`/admin/notices/${notice.id}`) - - const handleRemove = async () => { - if (removeTarget) await dispatch.adminNotices.remove(removeTarget.id) - setRemoveTarget(undefined) - } + const required = adminNoticeAttributes.find(a => a.required) + const attributes = adminNoticeAttributes.filter(a => !a.required) if (loading && !initialized) return return ( - - Notices - @@ -80,82 +54,35 @@ export const AdminNoticesListPage: React.FC = () => { ) : ( - - - - - Status - Type - Title - Stage - From - Until - Actions - - - - {notices.map(notice => ( - - - {liveNow(notice) ? ( - - ) : ( - - )} - - {notice.type} - - {notice.title} - {notice.preview && ( - - {notice.preview} - - )} - - {notice.stage || 'all'} - {dateLabel(notice.from)} - - {notice.type === 'BANNER' && !notice.until ? ( - - - none - - - ) : ( - dateLabel(notice.until) - )} - - - - handleEdit(notice)}> - - - - - setRemoveTarget(notice)}> - - - - - + + {notices.map(notice => ( + history.push(`/admin/notices/${notice.id}`)} + selected={location.pathname.includes(`/admin/notices/${notice.id}`)} + disableGutters + icon={ + + } + required={required?.value({ notice })} + > + {attributes.map(attribute => ( + + + {attribute.value({ notice })} + + ))} - -
-
+ + ))} + )} - - setRemoveTarget(undefined)} - action="Delete" - color="error" - > - - "{removeTarget?.title}" will be permanently deleted. This cannot be undone. - -
) } diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticesPage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticesPage.tsx new file mode 100644 index 000000000..0c789cfee --- /dev/null +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticesPage.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import { useParams } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Box } from '@mui/material' +import { AdminNoticesListPage } from './AdminNoticesListPage' +import { AdminNoticeDetailPanel } from './AdminNoticeDetailPanel' +import { State } from '../../store' +import { useContainerWidth } from '../../hooks/useContainerWidth' +import { useResizablePanel } from '../../hooks/useResizablePanel' + +const MIN_WIDTH = 250 +const DEFAULT_LEFT_WIDTH = 400 + +const panelSx = { + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, +} as const + +const handleSx = (theme: import('@mui/material').Theme) => ({ + zIndex: 8, + position: 'absolute', + height: '100%', + marginLeft: '-5px', + padding: '0 3px', + cursor: 'col-resize', + '& > div': { + width: '1px', + marginLeft: '1px', + marginRight: '1px', + height: '100%', + backgroundColor: theme.palette.grayLighter.main, + transition: 'background-color 100ms 200ms, width 100ms 200ms, margin 100ms 200ms', + }, + '&:hover > div, & .active': { + width: '3px', + marginLeft: 0, + marginRight: 0, + backgroundColor: theme.palette.primary.main, + }, +}) + +export const AdminNoticesPage: React.FC = () => { + const { noticeId } = useParams<{ noticeId?: string }>() + const layout = useSelector((state: State) => state.ui.layout) + + const { containerRef } = useContainerWidth() + const leftPanel = useResizablePanel(DEFAULT_LEFT_WIDTH, containerRef, { + minWidth: MIN_WIDTH, + }) + + const maxPanels = layout.singlePanel ? 1 : 2 + const hasNoticeSelected = !!noticeId + + const showLeft = !hasNoticeSelected || maxPanels >= 2 + const showRight = hasNoticeSelected + + return ( + + + {showLeft && ( + <> + + + + + {hasNoticeSelected && ( + + + + + + )} + + )} + + {showRight && ( + + + + )} + + + ) +} diff --git a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx new file mode 100644 index 000000000..d1502142c --- /dev/null +++ b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx @@ -0,0 +1,49 @@ +import { Attribute } from '../../components/Attributes' + +export type AdminNoticeAttributeOptions = { + notice?: IAdminNotice +} + +export class AdminNoticeAttribute extends Attribute { + type: Attribute['type'] = 'MASTER' +} + +// Mirrors the server's Notice.visible() filter so admins can see at a glance whether a notice is +// actually reaching users right now, rather than just whether it is enabled. +export const noticeStatus = (notice?: IAdminNotice) => { + if (!notice) return '-' + const now = new Date() + if (!notice.enabled) return 'Disabled' + if (notice.from && notice.from > now) return 'Scheduled' + if (notice.until && notice.until <= now) return 'Expired' + return 'Live' +} + +export const adminNoticeAttributes: AdminNoticeAttribute[] = [ + new AdminNoticeAttribute({ + id: 'title', + label: 'Title', + defaultWidth: 260, + required: true, + value: ({ notice }: AdminNoticeAttributeOptions) => notice?.title || '-', + }), + new AdminNoticeAttribute({ + id: 'status', + label: 'Status', + defaultWidth: 100, + value: ({ notice }: AdminNoticeAttributeOptions) => noticeStatus(notice), + }), + new AdminNoticeAttribute({ + id: 'type', + label: 'Type', + defaultWidth: 130, + value: ({ notice }: AdminNoticeAttributeOptions) => notice?.type || '-', + }), + new AdminNoticeAttribute({ + id: 'until', + label: 'Until', + defaultWidth: 170, + value: ({ notice }: AdminNoticeAttributeOptions) => + notice?.until ? notice.until.toLocaleString() : notice?.type === 'BANNER' ? 'none — set one' : '—', + }), +] diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index d236baa51..44f82fa26 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -62,8 +62,7 @@ import { AdminConfirmPage } from '../pages/AdminConfirmPage' import { AdminAdminsPage } from '../pages/AdminAdminsPage/AdminAdminsPage' import { AdminPartnersPage } from '../pages/AdminPartnersPage/AdminPartnersPage' import { AdminEnterpriseLicensesListPage } from '../pages/AdminEnterpriseLicensesPage/AdminEnterpriseLicensesListPage' -import { AdminNoticesListPage } from '../pages/AdminNoticesPage/AdminNoticesListPage' -import { AdminNoticePage } from '../pages/AdminNoticesPage/AdminNoticePage' +import { AdminNoticesPage } from '../pages/AdminNoticesPage/AdminNoticesPage' import { PartnerStatsPage } from '../pages/PartnerStatsPage/PartnerStatsPage' import browser, { getOs } from '../services/browser' import analytics from '../services/analytics' @@ -440,15 +439,8 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { - - - - - - - - - + + diff --git a/frontend/src/routers/routeParents.ts b/frontend/src/routers/routeParents.ts index 8d898d21e..f8fad8bc1 100644 --- a/frontend/src/routers/routeParents.ts +++ b/frontend/src/routers/routeParents.ts @@ -118,6 +118,7 @@ const ROUTE_PARENTS: [string, string][] = [ ['/admin/users/:userId/devices', '/admin/users/:userId'], ['/admin/users/:userId', '/admin/users'], ['/admin/partners/:partnerId', '/admin/partners'], + ['/admin/notices/:noticeId', '/admin/notices'], // Onboard ['/onboard/:platform/scanning', '/onboard/:platform'], From baeae7f92c0fdfb0060e5380e2a6a28bfa6636a3 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:24:10 -0700 Subject: [PATCH 06/19] feat(admin): restore status chips and detail columns in notices list, document stage values --- .../AdminNoticesPage/AdminNoticeForm.tsx | 4 +- .../AdminNoticesPage/AdminNoticesListPage.tsx | 8 ++- .../adminNoticeAttributes.tsx | 68 ++++++++++++++++--- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx index 6a9292948..44c64a0d1 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx @@ -146,7 +146,9 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS onChange={e => change({ stage: e.target.value })} /> - Limit to one deployment stage to test before going live. Blank targets every stage. + Limit to one API deployment stage to test before going live. Must match the stage exactly — one of{' '} + prod, beta, dev, or a personal stage (alt, benoit, evan,{' '} + jamie). Blank targets every stage. diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx index 69631a8f1..09836f61c 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx @@ -54,7 +54,13 @@ export const AdminNoticesListPage: React.FC = () => { ) : ( - + {notices.map(notice => ( type: Attribute['type'] = 'MASTER' } +export type NoticeStatus = 'Live' | 'Scheduled' | 'Expired' | 'Disabled' + // Mirrors the server's Notice.visible() filter so admins can see at a glance whether a notice is // actually reaching users right now, rather than just whether it is enabled. -export const noticeStatus = (notice?: IAdminNotice) => { - if (!notice) return '-' +export const noticeStatus = (notice: IAdminNotice): NoticeStatus => { const now = new Date() if (!notice.enabled) return 'Disabled' if (notice.from && notice.from > now) return 'Scheduled' @@ -19,31 +23,77 @@ export const noticeStatus = (notice?: IAdminNotice) => { return 'Live' } +const dateLabel = (date?: Date) => (date ? date.toLocaleString() : '—') + export const adminNoticeAttributes: AdminNoticeAttribute[] = [ new AdminNoticeAttribute({ id: 'title', label: 'Title', - defaultWidth: 260, + defaultWidth: 240, required: true, - value: ({ notice }: AdminNoticeAttributeOptions) => notice?.title || '-', + value: ({ notice }: AdminNoticeAttributeOptions) => + notice && ( + + + {notice.title} + + {notice.preview && ( + + {notice.preview} + + )} + + ), }), new AdminNoticeAttribute({ id: 'status', label: 'Status', - defaultWidth: 100, - value: ({ notice }: AdminNoticeAttributeOptions) => noticeStatus(notice), + defaultWidth: 110, + value: ({ notice }: AdminNoticeAttributeOptions) => { + if (!notice) return null + const status = noticeStatus(notice) + return status === 'Live' ? ( + + ) : ( + + ) + }, }), new AdminNoticeAttribute({ id: 'type', label: 'Type', defaultWidth: 130, - value: ({ notice }: AdminNoticeAttributeOptions) => notice?.type || '-', + value: ({ notice }: AdminNoticeAttributeOptions) => notice?.type || '—', + }), + new AdminNoticeAttribute({ + id: 'stage', + label: 'Stage', + defaultWidth: 100, + value: ({ notice }: AdminNoticeAttributeOptions) => notice?.stage || 'all', + }), + new AdminNoticeAttribute({ + id: 'from', + label: 'From', + defaultWidth: 170, + value: ({ notice }: AdminNoticeAttributeOptions) => dateLabel(notice?.from), }), new AdminNoticeAttribute({ id: 'until', label: 'Until', defaultWidth: 170, - value: ({ notice }: AdminNoticeAttributeOptions) => - notice?.until ? notice.until.toLocaleString() : notice?.type === 'BANNER' ? 'none — set one' : '—', + value: ({ notice }: AdminNoticeAttributeOptions) => { + if (!notice) return '—' + // A banner with no end date can only be taken down by hand — call it out. + if (notice.type === 'BANNER' && !notice.until) + return ( + + + + none + + + ) + return dateLabel(notice.until) + }, }), ] From 73dd0bd60b2d9e06ccb1d844685bb7dbdc13ea5b Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:26:36 -0700 Subject: [PATCH 07/19] refactor(admin): wrap all admin routes in a single Panel for consistent global nav --- frontend/src/routers/Router.tsx | 52 +++++++++++++++------------------ 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index 44f82fa26..f37d60233 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -410,40 +410,34 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { {/* Admin Routes */} - - - - - - + {/* Panel (and its Header) wraps the whole admin section so every admin page gets the + global nav — rather than each route remembering to wrap itself. */} + + + + + + - - - - + + - - - - + + - - - - + + - - - - + + - - - - - - - + + + + + + + From 7d31af3739a33946a30eeeb0ceb6427605953539 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:34:39 -0700 Subject: [PATCH 08/19] fix(admin): default notices row height with subtitle as its own column --- .../AdminNoticesPage/AdminNoticesListPage.tsx | 8 +----- .../adminNoticeAttributes.tsx | 28 +++++++++---------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx index 09836f61c..69631a8f1 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx @@ -54,13 +54,7 @@ export const AdminNoticesListPage: React.FC = () => { ) : ( - + {notices.map(notice => ( - notice && ( - - - {notice.title} - - {notice.preview && ( - - {notice.preview} - - )} - - ), + // The master column has no ellipsis clamp of its own (unlike `.attribute` columns), so a long + // title would wrap and make the row taller than the rest. + value: ({ notice }: AdminNoticeAttributeOptions) => ( + {notice?.title || '—'} + ), + }), + new AdminNoticeAttribute({ + id: 'subtitle', + label: 'Subtitle', + defaultWidth: 240, + value: ({ notice }: AdminNoticeAttributeOptions) => notice?.preview || '—', }), new AdminNoticeAttribute({ id: 'status', From f8e388a81efebe0e1dbca8b8e120e34e0ff0e566 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:43:52 -0700 Subject: [PATCH 09/19] fix(admin): label the notice preview field Subtitle consistently --- frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx index 44c64a0d1..e337f39d6 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx @@ -100,14 +100,14 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS change({ preview: e.target.value })} /> - {isBanner ? 'Smaller second line under the title. ' : 'Short summary. '} + {isBanner ? 'Smaller second line under the banner title. ' : 'Short summary shown under the title. '} Leave blank for title only. Max 255 characters. @@ -190,7 +190,7 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS {isBanner && ( - PREVIEW + BANNER PREVIEW {form.title || 'Title'} From 53400c0f462525fe3365f9baaa149ecc608d792d Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:48:47 -0700 Subject: [PATCH 10/19] feat(admin): add notice image field and live card preview for non-banner types --- .../AdminNoticesPage/AdminNoticeForm.tsx | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx index e337f39d6..910edab9e 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react' -import { Button, List, ListItem, MenuItem, TextField, Typography } from '@mui/material' +import { Box, Button, List, ListItem, MenuItem, TextField, Typography } from '@mui/material' import { fieldSx } from '../../components/ServiceForm' import { ListItemCheckbox } from '../../components/ListItemCheckbox' +import { AnnouncementCard } from '../../components/AnnouncementCard' import { Gutters } from '../../components/Gutters' import { Notice } from '../../components/Notice' @@ -22,6 +23,7 @@ const initialForm = (notice?: IAdminNotice): INoticeInput => ({ title: notice?.title || '', body: notice?.body || '', preview: notice?.preview || '', + image: notice?.image || '', link: notice?.link || '', stage: notice?.stage || '', enabled: notice?.enabled ?? false, @@ -42,6 +44,18 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS const isBanner = form.type === 'BANNER' const change = (values: Partial) => setForm(f => ({ ...f, ...values })) + // Shape the in-progress form as an IAnnouncement so the real card component can render it. + const previewAnnouncement: IAnnouncement = { + id: 'preview', + type: (form.type || 'GENERIC') as INoticeType, + title: form.title || 'Title', + body: form.body || '', + preview: form.preview || undefined, + image: form.image || '', + link: form.link || '', + modified: new Date(), + } + const handleSubmit = (event: React.FormEvent) => { event.preventDefault() // The API preserves explicit nulls (that is how you clear a field) but ignores undefined. @@ -50,6 +64,7 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS onSave({ ...form, preview: blankToNull(form.preview), + image: blankToNull(form.image), link: blankToNull(form.link), stage: blankToNull(form.stage), body: form.body || '', @@ -126,6 +141,22 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS )} + {!isBanner && ( + + change({ image: e.target.value })} + /> + + URL of a banner image shown across the top of the announcement card. Not used by banners.{' '} + Max 255 characters. + + + )} + = ({ notice, saving, onCancel, onS /> - {isBanner && ( - - - BANNER PREVIEW - + + + {isBanner ? 'BANNER PREVIEW' : 'CARD PREVIEW'} + + {isBanner ? ( {form.title || 'Title'} {form.preview ? {form.preview} : null} - - )} + ) : ( + // Reuse the real card so the preview can't drift from what users actually see. + // `hideMarkReadAction` suppresses the NEW button, which would otherwise mark a + // fabricated preview id as read. + + + + )} + {isBanner && !form.until && ( From 2daefd0330c6c16e31f15c69199935cea50a8cf5 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:54:52 -0700 Subject: [PATCH 11/19] refactor(announcements): use body for banner text and drop the unused preview field --- .../src/components/AnnouncementBanner.tsx | 8 +- frontend/src/models/announcements.ts | 2 - .../AdminNoticesPage/AdminNoticeForm.tsx | 38 ++--- .../adminNoticeAttributes.tsx | 9 +- frontend/src/preview.tsx | 161 ++++++++++++++++++ frontend/src/types.d.ts | 1 - 6 files changed, 185 insertions(+), 34 deletions(-) create mode 100644 frontend/src/preview.tsx diff --git a/frontend/src/components/AnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner.tsx index 07a8d50e5..16db163d1 100644 --- a/frontend/src/components/AnnouncementBanner.tsx +++ b/frontend/src/components/AnnouncementBanner.tsx @@ -39,12 +39,12 @@ export const AnnouncementBanner: React.FC = () => { > {/* Bind the two lines structurally rather than relying on the notice author to hand-write - ``/`` inside `preview`. `title` is NOT NULL so a banner always has a - heading, and both columns are varchar(255) — so this spends none of that budget on - markup. Notice styles `strong` as the title and `em` as a smaller block subtitle. + ``/``. `title` is NOT NULL so a banner always has a heading, and `body` + supplies the smaller line. Notice styles `strong` as the title and `em` as a smaller + block subtitle. */} {banner.title} - {banner.preview && } + {banner.body && } ))} diff --git a/frontend/src/models/announcements.ts b/frontend/src/models/announcements.ts index 922150a81..7271cb251 100644 --- a/frontend/src/models/announcements.ts +++ b/frontend/src/models/announcements.ts @@ -23,7 +23,6 @@ export default createModel()({ id title body - preview image link type @@ -49,7 +48,6 @@ export default createModel()({ id: n.id, title: n.title, body: n.body, - preview: n.preview, image: n.image, link: n.link, type: n.type, diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx index 910edab9e..e35960e0f 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx @@ -22,7 +22,6 @@ const initialForm = (notice?: IAdminNotice): INoticeInput => ({ type: notice?.type || 'BANNER', title: notice?.title || '', body: notice?.body || '', - preview: notice?.preview || '', image: notice?.image || '', link: notice?.link || '', stage: notice?.stage || '', @@ -50,7 +49,6 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS type: (form.type || 'GENERIC') as INoticeType, title: form.title || 'Title', body: form.body || '', - preview: form.preview || undefined, image: form.image || '', link: form.link || '', modified: new Date(), @@ -63,7 +61,6 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS const blankToNull = (value?: string | null) => (value ? value : null) onSave({ ...form, - preview: blankToNull(form.preview), image: blankToNull(form.image), link: blankToNull(form.link), stage: blankToNull(form.stage), @@ -115,32 +112,25 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS change({ preview: e.target.value })} + value={form.body || ''} + onChange={e => change({ body: e.target.value })} /> - {isBanner ? 'Smaller second line under the banner title. ' : 'Short summary shown under the title. '} - Leave blank for title only. Max 255 characters. + {isBanner ? ( + <> + Smaller second line under the banner title. Keep it to a single short sentence — it renders + inline in a one-line bar, so long copy or block HTML will look wrong. + + ) : ( + 'HTML shown on the announcement card.' + )} - {!isBanner && ( - - change({ body: e.target.value })} - /> - HTML shown on the announcement card. Not used by banners. - - )} - {!isBanner && ( = ({ notice, saving, onCancel, onS {isBanner ? ( {form.title || 'Title'} - {form.preview ? {form.preview} : null} + {form.body ? : null} ) : ( // Reuse the real card so the preview can't drift from what users actually see. diff --git a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx index b7ccbc0fc..c9a429a33 100644 --- a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx +++ b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx @@ -25,6 +25,8 @@ export const noticeStatus = (notice: IAdminNotice): NoticeStatus => { const dateLabel = (date?: Date) => (date ? date.toLocaleString() : '—') +const plainText = (html?: string) => html?.replace(/<[^>]*>/g, '').trim() + export const adminNoticeAttributes: AdminNoticeAttribute[] = [ new AdminNoticeAttribute({ id: 'title', @@ -38,10 +40,11 @@ export const adminNoticeAttributes: AdminNoticeAttribute[] = [ ), }), new AdminNoticeAttribute({ - id: 'subtitle', - label: 'Subtitle', + id: 'body', + label: 'Body', defaultWidth: 240, - value: ({ notice }: AdminNoticeAttributeOptions) => notice?.preview || '—', + // Body is HTML — strip tags so the cell shows readable text rather than markup. + value: ({ notice }: AdminNoticeAttributeOptions) => plainText(notice?.body) || '—', }), new AdminNoticeAttribute({ id: 'status', diff --git a/frontend/src/preview.tsx b/frontend/src/preview.tsx new file mode 100644 index 000000000..0d26769c3 --- /dev/null +++ b/frontend/src/preview.tsx @@ -0,0 +1,161 @@ +// TEMPORARY design-review harness for AnnouncementBanner — delete before committing. +import './polyfills' +import React from 'react' +import { store, persistor } from './store' +import { createRoot } from 'react-dom/client' +import { Box, Button, CssBaseline, Typography } from '@mui/material' +import { Provider, useDispatch, useSelector } from 'react-redux' +import { Layout } from './components/Layout' +import { AnnouncementBanner } from './components/AnnouncementBanner' +import { MemoryRouter, Route } from 'react-router-dom' +import { AdminNoticesPage } from './pages/AdminNoticesPage/AdminNoticesPage' +import { Panel } from './components/Panel' +import { Dispatch, State } from './store' +import './initializeCommon' + +const now = Date.now() +const hours = (n: number) => new Date(now + n * 60 * 60 * 1000) + +const banners: IAnnouncement[] = [ + { + id: 'banner-outage', + type: 'BANNER', + title: 'Service disruption', + body: 'Connections may fail to start. We are investigating.', + image: '', + link: 'https://status.remote.it', + modified: hours(-1), + until: hours(4), + }, + { + id: 'banner-maintenance', + type: 'BANNER', + title: 'Scheduled maintenance Saturday 02:00–04:00 UTC', + body: 'Device connections will be briefly interrupted. No action is required.', + image: '', + link: '', + modified: hours(-2), + until: hours(48), + }, + { + id: 'banner-plain', + type: 'BANNER', + title: 'Title only — no preview set, so no subtitle line', + body: '', + image: '', + link: '', + modified: hours(-3), + until: hours(8), + }, + { + // Expired: proves the client-side `until` check hides it even though it is in the store + id: 'banner-expired', + type: 'BANNER', + title: 'EXPIRED — should never render', + body: '', + image: '', + link: '', + modified: hours(-72), + until: hours(-1), + }, +] + +// Seed once now, then AGAIN after redux-persist finishes rehydrating. Rehydration is async and +// overwrites the announcements slice wholesale, so a browser with real persisted state (i.e. one +// that has actually used the local app) would otherwise clobber these mocks and render nothing. +const adminNotices: IAdminNotice[] = [ + { id: 'n1', type: 'BANNER', title: 'Service disruption', body: 'Connections may fail to start.', + enabled: true, from: hours(-1), until: hours(4), modified: hours(-1) }, + { id: 'n2', type: 'BANNER', title: 'Scheduled maintenance', body: 'Brief interruption expected.', + enabled: true, from: hours(24), until: hours(28), modified: hours(-2) }, + { id: 'n3', type: 'BANNER', title: 'Banner with no end date', body: '', enabled: true, modified: hours(-3) }, + { id: 'n4', type: 'SYSTEM', title: 'Old release note', + enabled: true, until: hours(-2), modified: hours(-72), stage: 'jamie', + image: 'https://placehold.co/600x150/0096e7/fff?text=Notice+Image', + body: '

Full HTML body copy for the card.

' }, + { id: 'n5', type: 'COMMUNICATION', title: 'Draft announcement', body: '', enabled: false, modified: hours(-5) }, +] + +const seed = () => { + store.dispatch.announcements.set({ all: banners }) + store.dispatch.adminNotices.set({ notices: adminNotices, initialized: true, loading: false }) +} + +seed() + +if (persistor.getState().bootstrapped) seed() +else { + const unsubscribe = persistor.subscribe(() => { + if (!persistor.getState().bootstrapped) return + seed() + unsubscribe() + }) +} + +const PanelWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const layout = useSelector((state: State) => state.ui.layout) + return {children} +} + +const Diagnostics: React.FC = () => { + const all = useSelector((state: State) => state.announcements.all) + const bannerCount = all.filter(a => a.type === 'BANNER').length + return ( + + store.announcements.all = {all.length} — of which BANNER = {bannerCount} (expect 4, one expired → 3 render) + + ) +} + +const ThemeToggle: React.FC = () => { + const dispatch = useDispatch() + return ( + + ) +} + +const LightToggle: React.FC = () => { + const dispatch = useDispatch() + return ( + + ) +} + +const root = createRoot(document.getElementById('root')!) +root.render( + + + + + + + + + + + + + + + + + Page content below the banner + + + Three banners should render (outage w/ link, maintenance, plain title). The fourth is expired and must not + appear. + + + + + + + + + + +) diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 4c167dc7c..c47215acd 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -180,7 +180,6 @@ declare global { link: string image: string body: string | React.ReactNode - preview?: string modified?: Date until?: Date read?: Date From 619b58e9f76015a47df4ad68792e1a1f68f7b482 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:55:02 -0700 Subject: [PATCH 12/19] chore: remove temporary preview harness from version control --- frontend/src/preview.tsx | 161 --------------------------------------- 1 file changed, 161 deletions(-) delete mode 100644 frontend/src/preview.tsx diff --git a/frontend/src/preview.tsx b/frontend/src/preview.tsx deleted file mode 100644 index 0d26769c3..000000000 --- a/frontend/src/preview.tsx +++ /dev/null @@ -1,161 +0,0 @@ -// TEMPORARY design-review harness for AnnouncementBanner — delete before committing. -import './polyfills' -import React from 'react' -import { store, persistor } from './store' -import { createRoot } from 'react-dom/client' -import { Box, Button, CssBaseline, Typography } from '@mui/material' -import { Provider, useDispatch, useSelector } from 'react-redux' -import { Layout } from './components/Layout' -import { AnnouncementBanner } from './components/AnnouncementBanner' -import { MemoryRouter, Route } from 'react-router-dom' -import { AdminNoticesPage } from './pages/AdminNoticesPage/AdminNoticesPage' -import { Panel } from './components/Panel' -import { Dispatch, State } from './store' -import './initializeCommon' - -const now = Date.now() -const hours = (n: number) => new Date(now + n * 60 * 60 * 1000) - -const banners: IAnnouncement[] = [ - { - id: 'banner-outage', - type: 'BANNER', - title: 'Service disruption', - body: 'Connections may fail to start. We are investigating.', - image: '', - link: 'https://status.remote.it', - modified: hours(-1), - until: hours(4), - }, - { - id: 'banner-maintenance', - type: 'BANNER', - title: 'Scheduled maintenance Saturday 02:00–04:00 UTC', - body: 'Device connections will be briefly interrupted. No action is required.', - image: '', - link: '', - modified: hours(-2), - until: hours(48), - }, - { - id: 'banner-plain', - type: 'BANNER', - title: 'Title only — no preview set, so no subtitle line', - body: '', - image: '', - link: '', - modified: hours(-3), - until: hours(8), - }, - { - // Expired: proves the client-side `until` check hides it even though it is in the store - id: 'banner-expired', - type: 'BANNER', - title: 'EXPIRED — should never render', - body: '', - image: '', - link: '', - modified: hours(-72), - until: hours(-1), - }, -] - -// Seed once now, then AGAIN after redux-persist finishes rehydrating. Rehydration is async and -// overwrites the announcements slice wholesale, so a browser with real persisted state (i.e. one -// that has actually used the local app) would otherwise clobber these mocks and render nothing. -const adminNotices: IAdminNotice[] = [ - { id: 'n1', type: 'BANNER', title: 'Service disruption', body: 'Connections may fail to start.', - enabled: true, from: hours(-1), until: hours(4), modified: hours(-1) }, - { id: 'n2', type: 'BANNER', title: 'Scheduled maintenance', body: 'Brief interruption expected.', - enabled: true, from: hours(24), until: hours(28), modified: hours(-2) }, - { id: 'n3', type: 'BANNER', title: 'Banner with no end date', body: '', enabled: true, modified: hours(-3) }, - { id: 'n4', type: 'SYSTEM', title: 'Old release note', - enabled: true, until: hours(-2), modified: hours(-72), stage: 'jamie', - image: 'https://placehold.co/600x150/0096e7/fff?text=Notice+Image', - body: '

Full HTML body copy for the card.

' }, - { id: 'n5', type: 'COMMUNICATION', title: 'Draft announcement', body: '', enabled: false, modified: hours(-5) }, -] - -const seed = () => { - store.dispatch.announcements.set({ all: banners }) - store.dispatch.adminNotices.set({ notices: adminNotices, initialized: true, loading: false }) -} - -seed() - -if (persistor.getState().bootstrapped) seed() -else { - const unsubscribe = persistor.subscribe(() => { - if (!persistor.getState().bootstrapped) return - seed() - unsubscribe() - }) -} - -const PanelWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const layout = useSelector((state: State) => state.ui.layout) - return {children} -} - -const Diagnostics: React.FC = () => { - const all = useSelector((state: State) => state.announcements.all) - const bannerCount = all.filter(a => a.type === 'BANNER').length - return ( - - store.announcements.all = {all.length} — of which BANNER = {bannerCount} (expect 4, one expired → 3 render) - - ) -} - -const ThemeToggle: React.FC = () => { - const dispatch = useDispatch() - return ( - - ) -} - -const LightToggle: React.FC = () => { - const dispatch = useDispatch() - return ( - - ) -} - -const root = createRoot(document.getElementById('root')!) -root.render( - - - - - - - - - - - - - - - - - Page content below the banner - - - Three banners should render (outage w/ link, maintenance, plain title). The fourth is expired and must not - appear. - - - - - - - - - - -) From b050aca1a2ec9a43094b7b0df42b26e9d9fe75a6 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 13:57:07 -0700 Subject: [PATCH 13/19] fix(admin): refresh the notices list from the global refresh button --- frontend/src/buttons/RefreshButton/RefreshButton.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/buttons/RefreshButton/RefreshButton.tsx b/frontend/src/buttons/RefreshButton/RefreshButton.tsx index 653cfd282..8577c7f32 100644 --- a/frontend/src/buttons/RefreshButton/RefreshButton.tsx +++ b/frontend/src/buttons/RefreshButton/RefreshButton.tsx @@ -36,6 +36,7 @@ export const RefreshButton: React.FC = props => { const adminUsersPage = useRouteMatch('/admin/users') const adminPartnersPage = useRouteMatch('/admin/partners') const adminEnterpriseLicensesPage = useRouteMatch('/admin/enterprise-licenses') + const adminNoticesPage = useRouteMatch('/admin/notices') const scriptingPage = useRouteMatch(['/script', '/scripts', '/runs']) const runsPage = useRouteMatch<{ fileID?: string }>('/runs/:fileID?') const scriptPage = useRouteMatch('/script') @@ -127,6 +128,11 @@ export const RefreshButton: React.FC = props => { } else if (adminEnterpriseLicensesPage) { title = 'Refresh enterprise customers' methods.push(async () => await dispatch.adminEnterpriseLicenses.fetch()) + + // admin notices pages + } else if (adminNoticesPage) { + title = 'Refresh notices' + methods.push(async () => await dispatch.adminNotices.fetch()) } const refresh = async () => { From 0fb703ace6e4ffd14d7aea2e7ae7fdc1f7b8bf09 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 14:18:19 -0700 Subject: [PATCH 14/19] feat(admin): group notices by status with newest edits first --- .../pages/AdminNoticesPage/AdminNoticesListPage.tsx | 10 ++++++---- .../pages/AdminNoticesPage/adminNoticeAttributes.tsx | 12 ++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx index 69631a8f1..a1bec6133 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useMemo } from 'react' import { useHistory, useLocation } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' import { Box, Button, Stack, Typography } from '@mui/material' @@ -10,7 +10,7 @@ import { Gutters } from '../../components/Gutters' import { Icon } from '../../components/Icon' import { LoadingMessage } from '../../components/LoadingMessage' import { Notice } from '../../components/Notice' -import { adminNoticeAttributes, noticeStatus } from './adminNoticeAttributes' +import { adminNoticeAttributes, noticeStatus, sortNotices } from './adminNoticeAttributes' export const AdminNoticesListPage: React.FC = () => { const dispatch = useDispatch() @@ -25,6 +25,8 @@ export const AdminNoticesListPage: React.FC = () => { dispatch.adminNotices.fetch() }, []) + const sorted = useMemo(() => sortNotices(notices), [notices]) + const required = adminNoticeAttributes.find(a => a.required) const attributes = adminNoticeAttributes.filter(a => !a.required) @@ -46,7 +48,7 @@ export const AdminNoticesListPage: React.FC = () => { } > - {!notices.length ? ( + {!sorted.length ? ( No notices yet @@ -55,7 +57,7 @@ export const AdminNoticesListPage: React.FC = () => { ) : ( - {notices.map(notice => ( + {sorted.map(notice => ( history.push(`/admin/notices/${notice.id}`)} diff --git a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx index c9a429a33..fe468958d 100644 --- a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx +++ b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx @@ -23,6 +23,18 @@ export const noticeStatus = (notice: IAdminNotice): NoticeStatus => { return 'Live' } +// Surface what is reaching users right now. The API returns `modified DESC`, which answers "what +// did I edit recently?" but scatters live notices through the list — so group by status here and +// keep most-recently-edited first within each group. +const STATUS_ORDER: Record = { Live: 0, Scheduled: 1, Disabled: 2, Expired: 3 } + +export const sortNotices = (notices: IAdminNotice[]): IAdminNotice[] => + [...notices].sort((a, b) => { + const byStatus = STATUS_ORDER[noticeStatus(a)] - STATUS_ORDER[noticeStatus(b)] + if (byStatus) return byStatus + return (b.modified?.getTime() || 0) - (a.modified?.getTime() || 0) + }) + const dateLabel = (date?: Date) => (date ? date.toLocaleString() : '—') const plainText = (html?: string) => html?.replace(/<[^>]*>/g, '').trim() From 1c8f9509534d3382294d9ad895c100341aba96d0 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 14:24:13 -0700 Subject: [PATCH 15/19] fix(admin): use the service enable checkbox pattern for notices --- frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx index e35960e0f..659167698 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx @@ -202,8 +202,13 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS + Disabling hides the notice from everyone, whatever the dates say.  + Notice is{form.enabled ? ' enabled' : ' disabled'} + + } onClick={checked => change({ enabled: checked })} /> From a60217cb4464125fb5f838fecc2022828caddd48 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 14:29:00 -0700 Subject: [PATCH 16/19] fix(announcements): use info severity for the banner --- frontend/src/components/AnnouncementBanner.tsx | 2 +- frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/AnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner.tsx index 16db163d1..59c2f56c7 100644 --- a/frontend/src/components/AnnouncementBanner.tsx +++ b/frontend/src/components/AnnouncementBanner.tsx @@ -21,7 +21,7 @@ export const AnnouncementBanner: React.FC = () => { {active.map(banner => ( = ({ notice, saving, onCancel, onS {isBanner ? 'BANNER PREVIEW' : 'CARD PREVIEW'} {isBanner ? ( - + {form.title || 'Title'} {form.body ? : null} From edd2c24c7a869d438797cde2a84e560c26fa23dd Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 14:31:20 -0700 Subject: [PATCH 17/19] feat(admin): give each notice type its own list icon --- .../pages/AdminNoticesPage/AdminNoticesListPage.tsx | 4 ++-- .../AdminNoticesPage/adminNoticeAttributes.tsx | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx index a1bec6133..132a64c90 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticesListPage.tsx @@ -10,7 +10,7 @@ import { Gutters } from '../../components/Gutters' import { Icon } from '../../components/Icon' import { LoadingMessage } from '../../components/LoadingMessage' import { Notice } from '../../components/Notice' -import { adminNoticeAttributes, noticeStatus, sortNotices } from './adminNoticeAttributes' +import { adminNoticeAttributes, noticeIcon, noticeStatus, sortNotices } from './adminNoticeAttributes' export const AdminNoticesListPage: React.FC = () => { const dispatch = useDispatch() @@ -65,7 +65,7 @@ export const AdminNoticesListPage: React.FC = () => { disableGutters icon={ diff --git a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx index fe468958d..ca57e0ec3 100644 --- a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx +++ b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx @@ -35,6 +35,19 @@ export const sortNotices = (notices: IAdminNotice[]): IAdminNotice[] => return (b.modified?.getTime() || 0) - (a.modified?.getTime() || 0) }) +// One icon per notice type so the list is scannable at a glance. Names are all already in use +// elsewhere in the app — an unknown name renders as a blank glyph rather than failing loudly. +const NOTICE_ICONS: Record = { + GENERIC: 'circle-info', // "Notice" + SYSTEM: 'wave-pulse', // "System Update" — maintenance and service health + SECURITY: 'shield', // "Security Notice" + RELEASE: 'sparkles', // "Release Note" — something new + COMMUNICATION: 'comments', // "Announcement" + BANNER: 'bullhorn', // persistent top bar +} + +export const noticeIcon = (type?: INoticeType) => (type && NOTICE_ICONS[type]) || NOTICE_ICONS.GENERIC + const dateLabel = (date?: Date) => (date ? date.toLocaleString() : '—') const plainText = (html?: string) => html?.replace(/<[^>]*>/g, '').trim() From ff7d1004dd1a96ce6759b645f46c463e32fe3222 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 14:34:00 -0700 Subject: [PATCH 18/19] feat(announcements): add warning and danger banner types --- .../src/components/AnnouncementBanner.tsx | 3 ++- frontend/src/helpers/noticeHelper.ts | 19 +++++++++++++++++++ .../AdminNoticesPage/AdminNoticeForm.tsx | 16 +++++++++++++--- .../adminNoticeAttributes.tsx | 5 ++++- frontend/src/selectors/announcements.ts | 3 ++- frontend/src/types.d.ts | 10 +++++++++- 6 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 frontend/src/helpers/noticeHelper.ts diff --git a/frontend/src/components/AnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner.tsx index 59c2f56c7..69f14d9ad 100644 --- a/frontend/src/components/AnnouncementBanner.tsx +++ b/frontend/src/components/AnnouncementBanner.tsx @@ -4,6 +4,7 @@ import { Box, Button } from '@mui/material' import { State } from '../store' import { selectBannerAnnouncements } from '../selectors/announcements' import { Notice } from './Notice' +import { bannerSeverity } from '../helpers/noticeHelper' export const AnnouncementBanner: React.FC = () => { const banners = useSelector((state: State) => selectBannerAnnouncements(state)) @@ -21,7 +22,7 @@ export const AnnouncementBanner: React.FC = () => { {active.map(banner => ( !!type && BANNER_TYPES.includes(type) + +export const bannerSeverity = (type?: INoticeType): NoticeProps['severity'] => { + switch (type) { + case 'BANNER_DANGER': + return 'error' + case 'BANNER_WARN': + return 'warning' + default: + return 'info' + } +} diff --git a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx index cbb1fba16..e5bbd1e19 100644 --- a/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx +++ b/frontend/src/pages/AdminNoticesPage/AdminNoticeForm.tsx @@ -5,8 +5,18 @@ import { ListItemCheckbox } from '../../components/ListItemCheckbox' import { AnnouncementCard } from '../../components/AnnouncementCard' import { Gutters } from '../../components/Gutters' import { Notice } from '../../components/Notice' +import { bannerSeverity, isBannerType } from '../../helpers/noticeHelper' -const NOTICE_TYPES: INoticeType[] = ['GENERIC', 'SYSTEM', 'SECURITY', 'RELEASE', 'COMMUNICATION', 'BANNER'] +const NOTICE_TYPES: INoticeType[] = [ + 'GENERIC', + 'SYSTEM', + 'SECURITY', + 'RELEASE', + 'COMMUNICATION', + 'BANNER', + 'BANNER_WARN', + 'BANNER_DANGER', +] // `datetime-local` needs `YYYY-MM-DDTHH:mm` in local time — toISOString() would shift to UTC. const toInputValue = (date?: Date | string | null) => { @@ -40,7 +50,7 @@ type Props = { export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onSave }) => { const [form, setForm] = useState(() => initialForm(notice)) - const isBanner = form.type === 'BANNER' + const isBanner = isBannerType(form.type) const change = (values: Partial) => setForm(f => ({ ...f, ...values })) // Shape the in-progress form as an IAnnouncement so the real card component can render it. @@ -218,7 +228,7 @@ export const AdminNoticeForm: React.FC = ({ notice, saving, onCancel, onS {isBanner ? 'BANNER PREVIEW' : 'CARD PREVIEW'} {isBanner ? ( - + {form.title || 'Title'} {form.body ? : null} diff --git a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx index ca57e0ec3..2822dce77 100644 --- a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx +++ b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Box, Chip, Tooltip } from '@mui/material' import { Attribute } from '../../components/Attributes' import { Icon } from '../../components/Icon' +import { isBannerType } from '../../helpers/noticeHelper' export type AdminNoticeAttributeOptions = { notice?: IAdminNotice @@ -44,6 +45,8 @@ const NOTICE_ICONS: Record = { RELEASE: 'sparkles', // "Release Note" — something new COMMUNICATION: 'comments', // "Announcement" BANNER: 'bullhorn', // persistent top bar + BANNER_WARN: 'triangle-exclamation', // persistent top bar, warning + BANNER_DANGER: 'octagon-xmark', // persistent top bar, error } export const noticeIcon = (type?: INoticeType) => (type && NOTICE_ICONS[type]) || NOTICE_ICONS.GENERIC @@ -110,7 +113,7 @@ export const adminNoticeAttributes: AdminNoticeAttribute[] = [ value: ({ notice }: AdminNoticeAttributeOptions) => { if (!notice) return '—' // A banner with no end date can only be taken down by hand — call it out. - if (notice.type === 'BANNER' && !notice.until) + if (isBannerType(notice.type) && !notice.until) return ( diff --git a/frontend/src/selectors/announcements.ts b/frontend/src/selectors/announcements.ts index 0060ec10f..ea295bf62 100644 --- a/frontend/src/selectors/announcements.ts +++ b/frontend/src/selectors/announcements.ts @@ -1,9 +1,10 @@ import { createSelector } from 'reselect' import { getAnnouncements, optionalParam } from './state' +import { isBannerType } from '../helpers/noticeHelper' // Banners are presented as a persistent bar at the top of the app rather than as cards, so they // are excluded from the announcements list, the unread badge and the full-screen presentation. -const isBanner = (announcement: IAnnouncement) => announcement.type === 'BANNER' +const isBanner = (announcement: IAnnouncement) => isBannerType(announcement.type) export const selectAnnouncements = createSelector([getAnnouncements, optionalParam], (announcements, unread?: boolean) => announcements.filter(a => !isBanner(a) && (!unread || !a.read)) diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index c47215acd..a542c3bbe 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -185,7 +185,15 @@ declare global { read?: Date } - type INoticeType = 'GENERIC' | 'SYSTEM' | 'RELEASE' | 'COMMUNICATION' | 'SECURITY' | 'BANNER' + type INoticeType = + | 'GENERIC' + | 'SYSTEM' + | 'RELEASE' + | 'COMMUNICATION' + | 'SECURITY' + | 'BANNER' + | 'BANNER_WARN' + | 'BANNER_DANGER' // Admin-only view of a notice — includes the scheduling and targeting fields that the // user-facing `notices` query filters on server side and therefore never returns. From b3d7c6a4026e8ee3980c2d63709d4a2560a3da01 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 21 Jul 2026 15:14:40 -0700 Subject: [PATCH 19/19] fix(admin): keep the until column consistent when no end date is set --- .../src/pages/AdminNoticesPage/adminNoticeAttributes.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx index 2822dce77..b7e4e73d2 100644 --- a/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx +++ b/frontend/src/pages/AdminNoticesPage/adminNoticeAttributes.tsx @@ -112,13 +112,14 @@ export const adminNoticeAttributes: AdminNoticeAttribute[] = [ defaultWidth: 170, value: ({ notice }: AdminNoticeAttributeOptions) => { if (!notice) return '—' - // A banner with no end date can only be taken down by hand — call it out. + // A banner with no end date can only be taken down by hand — flag it. Keep the same "—" the + // other rows use so an equal value looks equal; the icon carries the warning, not the text. if (isBannerType(notice.type) && !notice.until) return ( - none + {dateLabel(undefined)} )