Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6045d1b05e | ||
|
|
5df04f5a4a | ||
|
|
35805f0c79 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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,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';
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 "Not now", 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'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'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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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's nothing to run yourself.
|
||||
When a new version is out, the Version box shows what's new in it, so you can decide whether you want it. Press Request update and your provider updates the site for you — there'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'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>
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user