Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fdd2b8b9d | ||
|
|
8e2cb7eb4d | ||
|
|
bc73d7e3af | ||
|
|
925252a1f2 | ||
|
|
60726b71ce | ||
|
|
e58787ae7a | ||
|
|
8f533e1f61 |
@@ -7,6 +7,33 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.41.2] - 2026-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Background jobs page showed last-run times two hours ahead** (e.g. 12:55 at 10:55). Prisma's
|
||||
Postgres adapter dropped the time-zone offset of pg-boss's timestamps; the query now reads them
|
||||
as UTC. Job history itself was never wrong. `APP_TIMEZONE` is now in `backend/.env.example` and
|
||||
SETTINGS.md.
|
||||
|
||||
## [1.41.1] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- **No licence now means read-only**, the same as a suspended licence: a site that isn't connected
|
||||
to the Hub can't create events, registrations or payments until a licence key is connected
|
||||
(admins see a red banner). `LICENCE_CHECK_DISABLED=true` switches this off outside production,
|
||||
for the end-to-end tests.
|
||||
- **Read-only mode also blocks editing events**, their options and their variants (suspended,
|
||||
revoked and unlicensed sites).
|
||||
- The site **checks in with the Hub 30 seconds after starting**, and 30 seconds after a licence
|
||||
key is connected, instead of waiting up to 15 minutes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A site the Hub no longer knows (`INSTALL_NOT_FOUND`, e.g. the licence was released) kept showing
|
||||
"Active" on its old licence. It now drops the connection and licence, and shows "No licence".
|
||||
|
||||
## [1.41.0] - 2026-10-26
|
||||
|
||||
### Added
|
||||
|
||||
+3
-2
@@ -657,8 +657,9 @@ can't start, the same jobs run on in-process timers.
|
||||
Licence) and checks in every 15 minutes with its version, health and totals (no personal
|
||||
information; income as a range). Licences are signed tokens checked on the site, so a Hub outage
|
||||
never switches it off: past the end date there's a grace period (banner for site admins only),
|
||||
then — or when suspended or revoked — the site is read-only (no new events, registrations or
|
||||
payments; tickets still scan, data can be exported). Admins see when an update is available.
|
||||
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.
|
||||
See `docs/hub-protocol.md`.
|
||||
- **`/health`** — status, version, database, background jobs and last backup.
|
||||
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
|
||||
|
||||
+4
-2
@@ -17,6 +17,7 @@ in-process settings cache TTL) without a server restart.
|
||||
| `org_email` | *(blank)* | No fallback in most places. **Exception:** the Terms of Use / Privacy Policy pages default to `privacy@example.com` (an obvious placeholder) if unset — see the Legal section below. |
|
||||
| `org_phone` / `org_address` | *(blank)* | No fallback — simply omitted wherever shown if unset. |
|
||||
| `app_base_url` | `APP_BASE_URL` or `FRONTEND_URL` env var, else `http://localhost:3001` | Used to build links in emails (password reset, ticket delivery, etc.). Should always be set in production. |
|
||||
| `APP_TIMEZONE` (env) | `Africa/Johannesburg` | The site's time zone (an IANA name, e.g. `Europe/London`): background job schedules (e.g. the 07:00 daily summary), session dates and printed times. Also sent to the Hub in the heartbeat. Needs a backend restart. |
|
||||
|
||||
## Branding
|
||||
|
||||
@@ -90,8 +91,9 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
|---|---|---|
|
||||
| `HUB_URL` (env) | `https://events-hub.crosscode.co.za` | Where the Hub runs. |
|
||||
| `HUB_PUBLIC_KEY` (env) | The production key built into `backend/src/hub/publicKey.js` | Only for a local or test Hub. Licence tokens are checked with this key. |
|
||||
| `HUB_HEARTBEAT_ENABLED` (env) | `true` | The 15-minute `hub-heartbeat` job. Off means the licence isn't refreshed — after 30 days the site goes into grace, then read-only. |
|
||||
| `hub_install_id`, `hub_install_secret` (encrypted), `hub_licence`, `hub_last_heartbeat_at`, `hub_last_error`, `hub_latest_version` | — | Written only by `src/hub/client.js` when connecting and on each heartbeat. Never readable or writable through the generic settings endpoints. Deleting them disconnects the site (it becomes "not connected"). |
|
||||
| `HUB_HEARTBEAT_ENABLED` (env) | `true` | The 15-minute `hub-heartbeat` job (plus one check-in 30 s after starting and after connecting). Off means the licence isn't refreshed — after 30 days the site goes into grace, then read-only. |
|
||||
| `LICENCE_CHECK_DISABLED` (env) | `false` | `true` turns read-only mode off — **ignored when `NODE_ENV=production`**. For the end-to-end tests and local development without a Hub. |
|
||||
| `hub_install_id`, `hub_install_secret` (encrypted), `hub_licence`, `hub_last_heartbeat_at`, `hub_last_error`, `hub_latest_version` | — | Written only by `src/hub/client.js` when connecting and on each heartbeat. Never readable or writable through the generic settings endpoints. Deleting them disconnects the site (it becomes "no licence", which is read-only). The heartbeat deletes the install and licence keys itself when the Hub answers `INSTALL_NOT_FOUND`. |
|
||||
|
||||
## WhatsApp
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ JWT_SECRET=your_jwt_secret_here_minimum_32_characters
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# Time zone for background job schedules (e.g. the 07:00 daily summary), session dates
|
||||
# and printed times. An IANA name, e.g. Africa/Johannesburg or Europe/London.
|
||||
APP_TIMEZONE=Africa/Johannesburg
|
||||
|
||||
# Set this if the app runs behind a reverse proxy (e.g. nginx on a separate
|
||||
# server) so rate limiting reads the real client IP instead of the proxy's.
|
||||
# Accepts a hop count ("1"), "true"/"false", or comma-separated IP(s)/CIDR(s)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.2",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -63,6 +63,7 @@ const connect = async (req, res) => {
|
||||
if (!licenceKey) { res.status(400); throw new Error('Enter your licence key.'); }
|
||||
const me = await prisma.user.findUnique({ where: { id: req.user.id }, select: { name: true, email: true } });
|
||||
const claims = await hub.connect(licenceKey, me);
|
||||
hub.heartbeatSoon();
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_connected', targetType: 'hub', metadata: { customer: claims.customer, hubUrl: hub.hubUrl() }, ip: getClientIp(req) });
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
|
||||
@@ -114,10 +114,29 @@ async function heartbeat() {
|
||||
return { ok: true, status: claims?.status || null };
|
||||
} catch (e) {
|
||||
await save({ hub_last_error: `${new Date().toISOString()} ${e.code || ''} ${e.message}`.slice(0, 500) });
|
||||
// The Hub doesn't know this install any more (licence released): show "Reconnect".
|
||||
if (e.code === 'INSTALL_NOT_FOUND') return { ok: false, reconnect: true };
|
||||
// The Hub doesn't know this install any more (licence released): drop the connection and the
|
||||
// licence, so the site is unlicensed (read-only) and shows "Connect" until a key is entered.
|
||||
if (e.code === 'INSTALL_NOT_FOUND') {
|
||||
await save({ hub_install_id: null, hub_install_secret: null, hub_licence: null });
|
||||
return { ok: false, reconnect: true };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat };
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
let soonTimer = null;
|
||||
function heartbeatSoon(delayMs = 30 * 1000) {
|
||||
if (String(process.env.JOBS_ENABLED || 'true').toLowerCase() === 'false') return;
|
||||
if (String(process.env.HUB_HEARTBEAT_ENABLED || 'true').toLowerCase() === 'false') return;
|
||||
clearTimeout(soonTimer);
|
||||
soonTimer = setTimeout(() => {
|
||||
heartbeat().catch(e => console.warn('[hub] Heartbeat failed:', e?.message || e));
|
||||
}, delayMs);
|
||||
soonTimer.unref?.();
|
||||
}
|
||||
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon };
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* suspended — suspended by the Hub, or grace ran out: read-only (no new events,
|
||||
* registrations or payments); tickets still scan, data can be exported
|
||||
* revoked — like suspended, with a permanent banner
|
||||
* unlicensed — never connected to the Hub: works, admins see "Connect to Hub"
|
||||
* unlicensed — not connected to the Hub (never, or the Hub released this install): read-only,
|
||||
* like suspended, until a licence key is connected
|
||||
*
|
||||
* LICENCE_CHECK_DISABLED=true turns read-only off outside production (the end-to-end tests).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
@@ -15,7 +18,7 @@ const prisma = require('../config/db');
|
||||
const { hubPublicKey } = require('./publicKey');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const READ_ONLY_STATES = new Set(['suspended', 'revoked']);
|
||||
const READ_ONLY_STATES = new Set(['suspended', 'revoked', 'unlicensed']);
|
||||
const CACHE_MS = 30 * 1000;
|
||||
|
||||
let publicKeyObject = null;
|
||||
@@ -53,7 +56,7 @@ function verifyToken(token, key = publicKey()) {
|
||||
*/
|
||||
function stateFor(claims, installId, now = new Date()) {
|
||||
if (!claims || (installId && claims.installId !== installId)) {
|
||||
return { state: 'unlicensed', readOnly: false, endsAt: null, graceEndsAt: null, daysLeft: null };
|
||||
return { state: 'unlicensed', readOnly: true, endsAt: null, graceEndsAt: null, daysLeft: null };
|
||||
}
|
||||
const ends = [claims.licenceExpiresAt, claims.validUntil].filter(Boolean).map(d => new Date(d));
|
||||
const endsAt = ends.length ? new Date(Math.min(...ends)) : null;
|
||||
@@ -68,6 +71,10 @@ function stateFor(claims, installId, now = new Date()) {
|
||||
return { state, readOnly: READ_ONLY_STATES.has(state), endsAt, graceEndsAt, daysLeft, expired: state === 'suspended' && claims.status === 'active' };
|
||||
}
|
||||
|
||||
function checkDisabled() {
|
||||
return process.env.NODE_ENV !== 'production' && String(process.env.LICENCE_CHECK_DISABLED || '').toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
let cache = null;
|
||||
|
||||
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
|
||||
@@ -82,6 +89,7 @@ async function licenceStatus({ fresh = false } = {}) {
|
||||
installId: s.hub_install_id || null,
|
||||
claims: claims && claims.installId === s.hub_install_id ? claims : null,
|
||||
...stateFor(claims, s.hub_install_id || null),
|
||||
...(checkDisabled() ? { readOnly: false } : {}),
|
||||
lastHeartbeatAt: s.hub_last_heartbeat_at || null,
|
||||
lastError: s.hub_last_error || null,
|
||||
latestVersion: s.hub_latest_version || null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Read-only mode while the licence is suspended or revoked: no *new* activity — new events,
|
||||
* registrations, tickets added, payments started or recorded, waitlist places, coupons or bundles.
|
||||
* Read-only mode while the licence is suspended, revoked or missing (not connected to the Hub):
|
||||
* no new or edited events, and no *new* activity — registrations, tickets added, payments started
|
||||
* or recorded, waitlist places, coupons or bundles.
|
||||
* Everything else keeps working on purpose: scanning tickets for events people already paid for,
|
||||
* viewing and exporting data, refunds and cancellations, privacy requests, logging in, and the
|
||||
* settings needed to renew the licence. Payment webhooks for payments already started also still
|
||||
@@ -11,8 +12,11 @@ const { licenceStatus } = require('./licence');
|
||||
|
||||
const BLOCKED = [
|
||||
['POST', /^\/api\/events\/?$/],
|
||||
['PUT', /^\/api\/events\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
|
||||
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
|
||||
['PUT', /^\/api\/events\/variants\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/registrations\/?$/],
|
||||
['POST', /^\/api\/registrations\/manual\/?$/],
|
||||
['PUT', /^\/api\/registrations\/[^/]+\/options\/?$/],
|
||||
@@ -36,8 +40,10 @@ async function licenceReadOnly(req, res, next) {
|
||||
return res.status(403).json({
|
||||
code: 'LICENCE_READ_ONLY',
|
||||
message: status.state === 'revoked'
|
||||
? "This site's licence has been revoked, so nothing new can be added. Tickets still scan and all data can still be viewed and exported."
|
||||
: "This site's licence is suspended, so nothing new can be added for now. Tickets still scan and all data can still be viewed and exported. Please contact the site's administrator.",
|
||||
? "This site's licence has been revoked, so nothing new can be added and events can't be changed. Tickets still scan and all data can still be viewed and exported."
|
||||
: status.state === 'unlicensed'
|
||||
? "This site has no licence, so nothing new can be added and events can't be changed. A site admin can connect a licence key in Site settings → Licence."
|
||||
: "This site's licence is suspended, so nothing new can be added and events can't be changed for now. Tickets still scan and all data can still be viewed and exported. Please contact the site's administrator.",
|
||||
});
|
||||
} catch {
|
||||
// Never let a licence check failure take the site down.
|
||||
|
||||
@@ -162,7 +162,7 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Licence suspended or revoked: no new events, registrations or payments (src/hub/readOnly.js).
|
||||
// Licence suspended, revoked or missing: no new or edited events, registrations or payments (src/hub/readOnly.js).
|
||||
app.use(require('./hub/readOnly').licenceReadOnly);
|
||||
|
||||
// Health for monitoring (and the Hub): no secrets, no personal information.
|
||||
@@ -1570,6 +1570,9 @@ app.listen(PORT, () => {
|
||||
// backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
|
||||
require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
|
||||
|
||||
// Check in with the Hub 30 s after starting, so the licence is fresh without waiting 15 minutes.
|
||||
require('./hub/client').heartbeatSoon();
|
||||
|
||||
// Scheduled emails worker (polling)
|
||||
try {
|
||||
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
@@ -247,8 +247,14 @@ async function runNow(name) {
|
||||
async function jobStatus(prisma) {
|
||||
const recent = new Map();
|
||||
if (mode === 'queue') {
|
||||
// pg-boss columns are timestamptz, and Prisma's pg adapter drops their offset in raw queries
|
||||
// (10:55+02 came back as 10:55Z). As plain UTC timestamps they come back right.
|
||||
const rows = await prisma.$queryRawUnsafe(`
|
||||
SELECT name, state, created_on AS "createdOn", started_on AS "startedOn", completed_on AS "completedOn", output
|
||||
SELECT name, state,
|
||||
created_on AT TIME ZONE 'UTC' AS "createdOn",
|
||||
started_on AT TIME ZONE 'UTC' AS "startedOn",
|
||||
completed_on AT TIME ZONE 'UTC' AS "completedOn",
|
||||
output
|
||||
FROM (
|
||||
SELECT name, state, created_on, started_on, completed_on, output,
|
||||
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
|
||||
|
||||
@@ -33,8 +33,8 @@ describe('licence tokens', () => {
|
||||
});
|
||||
|
||||
describe('licence states', () => {
|
||||
test('never connected → unlicensed, not read-only', () => {
|
||||
expect(stateFor(null, null, NOW)).toMatchObject({ state: 'unlicensed', readOnly: false });
|
||||
test('never connected → unlicensed, read-only like suspended', () => {
|
||||
expect(stateFor(null, null, NOW)).toMatchObject({ state: 'unlicensed', readOnly: true });
|
||||
});
|
||||
|
||||
test('a token for another install counts as none', () => {
|
||||
@@ -80,6 +80,9 @@ describe('heartbeat report', () => {
|
||||
describe('read-only mode', () => {
|
||||
test.each([
|
||||
['POST', '/api/events'],
|
||||
['PUT', '/api/events/ev_1'],
|
||||
['PUT', '/api/events/options/opt_1'],
|
||||
['PUT', '/api/events/variants/var_1'],
|
||||
['POST', '/api/registrations'],
|
||||
['POST', '/api/registrations/manual'],
|
||||
['PUT', '/api/registrations/abc/options'],
|
||||
@@ -92,6 +95,7 @@ describe('read-only mode', () => {
|
||||
test.each([
|
||||
['POST', '/api/tickets/scan/QR123'],
|
||||
['GET', '/api/events'],
|
||||
['PUT', '/api/events/ev_1/notify-recipients'],
|
||||
['PUT', '/api/registrations/abc'],
|
||||
['POST', '/api/payments/refund'],
|
||||
['POST', '/api/privacy/me/requests'],
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-26, after **1.41.0** (and Hub 1.0.0). The plan itself is in
|
||||
Last updated: 2026-10-27, after **1.41.2** (and Hub 1.0.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).
|
||||
|
||||
@@ -8,6 +8,8 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.41.2 | Background jobs page: last-run times were 2 h ahead (Prisma's pg adapter drops the offset of timestamptz in raw queries — read pg-boss times `AT TIME ZONE 'UTC'`); `APP_TIMEZONE` documented |
|
||||
| 1.41.1 | Licence fixes — no licence (never connected, or the Hub answers `INSTALL_NOT_FOUND`, which now drops the old licence) is read-only like suspended; read-only also blocks editing events/options/variants; heartbeat 30 s after start and after connecting; `LICENCE_CHECK_DISABLED` for e2e |
|
||||
| 1.41.0 | Hope Events Hub connection — licence step in `/setup` and Site settings → Licence, 15-minute heartbeat (health, totals, income bands), licence states checked offline (grace banner for site admins only, read-only when suspended/revoked), `/health`, "update available", `scripts/install.sh`. The Hub itself is `joshua/events-hub` 1.0.0 |
|
||||
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
|
||||
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
|
||||
@@ -165,7 +167,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
active / grace / suspended / revoked / unlicensed, cached 30 s), `client.js` (connect, heartbeat;
|
||||
AppSetting `hub_*` keys, which the generic settings endpoints never touch), `report.js` (health,
|
||||
totals, income bands), `readOnly.js` (the routes blocked in read-only mode — add any new
|
||||
"create" route there).
|
||||
"create" or event-edit route there). Read-only = suspended, revoked or unlicensed.
|
||||
- Job `hub-heartbeat` (every 15 min), routes `/api/hub/*`, `/health`.
|
||||
- Frontend: `components/admin/LicenceTab.tsx` (Site settings → Licence; its `ConnectForm` is also on
|
||||
the last `/setup` screen), `components/shared/LicenceBanner.tsx` (site admins only).
|
||||
|
||||
@@ -140,9 +140,9 @@ out its state:
|
||||
|-------|------|--------------|
|
||||
| **active** | `status` is active and now is before `min(licenceExpiresAt, validUntil)` | Everything works |
|
||||
| **grace** | after that date, for up to `graceDays` | Everything works; **only site admins** see a banner "Licence needs renewing — X days left" |
|
||||
| **suspended** | `status` is suspended, or grace has run out | Read-only: no new events, registrations or payments. Tickets still scan; admins can view and export all data; privacy requests still work |
|
||||
| **suspended** | `status` is suspended, or grace has run out | Read-only: events can't be created or edited; no new registrations or payments. Tickets still scan; admins can view and export all data; privacy requests still work |
|
||||
| **revoked** | `status` is revoked | Same as suspended, with a permanent banner |
|
||||
| **unlicensed** | never connected | Works; admins see "Connect to Hub" |
|
||||
| **unlicensed** | never connected, or the Hub answered `INSTALL_NOT_FOUND` (the install drops its token) | Same as suspended (events can't be created or edited either); admins see "Connect to Hub" |
|
||||
|
||||
`validUntil` is refreshed by every heartbeat, so a Hub outage only matters after 30 days (then
|
||||
grace starts). Blocking the Hub can't keep a suspended licence active for more than 30 days.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -601,7 +601,7 @@ export default function SetupPage() {
|
||||
<p className="text-sm text-green-700">✓ Connected — licensed to {licenceConnected}.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">Enter the licence key you received to connect this site to the Hope Events Hub. You can also do this later under Admin → Site Settings → Licence.</p>
|
||||
<p className="text-xs text-gray-500">Enter the licence key you received to connect this site to the Hope Events Hub. Until a licence is connected, events can't be created or edited. You can also do this later under Admin → Site Settings → Licence.</p>
|
||||
<ConnectForm token={setupToken} compact onConnected={s => setLicenceConnected(s.licence?.customer || "your organisation")} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -26,9 +26,9 @@ export type HubStatus = {
|
||||
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." },
|
||||
suspended: { label: "Suspended", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: no new events, registrations or payments. Tickets still scan and all data can be exported." },
|
||||
revoked: { label: "Revoked", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: no new events, registrations or payments. Tickets still scan and all data can be exported." },
|
||||
unlicensed: { label: "Not connected", tone: "bg-gray-50 text-gray-700 border-gray-200", text: "Connect this site to the Hope Events Hub with your licence key." },
|
||||
suspended: { label: "Suspended", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: events can't be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported." },
|
||||
revoked: { label: "Revoked", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: events can't be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported." },
|
||||
unlicensed: { label: "No licence", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only until you connect a licence key: events can't be created or edited, and no new registrations or payments." },
|
||||
};
|
||||
|
||||
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
@@ -100,7 +100,7 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Licence</h2>
|
||||
<p className="text-sm text-gray-500">This site checks in with the Hope Events Hub every 15 minutes: version, health and totals only — never anyone's personal information.</p>
|
||||
<p className="text-sm text-gray-500">This site checks in with the Hope Events Hub 30 seconds after starting and every 15 minutes after that: version, health and totals only — never anyone's personal information.</p>
|
||||
</div>
|
||||
|
||||
<div className={`border rounded-xl p-4 ${st.tone}`}>
|
||||
|
||||
@@ -37,16 +37,16 @@ export function LicenceBanner() {
|
||||
<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>
|
||||
The licence is {status.state === "revoked" ? "revoked" : "suspended"}: the site is read-only — no new events, registrations or payments. Tickets still scan and all data can be exported. {link}
|
||||
The licence is {status.state === "revoked" ? "revoked" : "suspended"}: the site is read-only — events can't be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported. {link}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status.state === "unlicensed" && status.keyConfigured) {
|
||||
if (status.state === "unlicensed" && status.readOnly) {
|
||||
return (
|
||||
<div role="status" className="bg-gray-50 border-b text-gray-700 text-sm px-4 py-2 flex items-center gap-2">
|
||||
<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">
|
||||
<KeyRound className="w-4 h-4 shrink-0" />
|
||||
<span>This site isn't connected to the Hope Events Hub yet. {link}</span>
|
||||
<span>This site has no licence, so it's read-only — events can't be created or edited. Connect your licence key in {link}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.2",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.2",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
@@ -46,6 +46,8 @@ export default defineConfig({
|
||||
FRONTEND_URL: `http://localhost:${WEB_PORT}`,
|
||||
APP_BASE_URL: `http://localhost:${WEB_PORT}`,
|
||||
JOBS_ENABLED: "false",
|
||||
// The test site has no licence; without this it would be read-only.
|
||||
LICENCE_CHECK_DISABLED: "true",
|
||||
SCHEDULED_EMAILS_ENABLED: "false",
|
||||
// The kiosk test signs a supervisor in with a password (no 2FA code by email).
|
||||
TWO_FACTOR_REQUIRED_ROLES: "false",
|
||||
|
||||
Reference in New Issue
Block a user