Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 6045d1b05e WHATS-NEW.md for churches; docs, changelog (1.46.2)
Client-facing release notes the Hub reads from git with the version tags, so
the latest version and its notes reach installs without touching the Hub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:13:30 +02:00
joshuaandClaude Opus 5.5 5df04f5a4a Licence page: what's new, withdraw request, Not now, provider notices
- Site settings -> Licence shows the release notes for every newer version from
  the Hub; urgent (security/serious) updates are marked and can't be put off.
- Withdraw an update request (the provider is told through the Hub).
- "Not now" on the banner or Licence page hides update reminders until the
  next version.
- Notices from the provider show as a dashboard banner for site admins and
  supervisors, in their time window, and on the Licence page.
- New audit actions (migration), API docs, help, protocol doc, tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:54:35 +02:00
joshua 35805f0c79 Merge branch 'chore/remove-ci' 2026-10-28 08:15:45 +02:00
26 changed files with 576 additions and 53 deletions
+21
View File
@@ -7,6 +7,27 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.46.2] - 2026-10-28
### Added
- **What's new before you update** — Site settings → Licence shows the release notes for every
version newer than the site, so you can decide whether to ask for the update. Updates that fix a
security problem or a serious bug are marked, and show as a red banner.
- **Withdraw request** — changed your mind after pressing Request update? Withdraw it on the Licence
page and your provider is told not to update the site.
- **Not now** — on the update banner or the Licence page: hides the "update available" reminder until
the next version comes out ("Remind me again" turns it back on). Urgent updates can't be put off.
- **Notices from your provider** — planned maintenance and other messages from your provider show as
a banner at the top of the dashboard for site admins and supervisors, at the times given. Close
one with ×; current notices are also listed on the Licence page.
- **`WHATS-NEW.md`** — the release notes churches see, in plain language. The Hub reads it from git
with the version tags, so a release needs its section there before tagging.
Needs Cross Events Hub 1.4.0 for the notes, withdrawing and notices; with an older Hub the site
works as before.
## [1.46.1] - 2026-10-28
### Removed
+7 -2
View File
@@ -774,8 +774,13 @@ can't start, the same jobs run on in-process timers.
never switches it off: past the end date there's a grace period (banner for site admins only),
then — or when suspended, revoked or not licensed — the site is read-only (events can't be
created or edited; no new registrations or payments; tickets still scan, data can be exported).
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available and press **Request update**,
which tells the provider through the Hub (the site never updates itself).
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available, with
**what's new** in each version, and press **Request update**, which tells the provider through the Hub (the site never updates
itself). A request can be **withdrawn**. **Not now** hides the update banner until the next version; urgent updates (security or
serious bug fixes) can't be put off and show a red banner.
- **Notices from the provider** — maintenance and other messages the provider posts in the Hub show as a banner on the dashboard
for site admins and supervisors, in their time window; each can be closed (per browser) and stays listed on Site settings →
Licence. The provider can email them to the church too.
See `docs/hub-protocol.md`.
- **`/health`** — status, version, database, background jobs and last backup.
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
+53
View File
@@ -0,0 +1,53 @@
# What's new in Cross Events
What each version changes for the church, in plain language. Churches see these notes on Site
settings → Licence before they ask for an update, so they can decide whether they want it.
<!--
How this file is used: the Cross Events Hub reads it from git (as of the newest v* tag) every
15 minutes. Each version is a "## 1.2.3" heading; add "— urgent" (or "security") to the heading for a
security or serious bug fix, which churches can't put off. Under it, lines starting with "- " show as
a list and other lines as paragraphs; **bold**, `code` and links are shown as plain text.
Write for church admins, not developers: no environment variables, endpoints or job names. The
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.46.2
- **See what's new before you update.** When a new version is out, the Licence page lists what it
brings, so you can decide whether you want it.
- **Changed your mind?** Withdraw an update request, and your provider is told not to update the site.
- **Not now:** hide the "update available" reminder until the next version comes out. Updates that
fix a security problem or a serious bug are marked in red and can't be put off.
- **Notices from your provider**, such as planned maintenance, now show as a banner for site admins
and supervisors.
## 1.46.1
- Behind-the-scenes maintenance. Nothing changes for you.
## 1.46.0
- **Volunteer rosters:** set up serving roles (Welcome, Parking, Kids, Sound…) per event or session.
Anyone with an account can sign up under My account → Serving, or supervisors can assign people.
- Reminders by WhatsApp or email the day before a shift.
- **"Can't make it" swaps:** a volunteer asks for cover, and the first to accept takes the place.
- Volunteers check in by scanning the event's QR code; kids-room volunteers count as leaders on duty.
- Roster sheet with phone numbers and check-in times, and hours served per person.
- Fixed: the free kids check-in ticket was offered on the public event page.
## 1.45.1
- The built-in privacy notice and terms of use are brought up to date for kids check-in, sessions,
certificates and campuses. If you published your own privacy notice, review it under Admin →
Privacy.
## 1.45.0
- **Kids check-in:** a secure check-in and pickup for children's ministry, run from a staff desk, with
no label printers needed.
- Parents keep their children's details (allergies, medical and custody notes) and authorised adults
under My account → Kids.
- Each check-in gives the family a pickup code by WhatsApp or email; children can only be collected
with the code.
- Rooms with age ranges, capacity and ratios, live room screens, and an offline desk mode.
+4 -1
View File
@@ -844,10 +844,13 @@ The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protoc
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | public | `{ status, version, db, jobs, lastBackupAt }` — 503 when the database is down |
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable, urgentUpdate, updateSnoozed, releases: [{ version, releasedAt, urgent, notes }], updateRequest, notices }` — `releases` is what's new in each version newer than this one (from the Hub) |
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
| POST | `/api/hub/update-request` | admin | Ask the Hub for this site to be updated (protocol §3a); the provider is told, nothing runs on the site. Returns the status with `updateRequest: { requestedAt, version }` until the site runs that version. `409 UP_TO_DATE`; audited as `hub_update_requested` |
| POST | `/api/hub/update-request/cancel` | admin | Withdraw the update request; the Hub tells the provider. Returns the status. Audited as `hub_update_request_withdrawn` |
| POST | `/api/hub/update-snooze` | admin | `{ snooze }` — "Not now": `updateSnoozed` is true (no banner) until a newer version than the one put off is out. `400` when there's no update or it's urgent (security/serious fix). `{ snooze: false }` turns reminders back on. Audited as `hub_update_snoozed` / `hub_update_unsnoozed` |
| GET | `/api/hub/notices` | supervisor | The provider's notices showing now: `[{ id, level: info\|maintenance\|warning, title, message, startsAt, endsAt, updatedAt }]`, from the last heartbeat |
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.46.1",
"version": "1.46.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.46.1",
"version": "1.46.2",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.46.1",
"version": "1.46.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,11 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_request_withdrawn';
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_snoozed';
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_unsnoozed';
+3
View File
@@ -682,6 +682,9 @@ enum AdminAuditAction {
breach_updated
hub_connected
hub_update_requested
hub_update_request_withdrawn
hub_update_snoozed
hub_update_unsnoozed
campuses_toggled
campus_created
campus_updated
+89 -2
View File
@@ -19,9 +19,47 @@ function pendingUpdateRequest(version, requestedAt, requestedVersion) {
return { requestedAt, version: requestedVersion };
}
const str = (v, max) => String(v ?? '').slice(0, max);
/** What's new in each version newer than this one (from the Hub), newest first. */
function newerReleases(releases, version) {
return (releases || [])
.filter(r => r && /^\d+\.\d+\.\d+$/.test(String(r.version)) && compareVersions(r.version, version) > 0)
.sort((a, b) => compareVersions(b.version, a.version))
.map(r => ({ version: r.version, releasedAt: r.releasedAt || null, urgent: !!r.urgent, notes: str(r.notes, 20000) }));
}
/**
* "Not now" holds until a newer version than the one put off comes out. Urgent updates (security or
* serious bug fixes) can't be put off.
*/
function updateSnoozed({ latestVersion, snoozedVersion, urgent }) {
return !!(latestVersion && snoozedVersion && !urgent && compareVersions(snoozedVersion, latestVersion) >= 0);
}
const NOTICE_LEVELS = new Set(['info', 'maintenance', 'warning']);
/** The provider's notices showing now: started (or no start) and not ended. */
function activeNotices(notices, now = new Date()) {
return (notices || [])
.filter(n => n && n.id && (!n.startsAt || new Date(n.startsAt) <= now) && (!n.endsAt || new Date(n.endsAt) > now))
.map(n => ({
id: str(n.id, 64),
level: NOTICE_LEVELS.has(n.level) ? n.level : 'info',
title: str(n.title, 120),
message: str(n.message, 2000),
startsAt: n.startsAt || null,
endsAt: n.endsAt || null,
updatedAt: n.updatedAt || null,
}));
}
/** What the admin pages show: licence state, Hub connection, version and update. */
async function statusPayload({ fresh = false } = {}) {
const s = await licenceStatus({ fresh });
const releases = newerReleases(s.releases, APP_VERSION);
const updateAvailable = !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0);
const urgentUpdate = updateAvailable && releases.some(r => r.urgent);
return {
state: s.state,
readOnly: s.readOnly,
@@ -45,8 +83,12 @@ async function statusPayload({ fresh = false } = {}) {
lastError: s.lastError,
version: APP_VERSION,
latestVersion: s.latestVersion,
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
updateAvailable,
urgentUpdate,
updateSnoozed: updateAvailable && updateSnoozed({ latestVersion: s.latestVersion, snoozedVersion: s.updateSnoozedVersion, urgent: urgentUpdate }),
releases,
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
notices: activeNotices(s.notices),
};
}
@@ -107,4 +149,49 @@ const requestUpdate = async (req, res) => {
}
};
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, statusPayload, compareVersions, pendingUpdateRequest };
// @desc Withdraw the update request (the provider is told not to update after all)
// @route POST /api/hub/update-request/cancel
// @access Private/Admin
const cancelUpdateRequest = async (req, res) => {
try {
await hub.cancelUpdateRequest();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_request_withdrawn', targetType: 'hub', metadata: { version: APP_VERSION }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
const status = e.statusCode && e.statusCode < 500 && e.code !== 'INSTALL_NOT_FOUND' ? e.statusCode : 502;
res.status(status).json({ message: e.message || safeErrorMessage(e), code: e.code });
}
};
// @desc "Not now": no update reminders until a newer version is out (snooze: false turns them back on)
// @route POST /api/hub/update-snooze body: { snooze }
// @access Private/Admin
const snoozeUpdate = async (req, res) => {
try {
const snooze = req.body?.snooze !== false;
const current = await statusPayload({ fresh: true });
if (snooze) {
if (!current.updateAvailable) { res.status(400); throw new Error('There is no update to put off.'); }
if (current.urgentUpdate) { res.status(400); throw new Error('This update fixes a security or serious problem, so it can\'t be put off.'); }
}
await hub.snoozeUpdate(snooze ? current.latestVersion : null);
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: snooze ? 'hub_update_snoozed' : 'hub_update_unsnoozed', targetType: 'hub', metadata: { version: current.latestVersion }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
res.status(res.statusCode === 200 ? 500 : res.statusCode).json({ message: safeErrorMessage(e) });
}
};
// @desc The provider's notices showing now (banner for site admins and supervisors)
// @route GET /api/hub/notices
// @access Private/Supervisor
const getNotices = async (req, res) => {
try {
const s = await licenceStatus();
res.json(activeNotices(s.notices));
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices, statusPayload, compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices };
+33 -5
View File
@@ -1,10 +1,12 @@
/**
* Talking to the Cross Events Hub (docs/hub-protocol.md): register with a licence key, then a
* signed heartbeat every 15 minutes, and a signed update request when an admin asks for one.
* Install → Hub only. Stored in AppSetting:
* signed heartbeat every 15 minutes, and a signed update request (or withdrawal) when an admin
* asks. Install → Hub only. Stored in AppSetting:
* hub_install_id, hub_install_secret (encrypted), hub_licence (signed token),
* hub_last_heartbeat_at, hub_last_error, hub_latest_version,
* hub_update_requested_at, hub_update_requested_version.
* hub_releases, hub_notices (JSON from the last heartbeat: what's new, provider's notices),
* hub_update_requested_at, hub_update_requested_version,
* hub_update_snoozed_version ("Not now" for that version; set here, not by the Hub).
* These keys are kept out of the generic settings endpoints (settingsController).
*/
@@ -16,7 +18,7 @@ const report = require('./report');
const PROTOCOL_VERSION = 1;
const TIMEOUT_MS = 15000;
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'];
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'];
function hubUrl() {
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
@@ -38,6 +40,14 @@ async function save(values) {
clearLicenceCache();
}
/** What's new and the provider's notices from a register/heartbeat reply (a Hub before 1.4.0 sends neither). */
function fromHub(data) {
return {
hub_releases: Array.isArray(data.releases) ? JSON.stringify(data.releases.slice(0, 20)) : null,
hub_notices: Array.isArray(data.notices) ? JSON.stringify(data.notices.slice(0, 20)) : null,
};
}
async function read(keys) {
const rows = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
return Object.fromEntries(rows.map(r => [r.key, r.value]));
@@ -101,6 +111,7 @@ async function connect(licenceKey, adminContact) {
hub_last_heartbeat_at: new Date().toISOString(),
hub_last_error: null,
hub_latest_version: data.latestVersion || null,
...fromHub(data),
});
return claims;
}
@@ -119,6 +130,7 @@ async function heartbeat() {
hub_last_heartbeat_at: new Date().toISOString(),
hub_last_error: null,
hub_latest_version: data.latestVersion || null,
...fromHub(data),
});
return { ok: true, status: claims?.status || null };
} catch (e) {
@@ -149,6 +161,22 @@ async function requestUpdate() {
return data;
}
/** Withdraw the update request (§3a): the provider is told not to update after all. */
async function cancelUpdateRequest() {
try {
await signedPost('/api/installs/update-request/cancel', { protocolVersion: PROTOCOL_VERSION });
} catch (e) {
if (e.code === 'NOT_FOUND') throw new HubError('The Hub can\'t withdraw update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
throw e;
}
await save({ hub_update_requested_at: null, hub_update_requested_version: null });
}
/** "Not now": no update reminders until a newer version than `version` is out. Null turns them back on. */
async function snoozeUpdate(version) {
await save({ hub_update_snoozed_version: version || null });
}
/**
* A heartbeat soon (on startup, and after connecting), so a licence change on the Hub shows
* without waiting for the next 15-minute run. Never throws; skipped when the job is switched off.
@@ -164,4 +192,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
soonTimer.unref?.();
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate };
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
+13 -1
View File
@@ -77,10 +77,19 @@ function checkDisabled() {
let cache = null;
function parseList(json) {
try {
const list = JSON.parse(json || '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
async function licenceStatus({ fresh = false } = {}) {
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.value;
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'] } } });
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'] } } });
const s = Object.fromEntries(rows.map(r => [r.key, r.value]));
const claims = verifyToken(s.hub_licence);
const value = {
@@ -95,6 +104,9 @@ async function licenceStatus({ fresh = false } = {}) {
latestVersion: s.hub_latest_version || null,
updateRequestedAt: s.hub_update_requested_at || null,
updateRequestedVersion: s.hub_update_requested_version || null,
updateSnoozedVersion: s.hub_update_snoozed_version || null,
releases: parseList(s.hub_releases),
notices: parseList(s.hub_notices),
};
cache = { at: Date.now(), value };
return value;
+7
View File
@@ -926,6 +926,13 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Status' }, { status:502, desc:'Hub unreachable' }]},
{ method:'POST', path:'/api/hub/update-request', auth:'admin', desc:'Ask the Hub for this site to be updated to the latest version (the provider is told; the site never updates itself)',
responses:[{ status:200, desc:'Status, with updateRequest', body:{ updateAvailable:true, updateRequest:{ requestedAt:'2026-10-27T09:00:00.000Z', version:'1.42.0' } }}, { status:409, desc:'Already on the latest version', body:{ code:'UP_TO_DATE', message:'This site already runs the latest version.' }}, { status:502, desc:'Hub unreachable' }]},
{ method:'POST', path:'/api/hub/update-request/cancel', auth:'admin', desc:'Withdraw the update request (the provider is told not to update after all)',
responses:[{ status:200, desc:'Status, updateRequest null' }, { status:502, desc:'Hub unreachable, or a Hub too old to withdraw requests (NOT_SUPPORTED)' }]},
{ method:'POST', path:'/api/hub/update-snooze', auth:'admin', desc:'"Not now": no update reminders until a newer version is out. { snooze: false } turns them back on',
request:{ body:{ snooze:true } },
responses:[{ status:200, desc:'Status', body:{ updateAvailable:true, updateSnoozed:true } }, { status:400, desc:'No update, or an urgent one (can\'t be put off)' }]},
{ method:'GET', path:'/api/hub/notices', auth:'supervisor', desc:'The provider\'s notices showing now (the dashboard banner for site admins and supervisors)',
responses:[{ status:200, desc:'Success', body:[{ id:'…', level:'maintenance', title:'Planned maintenance', message:'The site will be down for 30 minutes.', startsAt:'2026-11-01T16:00:00.000Z', endsAt:'2026-11-01T16:30:00.000Z' }] }]},
]},
{ title: 'Campuses', base: '/api/campuses', endpoints: [
+7 -2
View File
@@ -1,7 +1,8 @@
const express = require('express');
const router = express.Router();
const { getStatus, connect, heartbeatNow, requestUpdate } = require('../controllers/hubController');
const { protect, admin } = require('../middleware/authMiddleware');
const { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices } = require('../controllers/hubController');
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
// Licence and the Cross Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
// admin token counts, so the licence step can run during /setup.
@@ -9,5 +10,9 @@ router.get('/status', protect, admin, getStatus);
router.post('/connect', protect, admin, connect);
router.post('/heartbeat', protect, admin, heartbeatNow);
router.post('/update-request', protect, admin, requestUpdate);
router.post('/update-request/cancel', protect, admin, cancelUpdateRequest);
router.post('/update-snooze', protect, admin, snoozeUpdate);
// The provider's notices: site admins and supervisors see them as a banner.
router.get('/notices', protect, supervisor, campusIndependent('notices from the provider are for the whole site'), getNotices);
module.exports = router;
+48 -1
View File
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const { verifyToken, stateFor } = require('../src/hub/licence');
const { incomeBand } = require('../src/hub/report');
const { isBlocked } = require('../src/hub/readOnly');
const { compareVersions, pendingUpdateRequest } = require('../src/controllers/hubController');
const { compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices } = require('../src/controllers/hubController');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
function sign(payload, key = privateKey) {
@@ -100,6 +100,8 @@ describe('read-only mode', () => {
['POST', '/api/payments/refund'],
['POST', '/api/privacy/me/requests'],
['POST', '/api/hub/connect'],
['POST', '/api/hub/update-request/cancel'],
['POST', '/api/hub/update-snooze'],
['POST', '/api/users/login'],
['POST', '/api/reports/pdf'],
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
@@ -151,3 +153,48 @@ test('an update request shows until the site runs the version asked for', () =>
expect(pendingUpdateRequest('1.43.0', at, '1.42.0')).toBeNull();
expect(pendingUpdateRequest('1.41.2', null, null)).toBeNull();
});
describe("what's new", () => {
const releases = [
{ version: '1.46.0', notes: 'Older', urgent: false, releasedAt: '2026-10-20T10:00:00.000Z' },
{ version: '1.48.0', notes: '- Webhooks', urgent: false, releasedAt: '2026-10-30T10:00:00.000Z' },
{ version: '1.47.1', notes: '- Security fix', urgent: true, releasedAt: '2026-10-29T10:00:00.000Z' },
{ version: 'bogus', notes: 'x' },
];
test('only versions newer than this site, newest first', () => {
expect(newerReleases(releases, '1.47.0').map(r => r.version)).toEqual(['1.48.0', '1.47.1']);
expect(newerReleases(releases, '1.48.0')).toEqual([]);
expect(newerReleases(null, '1.0.0')).toEqual([]);
});
test('"Not now" holds until a newer version than the one put off', () => {
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: '1.47.0', urgent: false })).toBe(true);
expect(updateSnoozed({ latestVersion: '1.48.0', snoozedVersion: '1.47.0', urgent: false })).toBe(false);
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: null, urgent: false })).toBe(false);
});
test('urgent updates can\'t be put off', () => {
expect(updateSnoozed({ latestVersion: '1.47.1', snoozedVersion: '1.47.1', urgent: true })).toBe(false);
});
});
describe("the provider's notices", () => {
const NOW_N = new Date('2026-11-01T18:30:00Z');
const n = (id, extra = {}) => ({ id, level: 'info', title: 'T', message: 'M', startsAt: null, endsAt: null, ...extra });
test('show between their start and end', () => {
const notices = [
n('now'),
n('window', { startsAt: '2026-11-01T18:00:00Z', endsAt: '2026-11-01T20:00:00Z' }),
n('later', { startsAt: '2026-11-02T18:00:00Z' }),
n('ended', { endsAt: '2026-11-01T18:00:00Z' }),
];
expect(activeNotices(notices, NOW_N).map(x => x.id)).toEqual(['now', 'window']);
});
test('an unknown level shows as information', () => {
expect(activeNotices([n('x', { level: 'party' })], NOW_N)[0].level).toBe('info');
expect(activeNotices([n('x', { level: 'maintenance' })], NOW_N)[0].level).toBe('maintenance');
});
});
+8 -3
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.46.1** (and Hub 1.1.0). The plan itself is in
Last updated: 2026-10-28, after **1.46.2** (and Hub 1.4.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -16,6 +16,7 @@ encrypted settings unreadable or break existing installs.
|---------|------|
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
@@ -52,11 +53,15 @@ a test server (both done 2026-10-28).
- **Release routine:** branch `feat/<name>` → commit → bump with
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → write the church-facing
`## X.Y.Z` section in **WHATS-NEW.md** (plain language; `— urgent` in the heading for a security
or serious fix) → `git merge --no-ff`
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
version per feature.
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language. CHANGELOG.md is the
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from the newest
tag every 15 minutes and sets the latest version from the tags — nothing to do in the Hub).
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
+41 -2
View File
@@ -55,7 +55,9 @@ POST /api/installs/register
"installSecret": "…", // shown once; the install stores it encrypted
"licence": "<licence token>",
"heartbeatIntervalMinutes": 15,
"latestVersion": "1.41.0"
"latestVersion": "1.41.0",
"releases": [ … ], // as in the heartbeat reply (§3); Hub 1.4.0+
"notices": [ … ]
}
```
@@ -101,9 +103,30 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
}
}
→ 200 { "licence": "<fresh licence token>", "latestVersion": "1.42.0", "serverTime": "…" }
→ 200 {
"licence": "<fresh licence token>",
"latestVersion": "1.42.0",
"releases": [ // Hub 1.4.0+: newer than the version sent, newest first, at most 20
{ "version": "1.42.0", "releasedAt": "2026-10-27T10:00:00.000Z", "urgent": false, "notes": "- Event series\n- Fixed: …" }
],
"notices": [ // Hub 1.4.0+: the provider's banners for this install
{ "id": "…", "level": "maintenance", "title": "Planned maintenance", "message": "…",
"startsAt": "2026-11-01T16:00:00.000Z", "endsAt": "2026-11-01T16:30:00.000Z", "updatedAt": "…" }
],
"serverTime": "…"
}
```
- **`releases`** — what's new in each version, so the church can decide whether to ask for the
update. The Hub takes the versions from the Cross Events repository's `v*` tags and the notes from
its `WHATS-NEW.md`. `notes` is plain text; lines starting with `- ` are
a list. `urgent` marks security or serious bug fixes: the install shows those in red and doesn't let
admins put them off. The highest release is `latestVersion`.
- **`notices`** — banners Joshua posts on the Hub's Notices page (`level`: `info`, `maintenance` or
`warning`). Sent until `endsAt`, including ones that haven't started, so the install shows each one
from `startsAt` without waiting for a heartbeat. The install shows them to site admins and
supervisors. Notices can also be emailed; that happens on the Hub, not through the install.
- A Hub older than 1.4.0 sends neither field; the install then shows no notes and no notices.
- The signature covers the exact bytes sent. Requests more than **5 minutes** off the Hub's
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
@@ -132,6 +155,22 @@ X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
hour per IP). A Hub older than this section answers `404 NOT_FOUND`.
- No personal information: not who pressed the button (the Hub already has the admin contact).
### Withdrawing it
Sent when a site admin presses **Withdraw request**. Signed like the heartbeat.
```
POST /api/installs/update-request/cancel
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
{ "protocolVersion": 1 }
→ 200 { "cancelled": true } // the open request was resolved, and Joshua is emailed
→ 200 { "cancelled": false } // there was no open request
```
- The Hub resolves the `update_requested` alert and records `update_request_withdrawn` in the install's
history. Rate-limited with update requests. A Hub older than 1.4.0 answers `404 NOT_FOUND`.
- **"Not now"** (no reminders until the next version) stays on the install; the Hub isn't told.
## 4. Licence token
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.46.1",
"version": "1.46.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.46.1",
"version": "1.46.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.46.1",
"version": "1.46.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -97,6 +97,9 @@ const ACTION_LABELS: Record<string, string> = {
breach_updated: "Security compromise updated",
hub_connected: "Connected to the Cross Events Hub",
hub_update_requested: "Asked the Hub for an update",
hub_update_request_withdrawn: "Withdrew the update request",
hub_update_snoozed: "Put off an update (Not now)",
hub_update_unsnoozed: "Turned update reminders back on",
campuses_toggled: "Campuses turned on/off",
campus_created: "Campus added",
campus_updated: "Campus changed",
+3
View File
@@ -8,6 +8,7 @@ import { Footer } from "@/components/layout/Footer";
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
import { LicenceBanner } from "@/components/shared/LicenceBanner";
import { ProviderNotices } from "@/components/shared/ProviderNotices";
// The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign
@@ -89,6 +90,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<div className="h-screen flex flex-col overflow-hidden">
<Navbar />
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<MobileSidebar />
<div className="flex-1 flex min-h-0">
@@ -106,6 +108,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<div className="min-h-screen flex flex-col">
<Navbar />
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
+109 -16
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle } from "lucide-react";
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle, Undo2, BellOff, BellRing, Megaphone } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
@@ -21,9 +21,39 @@ export type HubStatus = {
version: string;
latestVersion: string | null;
updateAvailable: boolean;
urgentUpdate: boolean;
updateSnoozed: boolean;
releases: HubRelease[];
updateRequest: { requestedAt: string; version: string } | null;
notices: HubNotice[];
};
export type HubRelease = { version: string; releasedAt: string | null; urgent: boolean; notes: string };
export type HubNotice = { id: string; level: "info" | "maintenance" | "warning"; title: string; message: string; startsAt: string | null; endsAt: string | null; updatedAt: string | null };
/** Release notes from the provider: lines starting with "- " are a list, the rest paragraphs. */
export function ReleaseNotes({ notes }: { notes: string }) {
const blocks: ({ list: false; text: string } | { list: true; items: string[] })[] = [];
for (const raw of notes.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const item = /^[-*•]\s+(.*)$/.exec(line);
const last = blocks[blocks.length - 1];
if (item) {
if (last?.list) last.items.push(item[1]);
else blocks.push({ list: true, items: [item[1]] });
} else blocks.push({ list: false, text: line });
}
if (!blocks.length) return <p className="text-gray-400 italic">No details given.</p>;
return (
<div className="space-y-1.5 text-gray-700">
{blocks.map((b, i) => (b.list
? <ul key={i} className="list-disc pl-5 space-y-0.5">{b.items.map((t, j) => <li key={j}>{t}</li>)}</ul>
: <p key={i}>{b.text}</p>))}
</div>
);
}
const STATE: Record<HubStatus["state"], { label: string; tone: string; text: string }> = {
active: { label: "Active", tone: "bg-green-50 text-green-800 border-green-200", text: "Everything works." },
grace: { label: "Needs renewing", tone: "bg-amber-50 text-amber-900 border-amber-200", text: "Everything still works for now — renew before the grace period ends." },
@@ -94,18 +124,25 @@ export function LicenceTab({ active }: { active: boolean }) {
}
};
const requestUpdate = async () => {
// Request, withdraw, "Not now" and "remind me again" all return the new status.
const updateAction = async (path: string, body: unknown, ok: string) => {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>("/api/hub/update-request", { method: "POST", authToken: token }));
setMsg({ type: "ok", text: "Update requested. Your provider has been told and will update the site." });
setStatus(await apiFetch<HubStatus>(path, { method: "POST", authToken: token, body }));
setMsg({ type: "ok", text: ok });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
} finally {
setBusy(false);
}
};
const requestUpdate = () => updateAction("/api/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
const withdrawRequest = () => {
if (!confirm("Withdraw the update request? Your provider is told not to update the site for now.")) return;
updateAction("/api/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
};
const snooze = (on: boolean) => updateAction("/api/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const st = STATE[status.state];
@@ -162,22 +199,78 @@ export function LicenceTab({ active }: { active: boolean }) {
<div className="border rounded-xl p-4 text-sm">
<div className="flex items-center gap-2 font-medium text-gray-900 mb-1"><ArrowUpCircle className="w-4 h-4" /> Version</div>
<p>This site runs Cross Events <strong>{status.version}</strong>.{status.latestVersion && !status.updateAvailable && " It's up to date."}</p>
{status.updateAvailable && (status.updateRequest ? (
<p className="mt-1 text-gray-600">
Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.
</p>
) : (
<div className="mt-2 flex flex-wrap items-center gap-3">
<p className="text-brand-700">Version {status.latestVersion} is available.</p>
{status.connected && (
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Requesting…" : "Request update"}
</button>
{status.updateAvailable && (
<div className="mt-2 space-y-3">
{status.urgentUpdate && (
<p className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-red-800">
<ShieldAlert className="w-4 h-4 mt-0.5 shrink-0" />
<span>This update fixes a security problem or a serious bug. Please ask for it soon.</span>
</p>
)}
{status.updateRequest ? (
<div className="flex flex-wrap items-center gap-3">
<p className="text-gray-600">Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.</p>
{status.connected && (
<button onClick={withdrawRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<Undo2 className="w-4 h-4" /> Withdraw request
</button>
)}
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<p className="text-brand-700">Version {status.latestVersion} is available.{status.updateSnoozed && <span className="text-gray-500"> You chose &quot;Not now&quot;, so there are no reminders until the next version.</span>}</p>
{status.connected && (
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Working…" : "Request update"}
</button>
)}
{!status.urgentUpdate && (status.updateSnoozed ? (
<button onClick={() => snooze(false)} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<BellRing className="w-4 h-4" /> Remind me again
</button>
) : (
<button onClick={() => snooze(true)} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<BellOff className="w-4 h-4" /> Not now
</button>
))}
</div>
)}
{status.releases.length > 0 && (
<div className="border-t pt-3">
<h3 className="font-medium text-gray-900 mb-2">What&apos;s new</h3>
<div className="space-y-4">
{status.releases.map(r => (
<div key={r.version}>
<div className="flex flex-wrap items-center gap-2 mb-1">
<span className="font-semibold">{r.version}</span>
{r.urgent && <span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 px-2 py-0.5 text-xs font-medium text-red-700"><ShieldAlert className="w-3 h-3" /> Security or urgent fix</span>}
{r.releasedAt && <span className="text-xs text-gray-500">{new Date(r.releasedAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })}</span>}
</div>
<ReleaseNotes notes={r.notes} />
</div>
))}
</div>
</div>
)}
</div>
))}
)}
</div>
{status.notices.length > 0 && (
<div className="border rounded-xl p-4 text-sm">
<div className="flex items-center gap-2 font-medium text-gray-900 mb-2"><Megaphone className="w-4 h-4" /> From your provider</div>
<div className="space-y-3">
{status.notices.map(n => (
<div key={n.id}>
<p className="font-medium">{n.title}</p>
<p className="text-gray-700 whitespace-pre-line">{n.message}</p>
{n.endsAt && <p className="text-xs text-gray-500 mt-0.5">Until {fmt(n.endsAt)}</p>}
</div>
))}
</div>
</div>
)}
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
</div>
);
@@ -2,18 +2,20 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ShieldAlert, KeyRound, ArrowUpCircle } from "lucide-react";
import { ShieldAlert, KeyRound, ArrowUpCircle, BellOff } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubStatus } from "@/components/admin/LicenceTab";
/**
* Licence and update notices for **site admins only** (never supervisors, staff, users or the
* public site): grace period, suspended/revoked, not connected, update available.
* public site): grace period, suspended/revoked, not connected, update available (with "Not now").
* The provider's own notices are ProviderNotices.
*/
export function LicenceBanner() {
const { user, token } = useAuth();
const [status, setStatus] = useState<HubStatus | null>(null);
const [busy, setBusy] = useState(false);
const isAdmin = user?.role === "admin";
useEffect(() => {
@@ -50,12 +52,31 @@ export function LicenceBanner() {
</div>
);
}
// Once an update has been requested there's nothing more for the admin to do.
if (status.updateAvailable && !status.updateRequest) {
// Once an update has been requested there's nothing more for the admin to do. "Not now" hides it
// until the next version, except for urgent (security or serious bug) updates.
if (status.urgentUpdate && !status.updateRequest) {
return (
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 shrink-0" />
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). Request the update in {link}</span>
<div role="alert" className="bg-red-50 border-b border-red-200 text-red-800 text-sm px-4 py-2 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 shrink-0" />
<span>Cross Events {status.latestVersion} fixes a security problem or a serious bug (this site runs {status.version}). Please request the update in {link}</span>
</div>
);
}
if (status.updateAvailable && !status.updateRequest && !status.updateSnoozed) {
const notNow = async () => {
setBusy(true);
try { setStatus(await apiFetch<HubStatus>("/api/hub/update-snooze", { method: "POST", authToken: token, body: { snooze: true } })); } catch { /* the Licence page shows why */ }
setBusy(false);
};
return (
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 shrink-0" />
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). See what&apos;s new and request it in {link}</span>
</span>
<button onClick={notNow} disabled={busy} className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-brand-700 hover:bg-brand-100 disabled:opacity-50" title="Hide this until the next version comes out">
<BellOff className="w-3.5 h-3.5" /> Not now
</button>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
import React, { useEffect, useState } from "react";
import { Info, Wrench, AlertTriangle, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubNotice } from "@/components/admin/LicenceTab";
const DISMISSED_KEY = "cross_events_dismissed_notices";
const LEVEL = {
info: { icon: Info, tone: "bg-sky-50 border-sky-200 text-sky-900" },
maintenance: { icon: Wrench, tone: "bg-amber-50 border-amber-200 text-amber-900" },
warning: { icon: AlertTriangle, tone: "bg-red-50 border-red-200 text-red-800" },
} as const;
// A notice the provider edits shows again, so it's remembered by id and last change.
const noticeKey = (n: HubNotice) => `${n.id}:${n.updatedAt || ""}`;
function readDismissed(): string[] {
try { return JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]"); } catch { return []; }
}
/**
* Notices from the provider (sent by the Cross Events Hub): maintenance, news and so on. Shown to
* site admins and supervisors only. Each can be closed on this browser; it's still listed on Site
* settings → Licence.
*/
export function ProviderNotices() {
const { user, token } = useAuth();
const [notices, setNotices] = useState<HubNotice[]>([]);
const [dismissed, setDismissed] = useState<string[]>([]);
const canSee = user?.role === "admin" || user?.role === "supervisor";
useEffect(() => {
if (!canSee || !token) return;
setDismissed(readDismissed());
apiFetch<HubNotice[]>("/api/hub/notices", { authToken: token }).then(setNotices).catch(() => {});
}, [canSee, token]);
if (!canSee) return null;
const shown = notices.filter(n => !dismissed.includes(noticeKey(n)));
if (!shown.length) return null;
const dismiss = (n: HubNotice) => {
// Keep only notices that still exist, so the list doesn't grow forever.
const next = [...dismissed.filter(k => notices.some(x => noticeKey(x) === k)), noticeKey(n)];
setDismissed(next);
try { localStorage.setItem(DISMISSED_KEY, JSON.stringify(next)); } catch { /* private window: closed until reload */ }
};
return (
<>
{shown.map(n => {
const { icon: Icon, tone } = LEVEL[n.level] || LEVEL.info;
return (
<div key={n.id} role={n.level === "warning" ? "alert" : "status"} className={`border-b text-sm px-4 py-2 flex items-start gap-2 ${tone}`}>
<Icon className="w-4 h-4 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="font-medium">{n.title}</span>
<span className="whitespace-pre-line"> — {n.message}</span>
</div>
<button onClick={() => dismiss(n)} className="shrink-0 rounded p-0.5 hover:bg-black/5" aria-label={`Close "${n.title}"`}>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</>
);
}
+8 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -184,7 +184,13 @@ export const siteSettingsHelpContent: HelpContent = {
When a licence is suspended, revoked or past its grace period, nothing new can be added — no events, registrations or payments. Tickets for events people already paid for still scan, and all data can still be viewed and exported.
</GuideItem>
<GuideItem icon={ArrowUpCircle} title="Updates" tone="brand">
When a new version is out, press Request update. Your provider is told and updates the site for you — there&apos;s nothing to run yourself.
When a new version is out, the Version box shows what&apos;s new in it, so you can decide whether you want it. Press Request update and your provider updates the site for you — there&apos;s nothing to run yourself. Changed your mind? Withdraw request tells your provider not to.
</GuideItem>
<GuideItem icon={BellOff} title="Not now" tone="gray">
Not now (on the Licence page or the update banner) hides the reminder until the next version comes out. Updates that fix a security problem or a serious bug can&apos;t be put off.
</GuideItem>
<GuideItem icon={Megaphone} title="Notices from your provider" tone="brand">
Your provider can post notices, such as planned maintenance. Site admins and supervisors see them as a banner at the top of the dashboard; close one with ×. Current notices are also listed on this page.
</GuideItem>
</div>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.46.1",
"version": "1.46.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.46.1",
"version": "1.46.2",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.46.1",
"version": "1.46.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",