Compare commits

..
Author SHA1 Message Date
joshua ce22f44f7c Merge branch 'fix/mobile-cookie-banner' 2026-10-20 12:47:41 +02:00
joshuaandClaude Opus 5.5 30b567f091 Bump to 1.25.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:47:36 +02:00
joshuaandClaude Opus 5.5 0b22eca0fb Lift cookie banner and help button above mobile bottom nav
The cookie consent banner was fixed at bottom-0 under the z-50 BottomNav, so
on mobile it was hidden. It now sits above the nav (h-16 + safe-area) and
publishes its height as --cookie-banner-h, which HelpFab adds to its offset
so the banner no longer covers the help button either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:47:13 +02:00
joshua 5529811119 Merge branch 'feat/yoco-settings' 2026-10-20 10:28:57 +02:00
joshuaandClaude Opus 5.5 1f0b2b1425 Bump to 1.25.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:53 +02:00
joshuaandClaude Opus 5.5 865a16abe9 Store Yoco secrets in the database; add Yoco settings tab
The Yoco secret key and webhook secret now live in AppSetting (AES-256-GCM
encrypted), with .env as a fallback. A new Site Settings → Yoco tab shows
them masked card-style, registers a webhook with Yoco (storing the secret it
returns) or accepts one entered by hand, and lists/deletes the account's
webhooks. Generic settings endpoints never read or write these keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:50 +02:00
joshua 921b94a66a Merge branch 'feat/privacy-transfer-accept' 2026-10-19 18:46:43 +02:00
joshuaandClaude Opus 5.5 b3bfbe486c Bump to 1.24.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:46:25 +02:00
joshuaandClaude Opus 5.5 686616cc18 Require acceptance for ticket transfers; privacy and legal updates
Transfers now send the new holder a link (to the email/phone typed) and
only complete once they accept; they can decline and the sender can
cancel. No response reveals whether contact details belong to an
account, and attendee account links are no longer sent to payers.
Terms of Use and Privacy Policy are updated for all features since
1.12, with a full cookie policy and a "Cookie settings" control. The
/docs route list now covers every mounted route, and account
activation auto-login is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:46:15 +02:00
joshua cd42ca375e Merge branch 'feat/impersonate-user' 2026-10-19 18:25:10 +02:00
joshuaandClaude Opus 5.5 ccb4db919a Bump to 1.23.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:25:08 +02:00
joshuaandClaude Opus 5.5 2b453b00e6 Add read-only admin impersonation
Admins (with 2FA) can view the site as a non-admin user for up to 30
minutes, with a required reason and a "Viewing as" banner. It is
strictly read-only: a guard mounted before every route refuses any
write made with an impersonation token, protect/optionalAuth check the
session is live, and GET handlers skip the price-refresh writes. Start,
end, reason and every page/API call (including blocked attempts) are
audited; optional email to the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:25:02 +02:00
joshua fb8ce5f86d Merge branch 'feat/funnel-stats' 2026-10-19 18:14:29 +02:00
62 changed files with 2257 additions and 134 deletions
+74
View File
@@ -7,6 +7,80 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.25.1] - 2026-10-20
### Fixed
- On mobile, the cookie consent banner now sits above the bottom navigation bar instead of being hidden behind it.
- The floating help button moves up above the cookie consent banner while it is showing, so the banner no longer covers it.
## [1.25.0] - 2026-10-20
### Added
- A **Yoco** tab in Site Settings for online payments:
- **Secret key:** shown masked like a card number (`sk_test_***…aba`) with a Test/Live badge. Yoco checks a new key before it is saved.
- **Webhook secret:** **Generate** registers this server's webhook URL (`BACKEND_URL/api/webhooks/yoco`, editable) with Yoco and saves the secret Yoco returns. **Enter manually** takes the secret of a webhook you registered yourself.
- **Registered webhooks:** every webhook on the Yoco account, with its URL and mode. It shows which one is active and warns when a URL isn't this server. Each can be deleted; deleting the active one also removes its stored secret.
- New admin endpoints under `/api/yoco` (config and webhooks). Changes are written to the admin audit log, without the secret values.
### Changed
- The Yoco secret key and webhook secret are now stored in the database, AES-256-GCM encrypted, instead of only in `backend/.env`. `YOCO_SECRET_KEY` and `YOCO_WEBHOOK_SECRET` still work as fallbacks when the database has no value. `npm run migrate:env-to-db` moves them across.
- Yoco secrets are never returned by `/api/settings/all` and can't be changed through `PUT /api/settings`.
- Starting a checkout or sponsorship without a Yoco key now gives a clear "online payments are not configured" message instead of failing at Yoco.
## [1.24.0] - 2026-10-19
### Changed
- Ticket transfers now need the new holder to accept.
- "Transfer" (in My Events, or At the door → Tickets) sends the person a link by email and/or WhatsApp, to the details typed.
- Nothing changes on the ticket until they accept on the new `/transfer/<link>` page. They can also decline.
- On accepting, the transfer completes as before: new QR code, ticket delivery, account activation link, parental consent on youth events.
- My Events shows "Waiting for … to accept" with a Cancel button.
- Offers expire after 7 days or when the event starts, and a new offer replaces an open one.
- Privacy for group tickets and transfers: there is no user search, and nothing reveals whether an email or phone number belongs to an account.
- Registration responses no longer include named attendees' account links.
- If an email and phone point at two different accounts, this is no longer reported to the person typing them. The email's account is used.
- Terms of Use and Privacy Policy updated for everything added since 1.12:
- booking for other people, ticket transfers, and youth-event consent with health information and how long it is kept;
- who can see what (door staff, group leaders, supervisors), and read-only admin support access;
- sponsorships, waitlists, discount codes and feedback;
- two-factor authentication and offline scanning.
- The Privacy Policy has a full **cookie policy**: every item the site stores in the browser (necessary, staff-only and optional), the optional visit statistics, and the third-party cookies from the Google Maps embed and Yoco checkout.
- A new "Cookie settings" link in the footer (and a button in the policy) lets visitors change their choice.
- The API docs page (`/docs`) now lists every mounted route. 45 were missing, including dashboard stats, backups, attachments, the audit log, ticket transfer links and WhatsApp admin.
### Fixed
- After activating an account, the visitor is now really signed in. The token was saved under a key the app never read, so the "auto-login" didn't work.
### Notes
- `npm install` succeeds in the root, `backend` and `frontend` with no missing packages.
- The remaining `npm audit` findings are all in development/build tools (Jest, nodemon, ESLint, Tailwind's build), and none affect the running site.
- `braces` and `sprintf-js` have no patched release.
- `postcss-selector-parser` 7 would break Tailwind 3.
- Requires the `add_transfer_requests` database migration.
## [1.23.0] - 2026-10-19
### Added
- Impersonate user (Admin → Manage users → Impersonate): an admin can see the site exactly as a user does, to help with problems such as "my ticket isn't showing".
- A reason is required, and the session lasts at most 30 minutes.
- A bright "Viewing as [name]" banner with a countdown and "End impersonation" shows on every page.
- Impersonation is strictly read-only.
- A server-side guard, mounted before every route, refuses any change (registrations, payments, refunds, cancellations, profile, consent, messages and everything else) made with an impersonation token.
- Viewing pages doesn't trigger the price refresh it normally does.
- Admins can't be impersonated, and the admin must have two-factor authentication on.
- Impersonation is recorded in the audit log: start, end, reason, and every page and API call, including blocked attempts.
- The audit log page lists recent sessions with their full trail.
- New setting: email users when an admin views their account (Site Settings → Legal → Support access; off by default).
- New `/api/admin/impersonate` endpoints.
- Requires the `add_impersonation` database migration.
## [1.22.0] - 2026-10-19
### Added
+20 -4
View File
@@ -164,13 +164,25 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
## Payments, donations & refunds
- **Online card payments via Yoco** — checkout for the full outstanding balance,
confirmed by a signed webhook.
confirmed by a signed webhook. Keys and the webhook are set up from
Site Settings → Yoco.
- **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
Each payment records **who recorded it** as well as who it's for.
- **Refunds** — negative payments against an original payment, netted against
the original method.
- **Impersonate user** (Admin → Manage users) — an admin with 2FA can view the site as a
user, to debug problems.
- It needs a reason, lasts at most 30 minutes, and shows a bright "Viewing as" banner
on every page.
- It is strictly read-only. A server-side guard refuses every write made with the
impersonation token before it reaches any route, and pages don't even refresh
prices while viewing.
- Admins can't be impersonated.
- The start, end, reason and every page and API call are in the audit log, with each
session's trail.
- Optional email to the user (`notify_user_on_impersonation`).
- **Registration funnel stats** — first-party and consent-based, with no third-party analytics.
- A cookie notice asks once. Only visitors who allow it get a random visit id.
- We record event page views and registration starts. Submits and payments come
@@ -295,9 +307,12 @@ logout controls. Disconnected sessions are recovered automatically.
- Registration detail: itemised bill (early-bird lines grouped), payments,
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
- **Ticket transfer** — hand a ticket to someone else before the event (name plus
email/phone). They get a new ticket and QR code, and an inactive account with an
activation link if they're new. The old code stops working, and scanning it says it
- **Ticket transfer** — offer a ticket to someone else before the event by typing
their name and email/phone (no user search, and nothing reveals whether they have an
account). They get a link to accept or decline; the ticket only moves once they
accept, and the sender can cancel until then (offers expire after 7 days or at the
event start). On accepting they get a new ticket and QR code, and an inactive account
with an activation link if they're new. The old code stops working, and scanning it says it
was transferred, both online and in offline-mode sync reports. One place can be split
off a ticket covering several people. Billing stays with the payer. It's not allowed
once a ticket has been scanned, and it can be turned off per event (supervisors can
@@ -442,6 +457,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
| Yoco | Secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
| Backups | List, run now, download database backups |
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
+1 -2
View File
@@ -59,8 +59,6 @@ Documentation: see Frontend README for environment and scripts.
- DATABASE_URL
- JWT_SECRET
- PORT (optional; defaults to 3000 if not set)
- YOCO_SECRET_KEY (for payments)
- YOCO_WEBHOOK_SECRET (to verify webhooks)
- EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM (for ticket emails)
- FRONTEND_URL or APP_BASE_URL (used in email links)
@@ -69,6 +67,7 @@ Documentation: see Frontend README for environment and scripts.
Example for default backend: NEXT_PUBLIC_API_URL=http://localhost:5000
- Organisation name, logo, brand colour, and notification emails are configured via the admin panel (Admin → Site Settings) and do not need to be set in .env files.
- Yoco payments are set up in Admin → Site Settings → Yoco: paste the secret key, then generate the webhook (or enter its secret). YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in backend/.env still work as fallbacks.
### 3) Run the apps
+11
View File
@@ -57,6 +57,16 @@ or Branding tab has been saved even once — the wizard always writes
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
## Payments (Yoco)
Managed in the Yoco tab (through `/api/yoco`, not the generic settings endpoints).
| Setting | Default when unset | Notes |
|---|---|---|
| `yoco_secret_key` | `YOCO_SECRET_KEY` env var, else *(blank)* | Stored AES-256-GCM encrypted. If blank, online checkouts and sponsorships fail with "online payments are not configured". |
| `yoco_webhook_secret` | `YOCO_WEBHOOK_SECRET` env var, else *(blank)* | Stored encrypted. If blank, Yoco webhooks are rejected, so payments aren't confirmed automatically. |
| `yoco_webhook_id` | *(blank)* | The Yoco webhook whose secret is stored, set when it's generated from the tab. Used to mark it "Active" and to clear the secret when it's deleted. |
## Legal (Terms of Use / Privacy Policy)
| Setting | Default when unset | Notes |
@@ -66,6 +76,7 @@ or Branding tab has been saved even once — the wizard always writes
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
+6 -3
View File
@@ -33,9 +33,11 @@ FRONTEND_URL=http://localhost:3000
# EMAIL_PASS=your_email_password
# EMAIL_FROM=no-reply@example.com
# ─── Payments (Yoco) ──────────────────────────────────────────────────────────
YOCO_SECRET_KEY=your_yoco_secret_key
YOCO_WEBHOOK_SECRET=your_yoco_webhook_secret
# ─── Payments (Yoco) — optional fallbacks ─────────────────────────────────────
# Preferred: set the secret key and generate the webhook in Admin → Site Settings →
# Yoco (stored AES-256 encrypted). These are only used if the database has no value.
# YOCO_SECRET_KEY=sk_test_...
# YOCO_WEBHOOK_SECRET=whsec_...
# ─── Public URLs ──────────────────────────────────────────────────────────────
# Used to build links in emails (password reset, activation, etc.)
@@ -72,6 +74,7 @@ BACKUP_ENABLED=true
# - Registration notification emails
# - SMTP settings (username and password stored AES-256-GCM encrypted, key
# derived from JWT_SECRET — do not change JWT_SECRET after saving SMTP creds)
# - Yoco secret key and webhook secret (encrypted the same way)
# - Legal page content (operator name, Information Officer, website URL, etc.)
# The first-time setup wizard handles initial configuration on fresh deployments.
# You no longer need ORG_NAME, ORG_TAGLINE, EMAIL_HEADER_COLOR, REGISTRATIONS_EMAIL,
+39 -2
View File
@@ -27,6 +27,7 @@ Tokens are issued by `POST /api/users/login`.
- [Sponsor a spot](#sponsor-a-spot)
- [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -284,6 +285,21 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Impersonation
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/admin/impersonate/:userId` | admin (2FA on) | `{ reason }` → `{ token, session }`. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users |
| POST | `/api/admin/impersonate/end` | impersonation token | End the session |
| POST | `/api/admin/impersonate/log` | impersonation token | `{ path }` — page viewed (audit trail) |
| GET | `/api/admin/impersonate/current` | impersonation token | The live session |
| GET | `/api/admin/impersonation-sessions` | admin | Sessions with their request trail |
Read-only enforcement:
- A guard mounted before every route returns `403 { code: "IMPERSONATION_READ_ONLY" }` for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions are `end` and `log`. Blocked attempts are logged.
- An ended or expired session gets `401 { code: "IMPERSONATION_ENDED" }`.
- GET handlers skip side-effect writes (pricing refresh) while `req.readOnly` is set.
## Registration funnel
| Method | Path | Auth | Description |
@@ -357,9 +373,18 @@ Related changes:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }` (date of birth, plus parent details for a minor, on youth events) |
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }` → `201`. Creates an offer and sends a link to the details given. **Nothing changes on the ticket yet.** A new offer replaces an open one |
| DELETE | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | Withdraw the open offer |
| GET | `/api/tickets/transfer-offer/:token` | link holder | `{ status, toName, fromName (first name), event, option, expiresAt }` |
| POST | `/api/tickets/transfer-offer/:token/accept` | link holder | Accept — the transfer happens now (all checks run again) |
| POST | `/api/tickets/transfer-offer/:token/decline` | link holder | Decline |
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
Privacy:
- There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account.
- Offers expire after 7 days or when the event starts.
- Only an HMAC of the link token is stored.
Rules:
- Only before the event starts, and only for tickets that have never been scanned.
- `Event.allowTicketTransfer` turns it off for non-supervisors.
@@ -681,7 +706,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
## Settings
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, Yoco secrets) are stored AES-256-GCM encrypted. Yoco secrets are never returned by `/api/settings/all` and can't be written through `PUT /api/settings`; use `/api/yoco` below.
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
@@ -693,6 +718,18 @@ For what each individual setting key defaults to when it's never been saved, see
| PUT | `/api/settings` | admin | Upsert settings; `smtp_user`/`smtp_pass` encrypted before storage; sending `••••••••` for `smtp_pass` keeps existing value |
| POST | `/api/settings/test-smtp` | admin | Test SMTP with provided credentials; sends real test email to admin on success. The admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
## Yoco
Managed from Admin → Site Settings → Yoco. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/yoco/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/api/yoco/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/api/yoco/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/api/yoco/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/api/yoco/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
### POST /api/settings/test-smtp
```json
{
+17 -4
View File
@@ -79,8 +79,8 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `JWT_SECRET` | Yes | Secret key for JWTs (32+ random characters). Also used to derive the AES-256 encryption key for sensitive settings (SMTP credentials) stored in the DB — must be set before saving SMTP settings. |
| `FRONTEND_URL` | Yes | Comma-separated allowed CORS origins |
| `YOCO_SECRET_KEY` | Yes | Yoco secret API key |
| `YOCO_WEBHOOK_SECRET` | Yes | Yoco webhook HMAC secret |
| `YOCO_SECRET_KEY` | — | Fallback Yoco secret API key (preferred: set via Admin → Site Settings → Yoco) |
| `YOCO_WEBHOOK_SECRET` | — | Fallback Yoco webhook secret (preferred: generate or enter it via Admin → Site Settings → Yoco) |
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
| `PORT` | — | Port to listen on (default `3000`) |
@@ -99,7 +99,9 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
> **Organisation name, branding colour, logo, registration notification email, SMTP settings, and legal page content** are now managed through the admin panel at **Admin → Site Settings** and stored in the database. You no longer need `ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, or SMTP vars in `.env` (they still work as fallbacks). On a fresh deployment these are set via the first-time setup wizard.
>
> **SMTP credentials** (`smtp_user`, `smtp_pass`) are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving SMTP credentials — the stored values will become unreadable.
> **SMTP credentials** (`smtp_user`, `smtp_pass`) and the **Yoco secret key and webhook secret** are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving them — the stored values will become unreadable.
>
> To move existing Yoco values from `.env` into the database, run `npm run migrate:env-to-db` (or save them in the Yoco tab).
---
@@ -333,6 +335,15 @@ Full interactive API docs available at `GET /docs` (requires admin JWT — pass
| PUT | `/` | admin | Upsert settings; `smtp_user` and `smtp_pass` are AES-256-GCM encrypted before storage; sending `••••••••` for `smtp_pass` is a no-op (keeps existing value) |
| POST | `/test-smtp` | admin | Test SMTP connection with provided credentials; success returns `{ message }`, failure returns `{ message, raw }` where `message` is human-readable and `raw` is the original SMTP error. The short-lived admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
#### Yoco — `/api/yoco`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
#### Setup — `/api/setup`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -500,6 +511,8 @@ The resolution priority at registration/payment time is: **variant tier → vari
## Payments (Yoco)
Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with Yoco before saving), then **Generate** a webhook — this registers `BACKEND_URL/api/webhooks/yoco` with Yoco and stores the secret Yoco returns (it's only shown once). Or **Enter** the secret of a webhook you registered yourself. The tab also lists the account's webhooks and can delete them.
1. Frontend calls `POST /api/payments/yoco-checkout`
2. Backend calls `refreshPricingForRegistration` — if any early-bird price changed, returns `{ priceUpdated: true, newTotal }` (HTTP 200) and does **not** create a checkout. Frontend shows warning; user retries.
3. On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns `{ redirectUrl }`.
@@ -524,6 +537,6 @@ pm2 save && pm2 startup
- [ ] `JWT_SECRET` is long (32+ chars) and secret
- [ ] `FRONTEND_URL` lists only production origins
- [ ] `NODE_ENV=production`
- [ ] Yoco webhook registered and HTTPS
- [ ] Yoco secret key saved and webhook generated (Site Settings → Yoco), over HTTPS
- [ ] WAWP credentials saved via admin panel
- [ ] SMTP credentials correct
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.22.0",
"version": "1.25.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.22.0",
"version": "1.25.1",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^5.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.22.0",
"version": "1.25.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,55 @@
-- 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 'impersonation_started';
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_ended';
-- CreateTable
CREATE TABLE "ImpersonationSession" (
"id" TEXT NOT NULL,
"adminId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"endedAt" TIMESTAMP(3),
"endedReason" TEXT,
CONSTRAINT "ImpersonationSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImpersonationRequestLog" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"blocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImpersonationRequestLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImpersonationSession_adminId_idx" ON "ImpersonationSession"("adminId");
-- CreateIndex
CREATE INDEX "ImpersonationSession_targetUserId_idx" ON "ImpersonationSession"("targetUserId");
-- CreateIndex
CREATE INDEX "ImpersonationRequestLog_sessionId_createdAt_idx" ON "ImpersonationRequestLog"("sessionId", "createdAt");
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationRequestLog" ADD CONSTRAINT "ImpersonationRequestLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ImpersonationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "TicketTransferRequest" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"toName" TEXT NOT NULL,
"toEmail" TEXT,
"toPhone" TEXT,
"details" JSONB,
"tokenHash" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"expiresAt" TIMESTAMP(3) NOT NULL,
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransferRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TicketTransferRequest_tokenHash_key" ON "TicketTransferRequest"("tokenHash");
-- CreateIndex
CREATE INDEX "TicketTransferRequest_ticketId_status_idx" ON "TicketTransferRequest"("ticketId", "status");
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- 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 'yoco_config_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_registered';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_deleted';
+62
View File
@@ -111,6 +111,9 @@ model User {
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
feedbackInvites FeedbackInvite[]
feedbackGiven FeedbackResponse[]
impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin")
transferRequests TicketTransferRequest[] @relation("TransferRequester")
impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
@@ -387,6 +390,7 @@ model Ticket {
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
usages TicketUsage[]
transfers TicketTransfer[]
transferRequests TicketTransferRequest[]
@@index([holderUserId])
@@index([eventId])
@@ -499,6 +503,11 @@ enum AdminAuditAction {
scan_lock_force_released
offline_sync
ticket_transferred
impersonation_started
impersonation_ended
yoco_config_updated
yoco_webhook_registered
yoco_webhook_deleted
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -854,6 +863,37 @@ enum TicketDelivery {
// One named person on a group registration, for one main-ticket place. Gets their
// own quantity-1 ticket and their own attendee form response.
/// An admin viewing the site as another user (strictly read-only, at most 30 minutes).
model ImpersonationSession {
id String @id @default(uuid())
adminId String
targetUserId String
reason String
startedAt DateTime @default(now())
expiresAt DateTime
endedAt DateTime?
endedReason String? // 'ended' | 'expired'
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade)
target User @relation("ImpersonationTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
requests ImpersonationRequestLog[]
@@index([adminId])
@@index([targetUserId])
}
/// Every page viewed and API call made during an impersonation session.
model ImpersonationRequestLog {
id String @id @default(uuid())
sessionId String
method String
path String
blocked Boolean @default(false)
createdAt DateTime @default(now())
session ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId, createdAt])
}
/// First-party funnel tracking (only after cookie consent): one row per anonymous
/// session, event and stage ('view' | 'start' | 'submit'). "Paid" comes from the
/// registration that carries the session id. Rows older than 90 days are rolled up
@@ -1012,6 +1052,28 @@ model AllocationAssignment {
@@index([attendeeId])
}
/// A transfer waiting for the new holder to accept it from the link sent to the email /
/// phone the sender typed. Nothing changes on the ticket until it's accepted. `details`
/// holds what the sender entered (date of birth, gender, parent details on youth events).
model TicketTransferRequest {
id String @id @default(uuid())
ticketId String
requestedById String
toName String
toEmail String?
toPhone String?
details Json?
tokenHash String @unique
status String @default("pending") // pending | accepted | declined | cancelled | expired
expiresAt DateTime
respondedAt DateTime?
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
requestedBy User @relation("TransferRequester", fields: [requestedById], references: [id], onDelete: Cascade)
@@index([ticketId, status])
}
/// A ticket handed to someone else. oldQrCode is the code that stopped working (null
/// when one place was split off a shared group ticket, whose code stays valid).
model TicketTransfer {
+5 -1
View File
@@ -52,7 +52,7 @@ function encrypt(plaintext) {
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
}
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
// ─── Mapping: env var(s) → DB key ────────────────────────────────────────────
// Each entry: { key: DB key, envVars: [...candidates in priority order], encrypt?: bool }
@@ -79,6 +79,10 @@ const MAPPINGS = [
{ key: 'smtp_user', envVars: ['SMTP_USER', 'EMAIL_USER'], encrypt: true },
{ key: 'smtp_pass', envVars: ['SMTP_PASS', 'EMAIL_PASS'], encrypt: true },
// Yoco payments
{ key: 'yoco_secret_key', envVars: ['YOCO_SECRET_KEY'], encrypt: true },
{ key: 'yoco_webhook_secret', envVars: ['YOCO_WEBHOOK_SECRET'], encrypt: true },
// App URLs (used in email templates)
{ key: 'app_base_url', envVars: ['FRONTEND_URL', 'APP_BASE_URL'] },
];
@@ -0,0 +1,108 @@
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { MAX_MINUTES, impersonationBlocker, logRequest } = require('../utils/impersonation');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const err = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
// @desc Start viewing the site as a user (read-only, 30 minutes)
// @route POST /api/admin/impersonate/:userId { reason }
// @access Private/Admin (with 2FA)
const startImpersonation = async (req, res) => {
try {
if (req.readOnly) throw err('End the current impersonation first.', 403);
const reason = String(req.body?.reason || '').trim();
if (reason.length < 5) throw err('Give a reason (at least a few words) — it is recorded in the audit log.');
const target = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true } });
const blocker = impersonationBlocker(req.user, target);
if (blocker) throw err(blocker, target ? 403 : 404);
// One live session per admin.
await prisma.impersonationSession.updateMany({ where: { adminId: req.user.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'replaced' } });
const expiresAt = new Date(Date.now() + MAX_MINUTES * 60 * 1000);
const session = await prisma.impersonationSession.create({ data: { adminId: req.user.id, targetUserId: target.id, reason: reason.slice(0, 500), expiresAt } });
const token = jwt.sign({ id: target.id, role: target.role, tokenVersion: target.tokenVersion, imp: session.id }, process.env.JWT_SECRET, { expiresIn: `${MAX_MINUTES}m` });
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
});
// Optional: tell the user (Site Settings → Security).
const { getSettingSync } = require('../utils/settingsCache');
if (getSettingSync('notify_user_on_impersonation', 'false') === 'true' && target.email && !target.email.endsWith('@guest.local')) {
const { sendMail } = require('../utils/email');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
sendMail({
to: target.email,
subject: `An administrator viewed your account — ${orgName}`,
text: `Hi ${target.name || 'there'},\n\nAn administrator at ${orgName} viewed your account (read-only) to help with support.\nReason given: ${session.reason}\n\nNothing was changed. If you have questions, reply to this email.\n\n${orgName}`,
}).catch(() => {});
}
res.status(201).json({ token, session: { id: session.id, expiresAt, target: { id: target.id, name: target.name, email: target.email, role: target.role } } });
} catch (error) {
fail(res, error);
}
};
// @desc End impersonation (called with the impersonation token)
// @route POST /api/admin/impersonate/end
// @access Impersonation token
const endImpersonation = async (req, res) => {
try {
const sessionId = req.impersonation?.id;
if (!sessionId) throw err('Not impersonating.', 400);
const s = await prisma.impersonationSession.update({ where: { id: sessionId }, data: { endedAt: new Date(), endedReason: 'ended' } });
const count = await prisma.impersonationRequestLog.count({ where: { sessionId } });
logAdminAction({
actorId: s.adminId, actorRole: 'admin', action: 'impersonation_ended', targetType: 'User', targetId: s.targetUserId,
metadata: { sessionId, minutes: Math.round((Date.now() - s.startedAt.getTime()) / 60000), requests: count }, ip: getClientIp(req),
});
res.json({ message: 'Impersonation ended.' });
} catch (error) {
fail(res, error);
}
};
// @desc Record a page the admin viewed while impersonating
// @route POST /api/admin/impersonate/log { path }
// @access Impersonation token
const logPageView = async (req, res) => {
if (req.impersonation?.id) logRequest(req.impersonation.id, 'PAGE', String(req.body?.path || '').slice(0, 500));
res.status(204).end();
};
// @desc The current impersonation session (for the banner)
// @route GET /api/admin/impersonate/current
// @access Impersonation token
const currentImpersonation = async (req, res) => {
const s = req.impersonation;
if (!s) return res.json(null);
res.json({ id: s.id, expiresAt: s.expiresAt, target: { id: req.user.id, name: req.user.name, email: req.user.email } });
};
// @desc Impersonation sessions with their request trail (audit log page)
// @route GET /api/admin/impersonation-sessions
// @access Private/Admin
const listSessions = async (req, res) => {
try {
const sessions = await prisma.impersonationSession.findMany({
orderBy: { startedAt: 'desc' },
take: Math.min(parseInt(req.query.limit, 10) || 50, 200),
include: {
admin: { select: { name: true, email: true } },
target: { select: { name: true, email: true } },
requests: { orderBy: { createdAt: 'asc' }, take: 500, select: { method: true, path: true, blocked: true, createdAt: true } },
},
});
res.json(sessions);
} catch (error) {
fail(res, error);
}
};
module.exports = { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions };
+5 -4
View File
@@ -6,6 +6,7 @@ const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
@@ -987,7 +988,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } }
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
await prisma.registration.update({
@@ -1090,7 +1091,7 @@ const createYocoCheckout = async (req, res) => {
lineItems.push({ displayName: 'Partial Payment', quantity: 1, pricingDetails: { price: -Math.round((remainingAmount - charge) * 100) } });
}
const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems };
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } });
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
}
@@ -1151,7 +1152,7 @@ const createYocoCheckout = async (req, res) => {
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } }
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
return res.status(200).json({
@@ -1163,7 +1164,7 @@ const createYocoCheckout = async (req, res) => {
}
} catch (error) {
console.error('Yoco checkout error:', error.response?.data || error.message);
res.status(error.response?.status || 400).json({
res.status(error.statusCode || error.response?.status || 400).json({
message: error.response?.data?.message || error.message
});
}
@@ -586,7 +586,7 @@ const getUserRegistrations = async (req, res) => {
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
@@ -663,7 +663,7 @@ const getRegistrationById = async (req, res) => {
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
// whenever it's viewed, not just at payment time.
if (registration.status === 'pending' || registration.status === 'partial_paid') {
if ((registration.status === 'pending' || registration.status === 'partial_paid') && !req.readOnly) {
await refreshPricingForRegistration(registration.id).catch(() => {});
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
}
@@ -876,7 +876,7 @@ const getRegistrationsByEvent = async (req, res) => {
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
@@ -2254,7 +2254,7 @@ const updateAttendees = async (req, res) => {
where: { id: registration.id },
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
});
res.json(updated);
res.json({ ...updated, attendees: updated.attendees.map(({ userId, ...a }) => a) });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
@@ -8,6 +8,10 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
// Yoco secrets are only readable (masked) and writable through /api/yoco — never
// through the generic settings endpoints.
const YOCO_PRIVATE_KEYS = new Set(['yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id']);
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
@@ -44,6 +48,7 @@ const getAllSettings = async (req, res) => {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
if (YOCO_PRIVATE_KEYS.has(r.key)) continue;
if (ENCRYPTED_KEYS.has(r.key)) {
// Return a sentinel so the UI knows the value is set, without exposing it.
// smtp_user (email address) we can safely return as-is after decryption so
@@ -79,7 +84,7 @@ const updateSettings = async (req, res) => {
}
const ops = Object.entries(updates)
.filter(([, v]) => v !== undefined && v !== null)
.filter(([k, v]) => v !== undefined && v !== null && !YOCO_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
@@ -118,7 +123,7 @@ const updateSettings = async (req, res) => {
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !YOCO_PRIVATE_KEYS.has(k)) },
ip: getClientIp(req),
});
}
@@ -195,7 +200,7 @@ const runSetup = async (req, res) => {
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !YOCO_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
@@ -1,4 +1,5 @@
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
@@ -87,7 +88,7 @@ const createSponsorCheckout = async (req, res) => {
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
};
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
headers: { 'Content-Type': 'application/json', Authorization: await yocoAuthHeader(), 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
});
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
} catch (error) {
+76 -7
View File
@@ -103,6 +103,7 @@ const getUserTickets = async (req, res) => {
},
user: { select: { id: true, name: true } },
event: true,
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
scannedBy: {
@@ -703,20 +704,84 @@ const sendTicketsTo = async (req, res) => {
}
};
// @desc Transfer a ticket to someone else (new QR code; billing stays with the payer)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
// @desc Offer a ticket to someone else: sends them a link to accept (nothing changes until they do)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }
// @access Private (payer or holder; supervisors always)
const transferTicketHandler = async (req, res) => {
try {
const { transferTicket } = require('../utils/ticketTransfer');
const { requestTransfer } = require('../utils/ticketTransfer');
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
const { request, sent } = await requestTransfer({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
res.status(201).json({
message: sent.length
? `We've sent ${request.toName} a link by ${sent.join(' and ')} to accept the ticket. It moves to them once they accept — until then it's still yours.`
: `The transfer is waiting for ${request.toName} to accept, but the link couldn't be sent. Check the email or phone number.`,
requestId: request.id,
expiresAt: request.expiresAt,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Withdraw a transfer that hasn't been accepted yet
// @route DELETE /api/tickets/:id/transfer
// @access Private (payer or holder; supervisors)
const cancelTransferHandler = async (req, res) => {
try {
const { cancelTransfer } = require('../utils/ticketTransfer');
await cancelTransfer(req.params.id, req.user);
res.json({ message: 'Transfer cancelled — the ticket is still yours.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc What's on offer behind a transfer link
// @route GET /api/tickets/transfer-offer/:token
// @access Public (link holder)
const getTransferOffer = async (req, res) => {
try {
const { findRequestByToken } = require('../utils/ticketTransfer');
const r = await findRequestByToken(req.params.token);
if (!r) return res.status(404).json({ message: 'This transfer link isn’t valid.' });
const t = r.ticket;
res.json({
status: r.status, toName: r.toName, expiresAt: r.expiresAt,
fromName: (t.attendee?.name || t.user?.name || r.requestedBy.name || '').split(' ')[0],
event: t.event,
option: t.registrationOption.variant ? `${t.registrationOption.eventOption.name} — ${t.registrationOption.variant.name}` : t.registrationOption.eventOption.name,
});
} catch (error) {
res.status(400).json({ message: safeErrorMessage(error) });
}
};
// @route POST /api/tickets/transfer-offer/:token/decline
const declineTransferOffer = async (req, res) => {
try {
const { declineTransfer } = require('../utils/ticketTransfer');
await declineTransfer(req.params.token);
res.json({ message: 'You declined the ticket. Nothing else will happen.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc Accept a transferred ticket — the transfer happens now
// @route POST /api/tickets/transfer-offer/:token/accept
// @access Public (link holder)
const acceptTransferOffer = async (req, res) => {
try {
const { acceptTransfer } = require('../utils/ticketTransfer');
const r = await acceptTransfer(req.params.token);
const actor = r.request.requestedBy;
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode },
actorId: actor.id, actorRole: actor.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode, acceptedVia: 'link' },
ip: getClientIp(req),
});
@@ -744,7 +809,7 @@ const transferTicketHandler = async (req, res) => {
}
res.json({
message: `Ticket transferred to ${a.name}. We've sent them the new ticket${r.needsConsent ? ' and a parental consent request' : ''}. The old QR code no longer works.`,
message: `The ticket is yours! We've sent it to you${r.needsConsent ? ', and asked your parent or guardian for consent' : ''}.${r.accountCreated ? ' We have also sent you a link to activate your account, where you can see your tickets.' : ''}`,
ticketId: r.target.id,
});
} catch (error) {
@@ -980,5 +1045,9 @@ module.exports = {
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
};
+4 -3
View File
@@ -4,6 +4,7 @@ const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { getYocoWebhookSecret } = require('../utils/yoco');
// @desc Handle Yoco webhook events
// @route POST /api/webhooks/yoco
@@ -54,10 +55,10 @@ const handleYocoWebhook = async (req, res) => {
// Construct the signed content
const signedContent = `${id}.${timestamp}.${requestBody}`;
// Get the webhook secret from environment variables
const secret = process.env.YOCO_WEBHOOK_SECRET;
// Webhook secret from Site Settings → Yoco (falls back to .env)
const secret = await getYocoWebhookSecret();
if (!secret) {
console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables');
console.error('Yoco webhook secret is not configured (Site Settings → Yoco, or YOCO_WEBHOOK_SECRET)');
const responseObj = {
success: false,
message: 'Server configuration error',
+149
View File
@@ -0,0 +1,149 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { encrypt } = require('../utils/encryption');
const { invalidate: invalidateSettingsCache, warmCache, getSetting } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
} = require('../utils/yoco');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// Write settings (null/'' clears), then refresh the cache so the new values apply immediately.
async function saveSettings(values) {
const ops = Object.entries(values).map(([key, value]) => {
const stored = value ? (key === KEY_WEBHOOK_ID ? value : encrypt(value)) : '';
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
});
await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache();
}
const audit = (req, action, metadata) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'AppSetting', metadata, ip: getClientIp(req),
});
// @desc Yoco configuration with secrets masked
// @route GET /api/yoco/config
// @access Admin
const getConfig = async (req, res) => {
try {
const [secretKey, webhookSecret, webhookId, source] = await Promise.all([
getYocoSecretKey(), getYocoWebhookSecret(), getSetting(KEY_WEBHOOK_ID), getYocoConfigSource(),
]);
res.json({
secretKeyMasked: maskSecret(secretKey),
secretKeySource: source.secretKey,
mode: yocoMode(secretKey),
webhookSecretMasked: maskSecret(webhookSecret),
webhookSecretSource: source.webhookSecret,
webhookId: webhookId || '',
webhookUrl: defaultWebhookUrl(req),
configured: !!(secretKey && webhookSecret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Save the secret key and/or webhook secret ('' clears)
// @route PUT /api/yoco/config
// @access Admin
const updateConfig = async (req, res) => {
try {
const { secretKey, webhookSecret } = req.body || {};
const updates = {};
if (typeof secretKey === 'string') {
const key = secretKey.trim();
if (key && !/^sk_(test|live)_\S+$/.test(key)) {
res.status(400); throw new Error('The secret key should start with sk_test_ or sk_live_.');
}
// Check Yoco accepts the key before replacing a working one.
if (key) await listWebhooks(key);
updates[KEY_SECRET] = key;
}
if (typeof webhookSecret === 'string') {
const secret = webhookSecret.trim();
if (secret && !/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) {
res.status(400); throw new Error('The webhook secret should start with whsec_.');
}
updates[KEY_WEBHOOK] = secret;
// A typed-in secret isn't tied to a webhook we registered.
updates[KEY_WEBHOOK_ID] = '';
}
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
await saveSettings(updates);
audit(req, 'yoco_config_updated', { changedKeys: Object.keys(updates) });
res.json({ message: 'Yoco settings saved' });
} catch (e) {
fail(res, e);
}
};
// @desc Webhooks registered on the Yoco account
// @route GET /api/yoco/webhooks
// @access Admin
const getWebhooks = async (req, res) => {
try {
const [hooks, activeId] = await Promise.all([listWebhooks(), getSetting(KEY_WEBHOOK_ID)]);
const here = defaultWebhookUrl(req);
res.json({
webhooks: hooks.map(h => ({ ...h, isActive: !!activeId && h.id === activeId, pointsHere: !!here && h.url === here })),
defaultUrl: here,
});
} catch (e) {
fail(res, e);
}
};
// @desc Register a webhook at Yoco and store the secret it returns
// @route POST /api/yoco/webhooks
// @access Admin
const createWebhook = async (req, res) => {
try {
const name = String(req.body?.name || '').trim() || 'hope-events';
const url = String(req.body?.url || '').trim() || defaultWebhookUrl(req);
const problem = webhookUrlProblem(url);
if (problem) { res.status(400); throw new Error(problem); }
const hook = await registerWebhook(name, url);
if (!hook?.secret) throw new Error('Yoco did not return a webhook secret.');
await saveSettings({ [KEY_WEBHOOK]: hook.secret, [KEY_WEBHOOK_ID]: hook.id });
audit(req, 'yoco_webhook_registered', { webhookId: hook.id, url: hook.url, mode: hook.mode });
res.status(201).json({
message: 'Webhook registered and its secret saved',
webhook: { id: hook.id, name: hook.name, url: hook.url, mode: hook.mode },
webhookSecretMasked: maskSecret(hook.secret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Delete a webhook at Yoco (clears the stored secret if it was the active one)
// @route DELETE /api/yoco/webhooks/:id
// @access Admin
const removeWebhook = async (req, res) => {
try {
const { id } = req.params;
await deleteWebhook(id);
const wasActive = (await getSetting(KEY_WEBHOOK_ID)) === id;
if (wasActive) await saveSettings({ [KEY_WEBHOOK]: '', [KEY_WEBHOOK_ID]: '' });
audit(req, 'yoco_webhook_deleted', { webhookId: id, clearedSecret: wasActive });
res.json({ message: wasActive ? 'Webhook deleted and its secret removed' : 'Webhook deleted', clearedSecret: wasActive });
} catch (e) {
fail(res, e);
}
};
module.exports = { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook };
+77
View File
@@ -103,6 +103,10 @@ const globalLimiter = rateLimit({
});
app.use(globalLimiter);
// Impersonation is strictly read-only: refuse every write made with an impersonation
// token before it can reach any route (see utils/impersonation.js).
app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
@@ -145,6 +149,7 @@ const cashupRoutes = require('./routes/cashupRoutes');
const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
const yocoRoutes = require('./routes/yocoRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
@@ -184,6 +189,7 @@ app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
app.use('/api/feedback', require('./routes/feedbackRoutes'));
app.use('/api/analytics', require('./routes/analyticsRoutes'));
app.use('/api/backups', backupRoutes);
app.use('/api/yoco', yocoRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
@@ -388,6 +394,7 @@ app.get('/docs', async (req, res) => {
// ── Endpoint data ────────────────────────────────────────────────────────
const GROUPS = [
{ title: 'Users', base: '/api/users', endpoints: [
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
{ method:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
responses:[
@@ -511,6 +518,14 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Events', base: '/api/events', endpoints: [
{ method:'GET', path:'/api/events/admin/all', auth:'admin', desc:'All events, including inactive ones' },
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
queryParams:{ limit:'Max results (default 20)' },
responses:[
@@ -588,6 +603,12 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Registrations', base: '/api/registrations', endpoints: [
{ method:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
@@ -639,6 +660,14 @@ app.get('/docs', async (req, res) => {
]},
]},
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
{ method:'GET', path:'/api/admin/impersonate/current', auth:'impersonation token', desc:'The live impersonation session' },
{ method:'GET', path:'/api/admin/impersonation-sessions', auth:'admin', desc:'Impersonation sessions with their request trail' },
]},
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
@@ -677,6 +706,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
]},
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
@@ -737,6 +770,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
responses:[
@@ -776,6 +813,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Tickets', base: '/api/tickets', endpoints: [
{ method:'GET', path:'/api/tickets', auth:'supervisor+', desc:'All tickets' },
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
@@ -855,6 +896,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Reports', base: '/api/reports', endpoints: [
{ method:'POST', path:'/api/reports/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
{ method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download',
responses:[{ status:200, desc:'PDF file (application/pdf)', body:'<binary PDF>' }]},
{ method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user',
@@ -879,6 +922,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Cashups', base: '/api/cashups', endpoints: [
{ method:'GET', path:'/api/cashups/event/:eventId/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
{ method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
@@ -900,6 +945,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Uploads', base: '/api/uploads', endpoints: [
{ method:'POST', path:'/api/uploads/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
responses:[
@@ -956,6 +1003,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
{ method:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
@@ -976,6 +1027,17 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]},
]},
{ title: 'Dashboard stats', base: '/api/stats', endpoints: [
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
]},
{ title: 'Backups', base: '/api/backups', endpoints: [
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
{ method:'POST', path:'/api/backups/run', auth:'admin', desc:'Run a backup now' },
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
]},
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
@@ -985,6 +1047,21 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
]},
{ title: 'Yoco', base: '/api/yoco', endpoints: [
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
{ method:'PUT', path:'/api/yoco/config', auth:'admin', desc:'Save the secret key and/or webhook secret (stored AES-256-GCM encrypted). A new secret key is checked against Yoco first. An empty string clears the value (the .env fallback then applies).',
request:{ body:{ secretKey:'sk_test_...', webhookSecret:'whsec_...' }},
responses:[{ status:200, desc:'Saved', body:{ message:'Yoco settings saved' }}]},
{ method:'GET', path:'/api/yoco/webhooks', auth:'admin', desc:'Webhooks registered on the Yoco account, flagged isActive (its secret is the stored one) and pointsHere (URL is this server).',
responses:[{ status:200, desc:'Success', body:{ webhooks:[{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test', isActive:true, pointsHere:true }], defaultUrl:'https://api.example.com/api/webhooks/yoco' }}]},
{ method:'POST', path:'/api/yoco/webhooks', auth:'admin', desc:'Register a webhook at Yoco (defaults: name "hope-events", URL = BACKEND_URL/api/webhooks/yoco) and store the secret Yoco returns. The URL must be public https.',
request:{ body:{ name:'hope-events', url:'https://api.example.com/api/webhooks/yoco' }},
responses:[{ status:201, desc:'Registered', body:{ message:'Webhook registered and its secret saved', webhook:{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test' }, webhookSecretMasked:'whsec_*****************1Xz' }}]},
{ method:'DELETE', path:'/api/yoco/webhooks/:id', auth:'admin', desc:'Delete a webhook at Yoco. If it was the active one, the stored webhook secret is cleared too.',
responses:[{ status:200, desc:'Deleted', body:{ message:'Webhook deleted', clearedSecret:false }}]},
]},
{ title: 'Settings', base: '/api/settings', endpoints: [
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
+24 -2
View File
@@ -48,6 +48,17 @@ const protect = async (req, res, next) => {
return next(new Error('User account is deactivated'));
}
// An admin viewing as this user: only while the session is live, and read-only.
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
if (!session || session.targetUserId !== req.user.id) {
return res.status(401).json({ message: 'The impersonation session has ended.', code: 'IMPERSONATION_ENDED' });
}
req.impersonation = session;
req.readOnly = true;
}
// Revocation check — tokenVersion in JWT must match DB
// Old tokens without tokenVersion are treated as version 0
const tokenVer = decoded.tokenVersion ?? 0;
@@ -60,7 +71,8 @@ const protect = async (req, res, next) => {
// requirement (or the user's promotion) is sent back to log in, where they
// enrol. The setup wizard's own token gets a short grace period.
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
if (!inSetupGrace && (await mustEnrolTwoFactor(req.user))) {
// (An impersonating admin has already passed 2FA themselves.)
if (!inSetupGrace && !req.impersonation && (await mustEnrolTwoFactor(req.user))) {
return res.status(401).json({
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
code: 'TWO_FACTOR_REQUIRED',
@@ -128,7 +140,17 @@ const optionalAuth = async (req, res, next) => {
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
});
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
req.user = user;
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
req.readOnly = true;
if (session && session.targetUserId === user.id) {
req.user = user;
req.impersonation = session;
}
} else {
req.user = user;
}
}
} catch {
// Token invalid or expired — proceed without user
+7
View File
@@ -1,8 +1,15 @@
const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
router.get('/impersonation-sessions', protect, admin, listSessions);
// Called with the impersonation token itself (which belongs to the viewed user).
router.post('/impersonate/end', protect, endImpersonation);
router.post('/impersonate/log', protect, logPageView);
router.get('/impersonate/current', protect, currentImpersonation);
router.post('/impersonate/:userId', protect, admin, startImpersonation);
module.exports = router;
+9
View File
@@ -15,6 +15,10 @@ const {
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
@@ -24,6 +28,11 @@ router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, transferTicketHandler);
router.delete('/:id/transfer', protect, cancelTransferHandler);
// Transfer links (public: the person the ticket is offered to)
router.get('/transfer-offer/:token', getTransferOffer);
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
router.post('/transfer-offer/:token/decline', declineTransferOffer);
router.get('/:id/transfers', protect, getTicketTransfers);
// Staff routes
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook } = require('../controllers/yocoController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
router.get('/webhooks', protect, admin, getWebhooks);
router.post('/webhooks', protect, admin, createWebhook);
router.delete('/webhooks/:id', protect, admin, removeWebhook);
module.exports = router;
+3 -5
View File
@@ -40,11 +40,9 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
*/
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
const match = await findAccountByContact({ email, phone }, db);
if (match.conflict) {
const err = new Error('That email address and phone number belong to two different accounts. Use just one of them.');
err.statusCode = 400;
throw err;
}
// Email and phone pointing at two different accounts: go with the email's account,
// without saying so — the person typing the details mustn't learn who has an account.
if (match.conflict) return { user: match.emailUser, created: false };
if (match.user) return { user: match.user, created: false };
const { v4: uuidv4 } = require('uuid');
const { hashPassword } = require('../config/auth');
+80
View File
@@ -0,0 +1,80 @@
/**
* Admin "view as user" — strictly read-only.
*
* An impersonation token is an ordinary JWT for the target user plus an `imp` claim (the
* ImpersonationSession id), valid for at most 30 minutes. Three layers keep it read-only:
* 1. readOnlyGuard (mounted before every route): any request carrying an impersonation
* token that isn't GET/HEAD/OPTIONS is refused, apart from ending the session and
* logging page views. No controller can be reached for a write.
* 2. protect / optionalAuth check the session is live and set req.readOnly.
* 3. GET handlers that write as a side effect (price refresh on view) skip it when
* req.readOnly is set.
* The only rows an impersonation session writes are its own session and request log.
*/
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
const h = req.headers.authorization;
if (!h || !h.startsWith('Bearer ')) return null;
try {
const decoded = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
return decoded.imp ? String(decoded.imp) : null;
} catch {
return null;
}
}
/** Whether this request may go ahead while impersonating. Pure. */
function allowedWhileImpersonating(method, path) {
if (SAFE_METHODS.includes(String(method).toUpperCase())) return true;
const p = String(path || '').split('?')[0].replace(/\/+$/, '');
return method.toUpperCase() === 'POST' && ALLOWED_WRITES.includes(p);
}
function logRequest(sessionId, method, path, blocked = false) {
prisma.impersonationRequestLog.create({ data: { sessionId, method, path: String(path).slice(0, 500), blocked } }).catch(() => {});
}
/** Layer 1 — mounted in index.js before every route (webhooks never carry user tokens). */
function readOnlyGuard(req, res, next) {
const sessionId = impersonationClaim(req);
if (!sessionId) return next();
const path = req.originalUrl || req.url;
if (!allowedWhileImpersonating(req.method, path)) {
logRequest(sessionId, req.method, path, true);
return res.status(403).json({ message: 'Read-only while viewing as another user. End impersonation to make changes.', code: 'IMPERSONATION_READ_ONLY' });
}
if (SAFE_METHODS.includes(req.method) && path.startsWith('/api/')) logRequest(sessionId, req.method, path);
next();
}
/** Layer 2 — the live session for an `imp` claim, or null when ended/expired. */
async function liveSession(sessionId) {
const s = await prisma.impersonationSession.findUnique({ where: { id: sessionId } });
if (!s || s.endedAt) return null;
if (s.expiresAt <= new Date()) {
await prisma.impersonationSession.updateMany({ where: { id: s.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'expired' } });
return null;
}
return s;
}
/** Why `admin` can't view as `target`, or null. Pure. */
function impersonationBlocker(admin, target) {
if (!admin || admin.role !== 'admin') return 'Only admins can view as another user.';
if (!admin.twoFactorEnabled) return 'Turn on two-factor authentication for your account first.';
if (!target) return 'User not found.';
if (target.id === admin.id) return "You can't view as yourself.";
if (target.role === 'admin') return "Admins can't be impersonated.";
if (!target.isActive) return 'That account is deactivated.';
return null;
}
module.exports = { MAX_MINUTES, impersonationClaim, allowedWhileImpersonating, readOnlyGuard, liveSession, impersonationBlocker, logRequest };
+5
View File
@@ -346,6 +346,11 @@ function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
*/
function attachComputedTotals(registration) {
if (!registration) return registration;
// Never send which named attendees have an account: the person who typed their email or
// phone mustn't be able to tell (the link is used server-side only).
if (Array.isArray(registration.attendees)) {
registration.attendees = registration.attendees.map(({ userId, ...a }) => a);
}
let lastPaymentAt = null;
try {
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
+2 -2
View File
@@ -4,14 +4,14 @@
* take effect without a server restart.
* Falls back to environment variables when a key is not found in the DB.
*
* Encrypted keys (smtp_user, smtp_pass) are automatically decrypted on read.
* Encrypted keys (SMTP credentials, Yoco secrets) are automatically decrypted on read.
*/
const prisma = require('../config/db');
const { decrypt, isEncrypted } = require('./encryption');
// Keys stored encrypted in the DB — decrypted transparently on read.
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
let _cache = {};
let _lastFetch = 0;
+153 -2
View File
@@ -1,6 +1,12 @@
/**
* Ticket transfer — handing a ticket to someone else before the event.
*
* Privacy first: the sender only types the new holder's name and email/phone — there is
* no user search, and nothing tells the sender whether those details belong to an
* account. A TicketTransferRequest is created and a link is sent to the details typed;
* nothing changes on the ticket until the new holder accepts (requestTransfer →
* acceptTransfer). Accepting runs the transfer itself:
*
* - The new holder becomes the ticket's attendee (name, contact details, account). The
* payer stays responsible for the registration; billing doesn't move.
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
@@ -17,6 +23,13 @@ const { validateAttendees } = require('./attendees');
const { isMinor } = require('./consent');
const SUPERVISORS = ['admin', 'supervisor'];
const OFFER_DAYS = 7;
const crypto = require('crypto');
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`transfer:${token}`).digest('hex');
function transferLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/transfer/${token}`;
}
function transferError(message, statusCode = 400) {
const err = new Error(message);
@@ -71,7 +84,7 @@ async function transferTicket({ ticketId, actor, ...holder }) {
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
if (currentHolderId && currentHolderId === toUser.id) throw transferError(`${clean.name} already holds this ticket.`);
if (currentHolderId && currentHolderId === toUser.id) throw transferError('This ticket is already yours.');
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
const attendeeData = {
@@ -126,6 +139,141 @@ async function transferTicket({ ticketId, actor, ...holder }) {
};
}
/** Checks a transfer the way transferTicket will, without touching anything. */
async function checkTransfer({ ticketId, actor, ...holder }) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
usages: { select: { id: true } },
attendee: true, holder: { select: { name: true } }, user: { select: { name: true } },
event: { include: { eventOptions: true } },
registrationOption: { include: { eventOption: { select: { name: true } }, registration: { select: { id: true, status: true } } } },
},
});
if (!ticket) throw transferError('Ticket not found', 404);
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
if (blocker) throw transferError(blocker.message, blocker.status);
const ro = ticket.registrationOption;
const [clean] = validateAttendees({ ...ticket.event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
}]);
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
return { ticket, clean };
}
async function sendTransferOffer(request, ticket, token, fromName) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const link = transferLink(token);
const event = ticket.event;
const when = new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
const subject = `${fromName} wants to give you a ticket for ${event.title}`;
const sent = [];
if (request.toEmail) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">You've been offered a ticket</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.toName}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${fromName} would like to give you their ticket for <strong>${event.title}</strong> on <strong>${when}</strong>. Accept it to get your own ticket and QR code.</p>
${ctaButton('View and accept the ticket', link)}
${fallbackLink(link)}
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">If you weren't expecting this, you can ignore this email or decline from the link.</p>
`, { preheader: subject });
try {
await sendMail({ to: request.toEmail, subject, html, text: `Hi ${request.toName},\n\n${fromName} would like to give you their ticket for ${event.title} on ${when}. View and accept it here: ${link}` });
sent.push('email');
} catch (e) {
console.warn('[transfer] offer email failed:', e?.message || e);
}
}
if (request.toPhone && isValidZAPhone(request.toPhone)) {
await waTextAny({ name: request.toName, phoneNumber: request.toPhone }, `Hi ${request.toName}, ${fromName} would like to give you their ticket for *${event.title}* on ${when}. View and accept it here: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
/**
* Starts a transfer: checks it, then sends the new holder a link to accept. Nothing on
* the ticket changes yet, and no account is looked up.
*/
async function requestTransfer({ ticketId, actor, ...holder }) {
const { ticket, clean } = await checkTransfer({ ticketId, actor, ...holder });
const token = crypto.randomBytes(24).toString('hex');
const expiresAt = new Date(Math.min(new Date(ticket.event.startDate).getTime(), Date.now() + OFFER_DAYS * 864e5));
const request = await prisma.$transaction(async (tx) => {
// One open offer per ticket: a new one replaces the old.
await tx.ticketTransferRequest.updateMany({ where: { ticketId: ticket.id, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
return tx.ticketTransferRequest.create({
data: {
ticketId: ticket.id, requestedById: actor.id, toName: clean.name, toEmail: clean.email, toPhone: clean.phone,
details: { dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone },
tokenHash: hashToken(token), expiresAt,
},
});
});
const fromName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || actor.name || 'Someone';
const sent = await sendTransferOffer(request, ticket, token, fromName);
return { request, sent };
}
async function findRequestByToken(token) {
if (!token || typeof token !== 'string' || token.length > 200) return null;
const request = await prisma.ticketTransferRequest.findUnique({
where: { tokenHash: hashToken(token) },
include: {
requestedBy: { select: { id: true, name: true, role: true } },
ticket: { include: { event: { select: { id: true, title: true, startDate: true, location: true } }, registrationOption: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } }, attendee: { select: { name: true } }, user: { select: { name: true } } } },
},
});
if (request && request.status === 'pending' && request.expiresAt <= new Date()) {
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'expired' } });
request.status = 'expired';
}
return request;
}
/** The new holder accepts: the transfer happens now, with every check run again. */
async function acceptTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
// Claim it first so a double-click can't run the transfer twice.
const claimed = await prisma.ticketTransferRequest.updateMany({ where: { id: request.id, status: 'pending' }, data: { status: 'accepted', respondedAt: new Date() } });
if (claimed.count !== 1) throw transferError('This transfer has already been answered.', 409);
try {
const d = request.details || {};
const result = await transferTicket({
ticketId: request.ticketId, actor: request.requestedBy,
name: request.toName, email: request.toEmail, phone: request.toPhone,
dateOfBirth: d.dateOfBirth, gender: d.gender, parentName: d.parentName, parentEmail: d.parentEmail, parentPhone: d.parentPhone,
});
return { ...result, request };
} catch (e) {
// Couldn't go ahead (e.g. the ticket was scanned meanwhile): the offer is void.
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'cancelled' } }).catch(() => {});
throw e;
}
}
async function declineTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'declined', respondedAt: new Date() } });
return request;
}
/** The sender (or a supervisor) withdraws an offer that hasn't been accepted yet. */
async function cancelTransfer(ticketId, actor) {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId }, select: { userId: true, holderUserId: true } });
if (!ticket) throw transferError('Ticket not found', 404);
if (!SUPERVISORS.includes(actor.role) && ticket.userId !== actor.id && ticket.holderUserId !== actor.id) throw transferError('Not authorized', 403);
const { count } = await prisma.ticketTransferRequest.updateMany({ where: { ticketId, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
if (!count) throw transferError('There is no transfer waiting for this ticket.', 404);
}
/** The transfer that retired this QR code, if any. */
async function findTransferByOldCode(qrCode) {
if (!qrCode) return null;
@@ -137,4 +285,7 @@ function transferredMessage(transfer) {
return `This ticket was transferred to ${transfer.toName} on ${when}. The old QR code no longer works — ask for the new ticket.`;
}
module.exports = { transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage };
module.exports = {
transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage,
checkTransfer, requestTransfer, findRequestByToken, acceptTransfer, declineTransfer, cancelTransfer,
};
+134
View File
@@ -0,0 +1,134 @@
/**
* Yoco configuration and API helpers.
*
* The secret key and webhook secret are stored AES-256-GCM encrypted in AppSetting
* (yoco_secret_key, yoco_webhook_secret) and managed from Admin → Site Settings → Yoco.
* YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in .env are only used as fallbacks when the
* database has no value, so older deployments keep working until an admin saves them.
*/
const axios = require('axios');
const prisma = require('../config/db');
const { getSetting } = require('./settingsCache');
const YOCO_API = 'https://payments.yoco.com/api';
const KEY_SECRET = 'yoco_secret_key';
const KEY_WEBHOOK = 'yoco_webhook_secret';
const KEY_WEBHOOK_ID = 'yoco_webhook_id';
const getYocoSecretKey = () => getSetting(KEY_SECRET, process.env.YOCO_SECRET_KEY);
const getYocoWebhookSecret = () => getSetting(KEY_WEBHOOK, process.env.YOCO_WEBHOOK_SECRET);
/** Where each value currently comes from: 'db' | 'env' | 'none'. */
async function getYocoConfigSource() {
const rows = await prisma.appSetting.findMany({ where: { key: { in: [KEY_SECRET, KEY_WEBHOOK] } } });
const inDb = new Set(rows.filter(r => r.value).map(r => r.key));
const source = (key, envVar) => (inDb.has(key) ? 'db' : process.env[envVar] ? 'env' : 'none');
return {
secretKey: source(KEY_SECRET, 'YOCO_SECRET_KEY'),
webhookSecret: source(KEY_WEBHOOK, 'YOCO_WEBHOOK_SECRET'),
};
}
const KNOWN_PREFIXES = ['sk_test_', 'sk_live_', 'whsec_'];
/**
* Mask a secret like a card number: keep the prefix and the last 3 characters.
* maskSecret('sk_test_abcdefghijklmnaba') → 'sk_test_**************aba'
*/
function maskSecret(value) {
if (!value) return '';
const prefix = KNOWN_PREFIXES.find(p => value.startsWith(p)) || value.slice(0, 4);
const rest = value.slice(prefix.length);
if (rest.length <= 6) return prefix + '*'.repeat(rest.length);
return prefix + '*'.repeat(rest.length - 3) + rest.slice(-3);
}
function yocoMode(key) {
if (!key) return 'unknown';
if (key.startsWith('sk_test_')) return 'test';
if (key.startsWith('sk_live_')) return 'live';
return 'unknown';
}
function notConfiguredError() {
const err = new Error('Online payments are not configured. An admin needs to add the Yoco secret key in Site Settings → Yoco.');
err.statusCode = 503;
return err;
}
/** `Bearer <key>` for Yoco API calls; throws a 503-style error when no key is set. */
async function yocoAuthHeader() {
const key = await getYocoSecretKey();
if (!key) throw notConfiguredError();
return `Bearer ${key}`;
}
/** Turn a Yoco API error into a message an admin can act on. */
function friendlyYocoError(e) {
const status = e.response?.status;
const detail = e.response?.data?.message || e.response?.data?.description || e.response?.data?.errorMessage;
let message;
if (status === 401) message = 'Yoco rejected the secret key. Check that it was copied correctly.';
else if (status === 403) message = 'This Yoco account is not allowed to manage webhooks.';
else if (status === 400) message = `Yoco rejected the request${detail ? `: ${detail}` : '.'}`;
else if (status) message = `Yoco returned an error (${status})${detail ? `: ${detail}` : '.'}`;
else message = e.message || 'Could not reach Yoco.';
const err = new Error(message);
err.statusCode = e.statusCode || (status === 401 || status === 403 || status === 400 ? 400 : 502);
return err;
}
/** Call the Yoco API. `key` overrides the stored key (used to verify a key before saving it). */
async function yocoRequest(method, path, body, key) {
const auth = key ? `Bearer ${key}` : await yocoAuthHeader();
try {
const res = await axios({
method,
url: `${YOCO_API}${path}`,
data: body,
headers: { 'Content-Type': 'application/json', Authorization: auth },
timeout: 15000,
});
return res.data;
} catch (e) {
throw friendlyYocoError(e);
}
}
async function listWebhooks(key) {
const data = await yocoRequest('get', '/webhooks', undefined, key);
return data?.subscriptions || [];
}
const registerWebhook = (name, url) => yocoRequest('post', '/webhooks', { name, url });
const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComponent(id)}`);
/**
* The URL Yoco should post events to: BACKEND_URL, or the public origin of the
* current request (correct behind a proxy when TRUST_PROXY is set).
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
function webhookUrlProblem(url) {
let u;
try { u = new URL(url); } catch { return 'Enter a full webhook URL, e.g. https://api.example.com/api/webhooks/yoco'; }
if (u.protocol !== 'https:') return 'The webhook URL must use https:// so Yoco can reach it.';
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
return 'The webhook URL must be a public address, not localhost.';
}
return null;
}
module.exports = {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, yocoAuthHeader,
yocoRequest, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
};
+43
View File
@@ -0,0 +1,43 @@
const { allowedWhileImpersonating, impersonationBlocker } = require('../src/utils/impersonation');
describe('allowedWhileImpersonating', () => {
test('reads are allowed', () => {
expect(allowedWhileImpersonating('GET', '/api/registrations/myregistrations')).toBe(true);
expect(allowedWhileImpersonating('HEAD', '/api/events')).toBe(true);
expect(allowedWhileImpersonating('OPTIONS', '/api/payments')).toBe(true);
});
test('every write is refused', () => {
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
expect(allowedWhileImpersonating(m, '/api/registrations')).toBe(false);
expect(allowedWhileImpersonating(m, '/api/payments/yoco-checkout')).toBe(false);
expect(allowedWhileImpersonating(m, '/api/users/profile')).toBe(false);
}
});
test('except ending the session and logging page views', () => {
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/log?x=1')).toBe(true);
expect(allowedWhileImpersonating('DELETE', '/api/admin/impersonate/end')).toBe(false);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end/../../users')).toBe(false);
});
});
describe('impersonationBlocker', () => {
const admin = { id: 'a', role: 'admin', twoFactorEnabled: true };
const user = { id: 'u', role: 'user', isActive: true };
test('an admin with 2FA can view a user', () => {
expect(impersonationBlocker(admin, user)).toBeNull();
expect(impersonationBlocker(admin, { ...user, role: 'supervisor' })).toBeNull();
});
test('refusals', () => {
expect(impersonationBlocker({ ...admin, role: 'supervisor' }, user)).toMatch(/Only admins/);
expect(impersonationBlocker({ ...admin, twoFactorEnabled: false }, user)).toMatch(/two-factor/);
expect(impersonationBlocker(admin, { ...user, role: 'admin' })).toMatch(/Admins can't/);
expect(impersonationBlocker(admin, { ...admin })).toMatch(/yourself/);
expect(impersonationBlocker(admin, { ...user, isActive: false })).toMatch(/deactivated/);
expect(impersonationBlocker(admin, null)).toMatch(/not found/);
});
});
+99
View File
@@ -0,0 +1,99 @@
jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() },
}));
const prisma = require('../src/config/db');
const { encrypt } = require('../src/utils/encryption');
const settingsCache = require('../src/utils/settingsCache');
const {
maskSecret, yocoMode, getYocoSecretKey, getYocoWebhookSecret, yocoAuthHeader,
defaultWebhookUrl, webhookUrlProblem,
} = require('../src/utils/yoco');
const ENV = { ...process.env };
afterEach(() => { process.env = { ...ENV }; });
function dbHas(rows) {
prisma.appSetting.findMany.mockResolvedValue(rows);
settingsCache.invalidate();
}
describe('maskSecret', () => {
test('keeps the prefix and the last 3 characters', () => {
expect(maskSecret('sk_test_0123456789abcdefaba')).toBe('sk_test_****************aba');
expect(maskSecret('sk_live_0123456789xyz')).toBe('sk_live_**********xyz');
expect(maskSecret('whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw')).toBe(`whsec_${'*'.repeat(29)}aSw`);
});
test('masks the length exactly, like a card number', () => {
const v = 'sk_test_0123456789abcdefaba';
expect(maskSecret(v)).toHaveLength(v.length);
});
test('short values show nothing after the prefix', () => {
expect(maskSecret('sk_test_abc')).toBe('sk_test_***');
expect(maskSecret('whsec_')).toBe('whsec_');
});
test('unknown formats keep the first 4 characters', () => {
expect(maskSecret('abcd0123456789')).toBe('abcd*******789');
});
test('empty stays empty', () => {
expect(maskSecret('')).toBe('');
expect(maskSecret(undefined)).toBe('');
});
});
test('yocoMode reads the key prefix', () => {
expect(yocoMode('sk_test_x')).toBe('test');
expect(yocoMode('sk_live_x')).toBe('live');
expect(yocoMode('pk_test_x')).toBe('unknown');
expect(yocoMode('')).toBe('unknown');
});
describe('stored secrets', () => {
test('the database wins over .env, and is decrypted', async () => {
process.env.YOCO_SECRET_KEY = 'sk_test_fromenv';
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_test_fromdb') }]);
expect(await getYocoSecretKey()).toBe('sk_test_fromdb');
});
test('.env is the fallback when the database has no value', async () => {
process.env.YOCO_WEBHOOK_SECRET = 'whsec_ZW52';
dbHas([{ key: 'yoco_webhook_secret', value: '' }]);
expect(await getYocoWebhookSecret()).toBe('whsec_ZW52');
});
test('checkouts fail clearly when no key is configured', async () => {
delete process.env.YOCO_SECRET_KEY;
dbHas([]);
await expect(yocoAuthHeader()).rejects.toThrow(/not configured/);
});
test('the auth header uses the stored key', async () => {
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_live_abc') }]);
expect(await yocoAuthHeader()).toBe('Bearer sk_live_abc');
});
});
describe('webhook URL', () => {
const req = { protocol: 'https', get: () => 'events-demo-api.crosscode.co.za' };
test('built from BACKEND_URL', () => {
process.env.BACKEND_URL = 'https://api.example.com/';
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/webhooks/yoco');
});
test('falls back to the request origin', () => {
delete process.env.BACKEND_URL;
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/webhooks/yoco');
});
test('must be public https', () => {
expect(webhookUrlProblem('https://api.example.com/api/webhooks/yoco')).toBeNull();
expect(webhookUrlProblem('http://api.example.com/api/webhooks/yoco')).toMatch(/https/);
expect(webhookUrlProblem('https://localhost:5000/api/webhooks/yoco')).toMatch(/localhost/);
expect(webhookUrlProblem('not a url')).toMatch(/full webhook URL/);
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.22.0",
"version": "1.25.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.22.0",
"version": "1.25.1",
"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.22.0",
"version": "1.25.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+6 -7
View File
@@ -3,13 +3,13 @@ import React, { Suspense, useMemo, useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { saveToken } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import { appName } from "@/lib/siteConfig";
import { ShieldCheck } from "lucide-react";
function ActivateAccountContent() {
const search = useSearchParams();
const router = useRouter();
const token = search.get("token") || "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
@@ -25,12 +25,11 @@ function ActivateAccountContent() {
setLoading(true);
setStatus(null);
const data = await apiFetch<any>("/api/users/activate", { method: "POST", body: { token, password } });
// Auto-login: save token and redirect to dashboard
if (data?.token) {
localStorage.setItem("token", data.token);
}
// Auto-login: save the session token (under the key AuthContext reads) and load the
// dashboard fresh so the new session is picked up.
if (data?.token) saveToken(data.token);
setStatus("Your account is now active. Redirecting…");
setTimeout(() => router.push("/dashboard"), 1500);
setTimeout(() => { window.location.href = "/dashboard"; }, 1500);
} catch (err: any) {
setStatus(err?.message || "Activation failed. The link may have expired.");
} finally {
@@ -30,6 +30,11 @@ const ACTIONS = [
"scan_lock_force_released",
"offline_sync",
"ticket_transferred",
"impersonation_started",
"impersonation_ended",
"yoco_config_updated",
"yoco_webhook_registered",
"yoco_webhook_deleted",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -54,6 +59,11 @@ const ACTION_LABELS: Record<string, string> = {
scan_lock_force_released: "Offline lock force-released",
offline_sync: "Offline scans synced",
ticket_transferred: "Ticket transferred",
impersonation_started: "Impersonation started",
impersonation_ended: "Impersonation ended",
yoco_config_updated: "Yoco settings changed",
yoco_webhook_registered: "Yoco webhook registered",
yoco_webhook_deleted: "Yoco webhook deleted",
};
interface AuditLogEntry {
@@ -81,6 +91,49 @@ function formatMetadata(metadata: Record<string, unknown> | null): string {
}
}
type ImpSession = {
id: string; reason: string; startedAt: string; expiresAt: string; endedAt: string | null; endedReason: string | null;
admin: { name: string; email: string }; target: { name: string; email: string };
requests: { method: string; path: string; blocked: boolean; createdAt: string }[];
};
/** "View as" sessions, each with the trail of pages and API calls made during it. */
function ImpersonationSessions({ token }: { token: string }) {
const [sessions, setSessions] = useState<ImpSession[]>([]);
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
apiFetch<ImpSession[]>("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([]));
}, [token]);
if (sessions.length === 0) return null;
return (
<div className="border rounded-xl bg-white shadow-sm p-4 mb-4">
<div className="font-semibold mb-2">Impersonation sessions</div>
<ul className="divide-y text-sm">
{sessions.map(s => (
<li key={s.id} className="py-2">
<button className="w-full text-left flex flex-wrap items-baseline justify-between gap-2" onClick={() => setOpen(open === s.id ? null : s.id)}>
<span><strong>{s.admin.name}</strong> viewed as <strong>{s.target.name}</strong> — “{s.reason}”</span>
<span className="text-xs text-gray-500">
{new Date(s.startedAt).toLocaleString()} · {s.endedAt ? `${s.endedReason || "ended"} ${new Date(s.endedAt).toLocaleTimeString()}` : new Date(s.expiresAt) > new Date() ? "active" : "expired"} · {s.requests.length} request(s){s.requests.some(r => r.blocked) ? ` · ${s.requests.filter(r => r.blocked).length} blocked` : ""}
</span>
</button>
{open === s.id && (
<ul className="mt-2 text-xs font-mono bg-gray-50 rounded p-2 max-h-64 overflow-auto space-y-0.5">
{s.requests.map((r, i) => (
<li key={i} className={r.blocked ? "text-red-700" : "text-gray-700"}>
{new Date(r.createdAt).toLocaleTimeString()} {r.method} {r.path}{r.blocked ? " (blocked — read-only)" : ""}
</li>
))}
{s.requests.length === 0 && <li className="text-gray-500">No requests recorded.</li>}
</ul>
)}
</li>
))}
</ul>
</div>
);
}
export default function AdminAuditLogPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
@@ -148,6 +201,7 @@ export default function AdminAuditLogPage() {
You need admin access to view the audit log.
</div>
)}
{isAdmin && token && <ImpersonationSessions token={token} />}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
@@ -6,14 +6,15 @@ import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, DatabaseBackup, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { YocoTab } from "@/components/admin/YocoTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "backups";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -22,6 +23,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "email", label: "Email", icon: Mail },
{ id: "legal", label: "Legal", icon: Scale },
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "yoco", label: "Yoco", icon: CreditCard },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
];
@@ -137,6 +139,7 @@ function SiteSettingsPageInner() {
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
const [consentRetentionDays, setConsentRetentionDays] = useState("30");
const [notifyOnImpersonation, setNotifyOnImpersonation] = useState(false);
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
@@ -173,6 +176,7 @@ function SiteSettingsPageInner() {
setLegalWebsiteUrl(s.legal_website_url || "");
setLegalEffectiveDate(s.legal_effective_date || "");
setConsentRetentionDays(s.consent_medical_retention_days || "30");
setNotifyOnImpersonation(s.notify_user_on_impersonation === "true");
})
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.finally(() => setLoadingInitial(false));
@@ -322,6 +326,7 @@ function SiteSettingsPageInner() {
legal_website_url: legalWebsiteUrl.trim(),
legal_effective_date: legalEffectiveDate.trim(),
consent_medical_retention_days: String(Math.max(0, parseInt(consentRetentionDays, 10) || 0)),
notify_user_on_impersonation: notifyOnImpersonation ? "true" : "false",
});
const handleTestSmtp = async () => {
@@ -670,6 +675,14 @@ function SiteSettingsPageInner() {
<p className="text-xs text-gray-500 mt-1">Collected on parental consent forms. The consent record itself (who agreed, and when) is kept.</p>
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-3">Support access</p>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={notifyOnImpersonation} onChange={e => setNotifyOnImpersonation(e.target.checked)} />
<span>Email a user when an admin views their account with &quot;Impersonate&quot; (read-only support access), including the reason given</span>
</label>
</div>
<SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
@@ -677,6 +690,9 @@ function SiteSettingsPageInner() {
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
{/* ── Yoco ──────────────────────────────────────────────────────────── */}
{activeTab === "yoco" && <YocoTab active={activeTab === "yoco"} />}
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
</div>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { beginImpersonation } from "@/lib/impersonation";
import { Users as UsersIcon } from "lucide-react";
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
@@ -187,6 +188,23 @@ export default function AdminUsersPage() {
}
};
const [impTarget, setImpTarget] = useState<{ id: string; name: string } | null>(null);
const [impReason, setImpReason] = useState("");
const [impBusy, setImpBusy] = useState(false);
const [impError, setImpError] = useState<string | null>(null);
const startImpersonation = async () => {
if (!impTarget || !token) return;
setImpBusy(true);
setImpError(null);
try {
const res = await apiFetch<any>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
beginImpersonation(res);
} catch (e: any) {
setImpError(e?.message || "Couldn't start impersonation");
setImpBusy(false);
}
};
const resetTwoFactor = async (id: string, name: string, role: string) => {
// Admins and supervisors can't go without 2FA: a reset just makes them set it up
// again (on a channel that works) at their next login.
@@ -222,6 +240,24 @@ export default function AdminUsersPage() {
}
};
const impersonateModal = impTarget && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => !impBusy && setImpTarget(null)}>
<div className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold">View as {impTarget.name}</h3>
<p className="text-sm text-gray-600">You&apos;ll see the site exactly as they do, for up to 30 minutes. It&apos;s strictly read-only: nothing can be registered, paid, changed or sent. The session, your reason and every page you open are recorded in the audit log.</p>
<div>
<label className="block text-xs text-gray-600 mb-1">Reason (required)</label>
<textarea className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} value={impReason} onChange={e => setImpReason(e.target.value)} placeholder="e.g. Says their ticket isn't showing in My Events" maxLength={500} autoFocus />
</div>
{impError && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{impError}</div>}
<div className="flex justify-end gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => setImpTarget(null)} disabled={impBusy}>Cancel</button>
<button className="px-3 py-1.5 text-sm rounded bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50" onClick={startImpersonation} disabled={impBusy || impReason.trim().length < 5}>{impBusy ? "Starting…" : "Start viewing"}</button>
</div>
</div>
</div>
);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
@@ -367,6 +403,9 @@ export default function AdminUsersPage() {
<td className="p-2">
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
{user?.role === "admin" && u.role !== "admin" && u.isActive && (
<button className="px-2 py-1 text-xs rounded bg-violet-600 text-white hover:bg-violet-700" onClick={() => { setImpTarget({ id: u.id, name: u.name }); setImpReason(""); setImpError(null); }} title="See the site exactly as this user sees it (read-only, 30 minutes, audited)">Impersonate</button>
)}
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
{u.twoFactorEnabled && (
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => resetTwoFactor(u.id, u.name, u.role)} title={u.role === "admin" || u.role === "supervisor" ? "Reset two-factor authentication — they set it up again at their next login" : "Turn off two-factor authentication for a user who can no longer receive their codes"}>Reset 2FA</button>
@@ -498,6 +537,7 @@ export default function AdminUsersPage() {
</div>
</div>
)}
{impersonateModal}
</div>
);
}
+12
View File
@@ -1011,6 +1011,18 @@ export default function UserDashboardPage() {
<button onClick={() => whatsappTickets([t.id])} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1">WhatsApp</button>
)}
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Print</button>
{(t.transferRequests || [])[0] && (
<span className="w-full text-xs text-violet-800 bg-violet-50 rounded px-2 py-1 flex items-center justify-between gap-2">
<span>Waiting for {t.transferRequests[0].toName} to accept</span>
<button className="underline" onClick={async () => {
try {
const r: any = await apiFetch(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
setInfo(r?.message || "Transfer cancelled");
reloadAfterAttendees();
} catch (e: any) { setError(e?.message || "Couldn't cancel"); }
}}>Cancel</button>
</span>
)}
{canOfferTransfer(t, t.event) && (
<button onClick={() => setTransferTarget(t)} className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-100 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" title="Give this ticket to someone else">Transfer</button>
)}
+2
View File
@@ -9,6 +9,7 @@ import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { CookieConsent } from "@/components/shared/CookieConsent";
import { ImpersonationBanner } from "@/components/shared/ImpersonationBanner";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
import { appUrl } from "@/lib/siteConfig";
@@ -94,6 +95,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider>
<ToastProvider>
<SetupGuard />
<ImpersonationBanner />
<BannerBar />
{children}
<HelpFab />
+89 -38
View File
@@ -4,23 +4,25 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
const sections = [
{ id: "summary", title: "Plain English Summary" },
{ id: "introduction",title: "1. Introduction" },
{ id: "information", title: "2. Information We Collect" },
{ id: "use", title: "3. How We Use Your Information" },
{ id: "whatsapp", title: "4. WhatsApp Communications" },
{ id: "storage", title: "5. Where We Store Your Data" },
{ id: "retention", title: "6. Data Retention" },
{ id: "children", title: "7. Children and Minors" },
{ id: "sharing", title: "8. Sharing of Information" },
{ id: "rights", title: "9. Your Rights (under POPIA)" },
{ id: "cookies", title: "10. Cookies and Local Storage" },
{ id: "security", title: "11. Security" },
{ id: "changes", title: "12. Changes to This Policy" },
{ id: "officer", title: "13. Information Officer" },
{ id: "contact", title: "14. Contact" },
{ id: "others", title: "3. People You Register or Transfer Tickets To" },
{ id: "use", title: "4. How We Use Your Information" },
{ id: "whatsapp", title: "5. WhatsApp Communications" },
{ id: "storage", title: "6. Where We Store Your Data" },
{ id: "retention", title: "7. Data Retention" },
{ id: "children", title: "8. Children, Parental Consent and Health Information" },
{ id: "sharing", title: "9. Who Can See Your Information" },
{ id: "rights", title: "10. Your Rights (under POPIA)" },
{ id: "cookies", title: "11. Cookie Policy (Cookies and Browser Storage)" },
{ id: "security", title: "12. Security" },
{ id: "changes", title: "13. Changes to This Policy" },
{ id: "officer", title: "14. Information Officer" },
{ id: "contact", title: "15. Contact" },
];
export default function PrivacyPolicyPage() {
@@ -89,15 +91,15 @@ export default function PrivacyPolicyPage() {
<h2 className="text-2xl font-semibold text-brand-600 mb-4">🟢 Plain English Summary</h2>
<p>We respect your privacy — here&apos;s what you need to know:</p>
<ul className="list-disc ml-6 space-y-1">
<li>We only collect info needed to register you for events and process payments.</li>
<li>We only collect info needed to run events: registrations, payments, tickets and check-in.</li>
<li>We store your info safely on South African servers.</li>
<li>We don&apos;t sell or share your data with anyone.</li>
<li>You can ask us what info we have about you or request deletion at any time.</li>
<li>Under 18s must register with a parent&apos;s permission.</li>
<li>We don&apos;t sell your data, and only share it with the services that process payments and deliver messages.</li>
<li>If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.</li>
<li>For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.</li>
<li>Payments go through Yoco; we never see your card details.</li>
<li>We may send you security alerts (e.g. new login notifications) via email and/or WhatsApp to keep your account safe.</li>
<li>If you opt in to WhatsApp notifications, we use your mobile number to send you event updates, tickets, and account alerts via WhatsApp (powered by WAWP).</li>
<li>We keep your data for up to 5 years after your last event registration.</li>
<li>We send tickets, updates and security alerts by email and/or WhatsApp (powered by WAWP).</li>
<li>Visit statistics are only collected if you click &quot;Allow&quot; on the cookie notice — no third-party analytics.</li>
<li>You can ask what we hold about you, or delete your account, at any time. We keep data for up to 5 years after your last event registration.</li>
</ul>
</section>
@@ -129,21 +131,36 @@ function Section({
],
information: [
"We collect the following information when you interact with our platform:",
"• Account Information: name, email address, phone number, and password (encrypted).",
"• Event Information: events you register for, ticket selections, and related preferences.",
"• Payment Information: processed by Yoco; we receive only payment status and reference numbers.",
"• Communication Data: emails or messages exchanged with our team.",
"• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.",
"• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.",
"• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.",
"• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).",
"• Payment information: processed by Yoco; we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
"• Communication data: emails or messages exchanged with our team.",
"• Visit statistics: only if you allow them on the cookie notice (see section 11).",
"We do not collect or store credit card numbers or bank details.",
"Administrator-created accounts (e.g. walk-in event registrations) may be created with a mobile number only. These accounts use a placeholder email address and can be activated by the account holder at any time.",
"Accounts may be created by an administrator, or when someone registers you or transfers a ticket to you, with a mobile number or email address only. You can activate such an account at any time.",
],
others: [
"You can register other people (for example family members) and give each one their own ticket, or pass your ticket on to someone else.",
"• You type their name and, optionally, their email address or phone number. There is no search or list of existing users to choose from, and we never tell you whether the details you enter belong to an account.",
"• We use those details only to send that person their ticket, their attendee form, a parental consent request (youth events), or — for a transfer — a link to accept the ticket. A transfer only happens once they accept it; they can also decline.",
"• If the email or phone number matches an existing account, the ticket also appears in that person's own account. Otherwise an account may be created for them, which they can activate if they wish.",
"• Some events ask for each person's date of birth or gender (for example, to allocate rooms at a camp).",
"By entering someone else's details, you confirm that you are allowed to share them with us for this purpose (for a child, that you are their parent or guardian or have their permission).",
],
use: [
"We use your information to:",
"• Process event registrations and payments.",
"• Process event registrations, payments, discounts and refunds.",
"• Communicate event updates, confirmations, or changes via email and/or WhatsApp (based on your preference).",
"• Send security notifications for new logins, password changes, and account actions on your account.",
"• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).",
"• Manage user accounts and event attendance records.",
"• Improve our website and event experience.",
"• Check tickets in at the door, including with scanners that keep working without an internet connection.",
"• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.",
"• Send you an optional feedback form after an event, with one reminder.",
"• Manage user accounts and event attendance records, and give support when you ask for help.",
"• Improve our events and website (using anonymous statistics, and only with your permission).",
"• Comply with legal obligations.",
],
whatsapp: [
@@ -165,19 +182,33 @@ function Section({
],
retention: [
"We retain your personal data for up to 5 years after your last event registration, or until you request deletion of your account — whichever comes first.",
"Medical and emergency-contact details from parental consent forms are deleted a set number of days after the event ends (30 days unless the organisation sets a different period). The record of who gave consent, when, and to which wording is kept.",
"Detailed visit statistics are combined into anonymous daily totals after 90 days.",
"Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
"Records of support access to accounts (see section 9) and other audit records are kept for accountability.",
"You can delete your account at any time from the Profile & Security page in your account. Deletion anonymises your personal information (name, email address, and phone number are replaced with placeholder values) while retaining event and payment records in anonymised form for financial record-keeping purposes.",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 13).",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 14).",
],
children: [
"Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.",
"We do not knowingly collect or process personal information of minors without such consent.",
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
"A parent or guardian may also say whether photos of the young person may be used.",
],
sharing: [
"We do not sell, rent, or share your personal information with any third parties.",
"The only exceptions are:",
"• Processing payments securely through Yoco (a South African payment processor).",
"• Delivering WhatsApp messages through WAWP (wawp.net), a WhatsApp Business API provider — only your mobile number and message content are shared, solely for delivery purposes.",
"• Where required by South African law.",
"We do not sell or rent your personal information. Outside our organisation, it is shared only with:",
"• Yoco (a South African payment processor), to process payments securely.",
"• WAWP (wawp.net), a WhatsApp Business API provider, to deliver WhatsApp messages — only your mobile number and the message content, solely for delivery.",
"• Our email delivery provider, to deliver emails.",
"• Anyone we are required to share it with under South African law.",
"Within our organisation, people see only what they need:",
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
"• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.",
"• Supervisors and administrators manage registrations, payments and communication for events.",
"• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.",
"• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.",
"• Nobody else can see whether you have an account.",
],
rights: [
"Under POPIA, you have the right to:",
@@ -188,16 +219,31 @@ function Section({
"To contact the Information Regulator:",
"• Website: www.inforeg.org.za",
"• Email: enquiries@inforeg.org.za",
"To exercise your rights directly with us, contact our Information Officer using the details in section 13 below.",
"To exercise your rights directly with us, contact our Information Officer using the details in section 14 below.",
],
cookies: [
"This website uses browser localStorage to store your login session token and user interface preferences (such as dismissed notifications). No third-party cookies, tracking pixels, or analytics tools are used.",
"If you choose \"Allow\" on the cookie notice, we also keep a random, anonymous visit identifier so we can count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link) and the type of device — never your name, IP address or browsing history. Detailed records are combined into daily totals after 90 days. If you choose \"No thanks\", nothing is counted.",
"You can clear your browser's local storage at any time through your browser settings, which will log you out of the site.",
"Our own site does not set tracking cookies. It keeps a few small items in your browser's storage (localStorage, and on staff scanners IndexedDB and an offline page cache). These work much like cookies, so this policy covers them. We do not use third-party analytics, advertising or tracking pixels.",
"Strictly necessary — always used, because the site can't work without them:",
"• hope_events_token — keeps you signed in.",
"• hope_events_user_cache — your last loaded profile, so pages keep working if your connection drops.",
"• hope_events_device — only if you tick \"Trust this device\" when entering a two-factor code, so you aren't asked for a code every time.",
"• hope_events_cookie_consent — remembers your choice on the cookie notice.",
"• banner_dismissed_… — remembers notices you have closed.",
"Staff and administrators only:",
"• hope_events_scan_device, hope_events_offline_lock and the \"hope-events-offline-scan\" database — on a staff member's scanner in offline mode: the device's id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.",
"• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.",
"Optional — only if you click \"Allow\" on the cookie notice:",
"• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose \"No thanks\", nothing is counted and no visit id is created.",
"Third parties:",
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
"• Payments open Yoco's secure checkout page, which uses its own cookies on Yoco's site.",
"You can change your cookie choice at any time with \"Cookie settings\" at the bottom of every page, or with the button above. You can also clear your browser's storage in your browser settings; this signs you out.",
],
security: [
"Your personal data is encrypted during transmission and stored using industry-standard security protocols.",
"Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.",
"Passwords are encrypted and cannot be viewed by anyone, including administrators.",
"Two-factor authentication is available to everyone and is required for supervisors and administrators.",
"Links we send for consent, feedback and ticket transfers are personal and time-limited. Only a secure fingerprint of each link is stored, not the link itself.",
"In the event of a data breach that poses a risk to your rights, we will notify you and the Information Regulator of South Africa as required under POPIA section 22, without unreasonable delay.",
],
changes: [
@@ -219,6 +265,11 @@ function Section({
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-brand-600 mb-3">{title}</h3>
{id === "cookies" && (
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
Change my cookie choice
</CookieSettingsButton>
)}
{content[id]?.map((line, i) => {
// Render email addresses as links
const emailMatch = line.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
+54 -15
View File
@@ -12,14 +12,19 @@ const sections = [
{ id: "accounts", title: "3. User Accounts" },
{ id: "payments", title: "4. Payments & Refunds" },
{ id: "registrations", title: "5. Registrations & Tickets" },
{ id: "acceptable", title: "6. Acceptable Use" },
{ id: "communication", title: "7. Communication" },
{ id: "intellectual", title: "8. Intellectual Property" },
{ id: "liability", title: "9. Limitation of Liability" },
{ id: "forcemajeure", title: "10. Force Majeure" },
{ id: "termination", title: "11. Termination of Access" },
{ id: "law", title: "12. Governing Law" },
{ id: "contact", title: "13. Contact" },
{ id: "others", title: "6. Registering Other People" },
{ id: "youth", title: "7. Youth Events & Parental Consent" },
{ id: "transfers", title: "8. Ticket Transfers" },
{ id: "extras", title: "9. Discount Codes, Waitlists, Sponsorships & Feedback" },
{ id: "acceptable", title: "10. Acceptable Use" },
{ id: "communication", title: "11. Communication" },
{ id: "support", title: "12. Account Security & Support Access" },
{ id: "intellectual", title: "13. Intellectual Property" },
{ id: "liability", title: "14. Limitation of Liability" },
{ id: "forcemajeure", title: "15. Force Majeure" },
{ id: "termination", title: "16. Termination of Access" },
{ id: "law", title: "17. Governing Law" },
{ id: "contact", title: "18. Contact" },
];
export default function TermsOfUsePage() {
@@ -88,7 +93,9 @@ export default function TermsOfUsePage() {
<li>Respect that under 18s need parental permission to register.</li>
<li>Understand that all payments go through Yoco, and we don&apos;t store your card details.</li>
<li>Accept that we may contact you via email and/or WhatsApp (based on your preference) about events you register for, your tickets, and account security alerts.</li>
<li>Understand that registrations are personal to you and non-transferable.</li>
<li>Only enter other people&apos;s details (when booking for them or passing on a ticket) if you&apos;re allowed to.</li>
<li>Where an event allows it, you can pass your ticket to someone else before the event — it moves to them once they accept, with a new QR code.</li>
<li>For youth events, a parent or guardian must give consent before a young person can be checked in.</li>
<li>If we cancel an event, you&apos;ll get a full refund on request or your registration moved to the new date.</li>
</ul>
<p className="mt-2">If you disagree with these terms, please don&apos;t use the site.</p>
@@ -126,11 +133,11 @@ function Section({
],
accounts: [
"You must create an account to register for certain events or access restricted features.",
"You are responsible for keeping your password secure.",
"You are responsible for keeping your password secure, and for any two-factor codes sent to you.",
"Passwords are encrypted and not accessible to any staff member.",
"Users under the age of 18 must only register or create an account with the consent and supervision of a parent or guardian.",
"You agree to provide accurate, current, and complete information when creating your account.",
`Accounts may also be created on your behalf by an administrator (e.g. for walk-in event registration). In this case, you will receive a WhatsApp message with an activation link if your mobile number is provided.`,
`Accounts may also be created on your behalf — by an administrator (e.g. for walk-in event registration), or when someone registers you for an event or transfers a ticket to you. You will receive an activation link by email or WhatsApp, and can choose whether to activate the account.`,
],
payments: [
"Payments are processed securely through Yoco, a third-party payment processor.",
@@ -142,18 +149,46 @@ function Section({
`Refund Policy: Refunds are handled on a per-event basis. As a baseline, if you are unable to attend an event you have paid for, please contact us at ${orgEmail} at least 48 hours before the event. Refund requests at this stage are at the discretion of the event organiser. No refunds will be issued for no-shows. Any event-specific refund terms will be communicated at the time of registration and take precedence over this baseline.`,
],
registrations: [
`Registrations and tickets are personal to the registered individual and are non-transferable unless explicitly authorised by ${orgName} in writing.`,
"You may not sell, gift, or pass on your registration to another person without written authorisation.",
"Each ticket has a QR code that is checked at the event. Keep it private — anyone holding a copy of it may be able to use it.",
"A ticket can only be used for the number of places it covers. Once a ticket has been scanned it cannot be transferred.",
"Tickets may be passed on to another person only through the platform's transfer feature, where the event allows it (see section 8). You may not sell tickets for profit.",
"At some events, door staff may check tickets against a list downloaded to their device in case the internet connection fails. If a ticket was bought or changed after that list was downloaded, you may be asked to go to the door desk.",
"You may cancel your own registration at any time before any payment has been made. Once a payment has been recorded against your registration, cancellation must be requested through the organisation. Please contact us to arrange this.",
`If ${orgName} cancels or significantly postpones an event, affected registrants will be notified by email. In this case, a full refund will be issued upon request, or your registration may be transferred to the rescheduled or replacement event — whichever you prefer. Refund requests for organiser-initiated cancellations must be submitted to ${orgEmail} within 30 days of the cancellation notice.`,
],
others: [
"When you register other people (for example family members) or give each person their own ticket, you confirm that:",
"• the details you enter are accurate, and you are allowed to share them with us for this purpose; and",
"• for anyone under 18, you are their parent or guardian or have their parent's or guardian's permission.",
"We will use the email address or phone number you enter to send that person their ticket, attendee form, or other event messages. There is no searching for other users — you must type their details yourself.",
"The person who registers and pays remains responsible for the registration and its payment.",
],
youth: [
"Some events are marked as youth events. Each attendee's date of birth is required, and for anyone under 18 a parent or guardian must give consent through the link we send them before the young person can be checked in.",
"The parent or guardian is responsible for the accuracy of any medical and emergency information they provide, and for telling the organisers about changes.",
"A supervisor may in exceptional cases check someone in without recorded consent; such decisions are logged.",
],
transfers: [
"Where an event allows it, the person who booked a ticket or the person currently holding it can offer the ticket to someone else before the event starts, provided the ticket has not been scanned. Supervisors may transfer tickets even where an event does not otherwise allow it.",
"You enter the new holder's name and email address and/or phone number. We send them a link to accept the ticket. Nothing changes until they accept; they may also decline, and you may cancel the offer until then. Offers expire after 7 days or when the event starts.",
"When the transfer is accepted, the new holder receives their own ticket with a new QR code, and the old QR code (including any copies or screenshots of it) stops working.",
"The person who booked stays responsible for the registration and its payment; a transfer does not move or refund any payment.",
"On youth events, the new holder needs their own parental consent, and any attendee form answers from the previous holder are removed.",
],
extras: [
"Discount codes: codes are issued at the organisers' discretion, may be limited to certain events, people, dates or numbers of uses, and may not be sold or shared where they are restricted. We may cancel a discount obtained through misuse.",
"Waitlists: joining a waitlist does not guarantee a place. If a place becomes available, it is offered to the next person for a limited time; if the offer is not claimed in time, it passes to the next person.",
"Sponsor a spot: a sponsorship is a donation towards places for people who couldn't otherwise attend. The organisers decide how sponsorship money is allocated, and sponsors are not told who benefits. Refunds of sponsorships are at the organisers' discretion.",
"Feedback: post-event feedback forms are optional. Please don't include other people's personal information in your answers.",
],
acceptable: [
"You agree not to:",
"• Use the website for any unlawful or fraudulent purpose.",
"• Impersonate another person or misrepresent your identity.",
"• Interfere with or disrupt the operation of the site.",
"• Attempt to gain unauthorised access to our systems.",
"• Resell, tout, or transfer your registration or tickets without written authorisation.",
"• Resell or tout tickets, or transfer them other than through the platform's transfer feature.",
"• Enter another person's details without being allowed to, or use the registration, transfer or messaging features to contact people who have not agreed to it.",
"• Use automated tools, bots, or scrapers to access or extract data from the site.",
],
communication: [
@@ -163,6 +198,10 @@ function Section({
"For accounts without a valid email address (e.g. walk-in or at-the-door registrations), these required communications will be delivered via WhatsApp if a mobile number is on file.",
"WhatsApp messages are delivered via WAWP, a third-party WhatsApp Business API provider. By enabling WhatsApp notifications, you consent to your mobile number being used to send you messages via this service.",
],
support: [
"Two-factor authentication is required for supervisors and administrators, and recommended for everyone.",
"To help solve a problem with your account (for example, a ticket not showing), an administrator may view the site as you see it, for up to 30 minutes. This support access is strictly read-only: nothing can be changed, registered, paid, cancelled or sent on your behalf. A reason must be given, and every session is recorded in an audit log. The organisation may choose to notify you by email whenever this happens.",
],
intellectual: [
`All content on the platform, including text, graphics, and logos, is owned by ${orgName} unless otherwise stated.`,
"You may not copy, modify, or distribute any content without written permission.",
@@ -173,7 +212,7 @@ function Section({
"We are not liable for any loss or damages arising from:",
"• Technical failures; or",
"• Misuse of the platform.",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 10 (Force Majeure).",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 15 (Force Majeure).",
],
forcemajeure: [
`${orgName} shall not be liable for any failure or delay in performing its obligations where such failure or delay results from causes beyond its reasonable control, including but not limited to: acts of God, natural disasters, government restrictions or regulations, public health emergencies, venue closures, or any other event outside our reasonable control ('force majeure event').`,
@@ -0,0 +1,99 @@
"use client";
import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { Ticket, CheckCircle2, XCircle } from "lucide-react";
type Offer = {
status: "pending" | "accepted" | "declined" | "cancelled" | "expired";
toName: string;
fromName: string;
expiresAt: string;
option: string;
event: { id: string; title: string; startDate: string; location: string | null };
};
const STATUS_TEXT: Record<string, string> = {
accepted: "This ticket has already been accepted.",
declined: "This ticket offer was declined.",
cancelled: "This ticket offer was withdrawn.",
expired: "This ticket offer has expired.",
};
/** Public page the person a ticket is offered to opens from their email or WhatsApp. */
export default function TransferOfferPage() {
const params = useParams<{ token: string }>();
const token = params?.token;
const [offer, setOffer] = useState<Offer | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<{ ok: boolean; message: string } | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<Offer>(`/api/tickets/transfer-offer/${encodeURIComponent(token)}`)
.then(setOffer)
.catch(e => setLoadError(e?.message || "This transfer link isn't valid."));
}, [token]);
const answer = async (action: "accept" | "decline") => {
setBusy(true);
setError(null);
try {
const r = await apiFetch<{ message: string }>(`/api/tickets/transfer-offer/${encodeURIComponent(token!)}/${action}`, { method: "POST" });
setDone({ ok: action === "accept", message: r.message });
} catch (e: any) {
setError(e?.message || "Something went wrong");
} finally {
setBusy(false);
}
};
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Navbar />
<main className="flex-1 max-w-xl w-full mx-auto p-4 sm:p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">A ticket for you</h1>
</div>
{loadError && <div className="p-4 border rounded-xl bg-white text-sm text-gray-700">{loadError}</div>}
{done && (
<div className="p-5 border rounded-xl bg-white shadow-sm flex items-start gap-3">
{done.ok ? <CheckCircle2 className="w-6 h-6 text-green-600 shrink-0" /> : <XCircle className="w-6 h-6 text-gray-500 shrink-0" />}
<p className="text-sm text-gray-700">{done.message}</p>
</div>
)}
{offer && !done && (
<div className="p-5 border rounded-xl bg-white shadow-sm space-y-3">
<p className="text-sm text-gray-700">Hi {offer.toName.split(" ")[0]}, {offer.fromName || "someone"} would like to give you their ticket.</p>
<div className="rounded-lg bg-gray-50 p-3 text-sm">
<div className="font-semibold">{offer.event.title}</div>
<div className="text-gray-600">{formatDate(new Date(offer.event.startDate))}{offer.event.location ? ` · ${offer.event.location}` : ""}</div>
<div className="text-gray-600">{offer.option}</div>
</div>
{offer.status === "pending" ? (
<>
<p className="text-xs text-gray-500">Accepting sends you your own ticket and QR code. If you don&apos;t have an account with us yet, we&apos;ll create one and send you a link to activate it. This offer is open until {new Date(offer.expiresAt).toLocaleString()}.</p>
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<div className="flex gap-2">
<button onClick={() => answer("accept")} disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">{busy ? "Please wait…" : "Accept ticket"}</button>
<button onClick={() => answer("decline")} disabled={busy} className="px-4 py-2 rounded-lg border hover:bg-gray-50 disabled:opacity-50">Decline</button>
</div>
</>
) : (
<p className="text-sm text-gray-600">{STATUS_TEXT[offer.status]}</p>
)}
</div>
)}
</main>
<Footer />
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Copy, CreditCard, KeyRound, Trash2, Webhook } from "lucide-react";
type Source = "db" | "env" | "none";
interface YocoConfig {
secretKeyMasked: string;
secretKeySource: Source;
mode: "test" | "live" | "unknown";
webhookSecretMasked: string;
webhookSecretSource: Source;
webhookId: string;
webhookUrl: string;
configured: boolean;
}
interface YocoWebhook {
id: string;
name: string;
url: string;
mode: "test" | "live";
isActive: boolean;
pointsHere: boolean;
}
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const primaryBtn =
"px-3 py-1.5 text-sm rounded-lg bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white font-medium";
const secondaryBtn = "px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50";
function ModeBadge({ mode }: { mode: string }) {
if (mode !== "test" && mode !== "live") return null;
return (
<span
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${
mode === "live" ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"
}`}
>
{mode === "live" ? "Live" : "Test"}
</span>
);
}
function SourceNote({ source }: { source: Source }) {
if (source === "env") {
return <p className="text-xs text-amber-700 mt-1">From the server&apos;s .env file. Save it here to store it in the database instead.</p>;
}
if (source === "none") return <p className="text-xs text-gray-500 mt-1">Not set.</p>;
return null;
}
function Card({ icon: Icon, title, subtitle, children }: {
icon: React.ElementType; title: string; subtitle: string; children: React.ReactNode;
}) {
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-brand-600" />
<div>
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
<p className="text-xs text-gray-500">{subtitle}</p>
</div>
</div>
{children}
</div>
);
}
export function YocoTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [cfg, setCfg] = useState<YocoConfig | null>(null);
const [hooks, setHooks] = useState<YocoWebhook[]>([]);
const [hooksError, setHooksError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
// API key editor
const [editingKey, setEditingKey] = useState(false);
const [keyInput, setKeyInput] = useState("");
// Webhook secret: generate (register at Yoco) or enter by hand
const [secretMode, setSecretMode] = useState<"generate" | "enter">("generate");
const [hookName, setHookName] = useState("hope-events");
const [hookUrl, setHookUrl] = useState("");
const [secretInput, setSecretInput] = useState("");
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const loadHooks = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ webhooks: YocoWebhook[] }>("/api/yoco/webhooks", { authToken: token });
setHooks(res.webhooks || []);
setHooksError(null);
} catch (e: any) {
setHooks([]);
setHooksError(e?.message || "Could not load webhooks from Yoco");
}
}, [token]);
const load = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const c = await apiFetch<YocoConfig>("/api/yoco/config", { authToken: token });
setCfg(c);
setHookUrl((prev) => prev || c.webhookUrl);
if (c.secretKeySource !== "none") await loadHooks();
else { setHooks([]); setHooksError(null); }
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Failed to load Yoco settings" });
} finally {
setLoading(false);
}
}, [token, loadHooks, setMessage]);
useEffect(() => { if (active) load(); }, [active, load]);
const run = async (label: string, fn: () => Promise<string>) => {
setBusy(label);
setMessage(null);
try {
const text = await fn();
setMessage({ type: "ok", text });
await load();
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Something went wrong" });
} finally {
setBusy(null);
}
};
const saveKey = (value: string) => run("key", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { secretKey: value } });
setEditingKey(false);
setKeyInput("");
return value ? "Secret key saved." : "Secret key removed.";
});
const saveSecret = (value: string) => run("secret", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { webhookSecret: value } });
setSecretInput("");
return value ? "Webhook secret saved." : "Webhook secret removed.";
});
const generate = () => run("generate", async () => {
const res = await apiFetch<{ message: string }>("/api/yoco/webhooks", {
method: "POST", authToken: token!, body: { name: hookName.trim(), url: hookUrl.trim() },
});
return res.message;
});
const removeHook = (id: string) => run(`delete:${id}`, async () => {
const res = await apiFetch<{ message: string }>(`/api/yoco/webhooks/${encodeURIComponent(id)}`, {
method: "DELETE", authToken: token!,
});
setConfirmDelete(null);
return res.message;
});
const copy = (text: string) => {
navigator.clipboard?.writeText(text).then(
() => setMessage({ type: "ok", text: "Copied to clipboard." }),
() => setMessage({ type: "err", text: "Could not copy." }),
);
};
const hasKey = !!cfg && cfg.secretKeySource !== "none";
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<CreditCard className="w-4 h-4 text-brand-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Yoco payments</h2>
<p className="text-xs text-gray-500">Online card payments. Secrets are stored encrypted and never shown in full.</p>
</div>
</div>
{message && (
<div className={`text-sm p-2.5 rounded-lg ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
{message.text}
</div>
)}
{loading && !cfg ? (
<p className="text-sm text-gray-400">Loading…</p>
) : cfg && (
<>
{/* ── API key ─────────────────────────────────────────────────── */}
<Card icon={KeyRound} title="Secret key" subtitle="From the Yoco app: Selling Online → Payment Gateway → API keys.">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-sm bg-gray-50 border rounded px-2 py-1">{cfg.secretKeyMasked || "—"}</code>
<ModeBadge mode={cfg.mode} />
{!editingKey && (
<button type="button" className={`${secondaryBtn} ml-auto`} onClick={() => setEditingKey(true)}>
{hasKey ? "Change" : "Add key"}
</button>
)}
</div>
<SourceNote source={cfg.secretKeySource} />
{editingKey && (
<div className="space-y-2">
<input
type="password"
autoComplete="off"
className={inputCls}
placeholder="sk_test_… or sk_live_…"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
/>
<div className="flex gap-2">
<button type="button" className={primaryBtn} disabled={!keyInput.trim() || busy === "key"} onClick={() => saveKey(keyInput.trim())}>
{busy === "key" ? "Checking…" : "Save"}
</button>
<button type="button" className={secondaryBtn} onClick={() => { setEditingKey(false); setKeyInput(""); }}>
Cancel
</button>
{cfg.secretKeySource === "db" && (
<button type="button" className={`${secondaryBtn} ml-auto text-red-600`} disabled={busy === "key"} onClick={() => saveKey("")}>
Remove
</button>
)}
</div>
<p className="text-xs text-gray-500">The key is checked with Yoco before it is saved.</p>
</div>
)}
</Card>
{/* ── Webhook secret ──────────────────────────────────────────── */}
<Card icon={Webhook} title="Webhook secret" subtitle="Used to check that payment notifications really come from Yoco.">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-sm bg-gray-50 border rounded px-2 py-1">{cfg.webhookSecretMasked || "—"}</code>
{cfg.webhookSecretSource === "db" && (
<button type="button" className={`${secondaryBtn} ml-auto text-red-600`} disabled={busy === "secret"} onClick={() => saveSecret("")}>
Remove
</button>
)}
</div>
<SourceNote source={cfg.webhookSecretSource} />
<div>
<label className="block text-xs text-gray-600 mb-1">Webhook endpoint on this server</label>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-50 border rounded px-2 py-1.5 flex-1 break-all">{cfg.webhookUrl || "—"}</code>
{cfg.webhookUrl && (
<button type="button" className={secondaryBtn} onClick={() => copy(cfg.webhookUrl)} aria-label="Copy webhook URL">
<Copy className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<div className="flex gap-2 pt-1">
{(["generate", "enter"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setSecretMode(m)}
className={`px-3 py-1 text-xs rounded-lg border ${secretMode === m ? "bg-brand-50 border-brand-300 text-brand-700" : "bg-white border-gray-200 text-gray-600"}`}
>
{m === "generate" ? "Generate new" : "Enter manually"}
</button>
))}
</div>
{secretMode === "generate" ? (
hasKey ? (
<div className="space-y-2">
<p className="text-xs text-gray-500">Registers a webhook with Yoco and saves the secret it returns. Yoco only shows the secret once, so it is stored straight away.</p>
<div className="grid sm:grid-cols-3 gap-2">
<input className={inputCls} value={hookName} onChange={(e) => setHookName(e.target.value)} placeholder="Name" />
<input className={`${inputCls} sm:col-span-2`} value={hookUrl} onChange={(e) => setHookUrl(e.target.value)} placeholder="https://…/api/webhooks/yoco" />
</div>
<button type="button" className={primaryBtn} disabled={busy === "generate" || !hookUrl.trim()} onClick={generate}>
{busy === "generate" ? "Registering…" : "Register webhook"}
</button>
</div>
) : (
<p className="text-xs text-gray-500">Add the secret key first.</p>
)
) : (
<div className="space-y-2">
<p className="text-xs text-gray-500">Paste the secret of a webhook you registered yourself.</p>
<input
type="password"
autoComplete="off"
className={inputCls}
placeholder="whsec_…"
value={secretInput}
onChange={(e) => setSecretInput(e.target.value)}
/>
<button type="button" className={primaryBtn} disabled={!secretInput.trim() || busy === "secret"} onClick={() => saveSecret(secretInput.trim())}>
{busy === "secret" ? "Saving…" : "Save secret"}
</button>
</div>
)}
</Card>
{/* ── Registered webhooks ─────────────────────────────────────── */}
<Card icon={Webhook} title="Registered webhooks" subtitle="Webhooks on this Yoco account. Yoco allows up to 5 and recommends just one.">
{!hasKey ? (
<p className="text-xs text-gray-500">Add the secret key to see the webhooks.</p>
) : hooksError ? (
<p className="text-sm text-red-600">{hooksError}</p>
) : hooks.length === 0 ? (
<p className="text-sm text-gray-500">No webhooks registered.</p>
) : (
<ul className="divide-y border rounded-lg">
{hooks.map((h) => (
<li key={h.id} className="p-3 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-900">{h.name}</span>
<ModeBadge mode={h.mode} />
{h.isActive && <span className="text-[11px] font-medium px-1.5 py-0.5 rounded bg-brand-100 text-brand-700">Active</span>}
<div className="ml-auto flex gap-2">
{confirmDelete === h.id ? (
<>
<button type="button" className="px-3 py-1.5 text-sm rounded-lg bg-red-600 hover:bg-red-700 text-white disabled:opacity-50" disabled={busy === `delete:${h.id}`} onClick={() => removeHook(h.id)}>
{busy === `delete:${h.id}` ? "Deleting…" : "Delete"}
</button>
<button type="button" className={secondaryBtn} onClick={() => setConfirmDelete(null)}>Cancel</button>
</>
) : (
<button type="button" className={secondaryBtn} onClick={() => setConfirmDelete(h.id)} aria-label={`Delete ${h.name}`}>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<p className="text-xs text-gray-500 break-all">{h.url}</p>
{!h.pointsHere && <p className="text-xs text-amber-700">This URL isn&apos;t this server&apos;s webhook endpoint.</p>}
{confirmDelete === h.id && h.isActive && (
<p className="text-xs text-red-600">This is the active webhook. Deleting it also removes the stored secret, and payments won&apos;t be confirmed until a new one is set up.</p>
)}
</li>
))}
</ul>
)}
</Card>
</>
)}
</div>
);
}
@@ -3,6 +3,7 @@
import Link from "next/link";
import { appName } from "@/lib/siteConfig";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
export const Footer = () => {
const { settings } = useSiteSettings();
@@ -19,6 +20,7 @@ export const Footer = () => {
<Link href="/legal/privacy" className="hover:underline text-gray-300">
Privacy Policy
</Link>
<CookieSettingsButton className="hover:underline text-gray-300" />
</div>
</footer>
{/* Spacer so content isn't hidden behind the fixed mobile bottom nav */}
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Cookie } from "lucide-react";
import { getConsent, setConsent } from "@/lib/analytics";
@@ -11,11 +11,24 @@ import { getConsent, setConsent } from "@/lib/analytics";
*/
export function CookieConsent() {
const [show, setShow] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { setShow(getConsent() === null); }, []);
// Publishes the banner's height as --cookie-banner-h so HelpFab can sit above it.
useEffect(() => {
const el = ref.current;
if (!show || !el) return;
const root = document.documentElement;
const update = () => root.style.setProperty("--cookie-banner-h", `${el.offsetHeight}px`);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => { ro.disconnect(); root.style.removeProperty("--cookie-banner-h"); };
}, [show]);
if (!show) return null;
const choose = (v: "accepted" | "declined") => { setConsent(v); setShow(false); };
return (
<div className="fixed bottom-0 inset-x-0 z-40 p-3 sm:p-4 pointer-events-none" role="region" aria-label="Cookie consent">
// On mobile, sit above BottomNav (fixed, h-16 + safe-area, z-50).
<div ref={ref} className="fixed bottom-[calc(4rem+env(safe-area-inset-bottom))] md:bottom-0 inset-x-0 z-40 p-3 sm:p-4 pointer-events-none" role="region" aria-label="Cookie consent">
<div className="pointer-events-auto max-w-3xl mx-auto bg-white border shadow-lg rounded-xl p-4 flex flex-col sm:flex-row sm:items-center gap-3">
<Cookie className="w-6 h-6 text-brand-600 shrink-0 hidden sm:block" />
<p className="text-sm text-gray-700 flex-1">
@@ -0,0 +1,13 @@
"use client";
import React from "react";
import { resetConsent } from "@/lib/analytics";
/** Lets a visitor change their cookie choice: clears it so the notice asks again. */
export function CookieSettingsButton({ className = "", children = "Cookie settings" }: { className?: string; children?: React.ReactNode }) {
return (
<button type="button" className={className} onClick={() => { resetConsent(); window.location.reload(); }}>
{children}
</button>
);
}
+3 -2
View File
@@ -34,8 +34,9 @@ export default function HelpFab() {
type="button"
onClick={openGuide}
aria-label="Help"
// bottom-20 on mobile clears BottomNav (fixed, h-16 + safe-area, z-50).
className="fixed z-40 bottom-20 md:bottom-6 right-4 md:right-6 w-12 h-12 rounded-full bg-brand-600 text-white shadow-lg hover:bg-brand-700 hover:scale-105 hover:shadow-xl active:scale-95 flex items-center justify-center transition-all"
// 5rem on mobile clears BottomNav (fixed, h-16 + safe-area, z-50);
// --cookie-banner-h (set by CookieConsent while it shows) lifts it above the banner.
className="fixed z-40 bottom-[calc(5rem+var(--cookie-banner-h,0px))] md:bottom-[calc(1.5rem+var(--cookie-banner-h,0px))] right-4 md:right-6 w-12 h-12 rounded-full bg-brand-600 text-white shadow-lg hover:bg-brand-700 hover:scale-105 hover:shadow-xl active:scale-95 flex items-center justify-center transition-all"
>
<HelpCircle className="w-6 h-6" />
</button>
@@ -0,0 +1,50 @@
"use client";
import React, { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { Eye } from "lucide-react";
import { currentImpersonation, endImpersonation, logImpersonatedPage, type Impersonation } from "@/lib/impersonation";
/** Bright bar on every page while an admin is viewing the site as someone else. */
export function ImpersonationBanner() {
const pathname = usePathname();
const [imp, setImp] = useState<Impersonation | null>(null);
const [left, setLeft] = useState("");
const [ending, setEnding] = useState(false);
useEffect(() => { setImp(currentImpersonation()); }, []);
// Every page viewed goes into the session's audit trail.
useEffect(() => {
if (imp && pathname) logImpersonatedPage(pathname + (typeof window !== "undefined" ? window.location.search : ""));
}, [imp, pathname]);
useEffect(() => {
if (!imp) return;
const tick = () => {
const ms = new Date(imp.expiresAt).getTime() - Date.now();
if (ms <= 0) { endImpersonation(); return; }
const m = Math.floor(ms / 60000), s = Math.floor((ms % 60000) / 1000);
setLeft(`${m}:${String(s).padStart(2, "0")}`);
};
tick();
const t = setInterval(tick, 1000);
return () => clearInterval(t);
}, [imp]);
if (!imp) return null;
return (
<div className="sticky top-0 z-[60] bg-amber-400 text-amber-950 px-4 py-2 text-sm flex flex-wrap items-center justify-center gap-x-3 gap-y-1 shadow" role="status">
<Eye className="w-4 h-4 shrink-0" />
<span><strong>Viewing as {imp.name}</strong> ({imp.email}) — read-only, nothing can be changed.</span>
<span className="tabular-nums">{left} left</span>
<button
onClick={() => { setEnding(true); endImpersonation(); }}
disabled={ending}
className="px-2.5 py-1 rounded bg-amber-950 text-amber-50 text-xs font-medium hover:bg-black disabled:opacity-60"
>
{ending ? "Ending…" : "End impersonation"}
</button>
</div>
);
}
@@ -14,8 +14,9 @@ export function canOfferTransfer(ticket: any, event: any, isSupervisor = false):
}
/**
* Hand a ticket to someone else: their name and email/phone (plus date of birth and a
* parent's details on youth events). They get a new QR code; the old one stops working.
* Offer a ticket to someone else: their name and email/phone (plus date of birth and a
* parent's details on youth events). There's deliberately no user search — we send a link
* to the details typed, and the ticket only moves (with a new QR code) once they accept.
*/
export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
ticket: any;
@@ -57,9 +58,10 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
<h3 className="text-lg font-semibold">Transfer ticket</h3>
</div>
<p className="text-sm text-gray-600">
We&apos;ll send them a link to accept the ticket. Nothing changes until they accept — then{" "}
{places > 1
? `This ticket is for ${places} people. One place moves to the new holder on their own ticket; the other ${places - 1} stay on this one.`
: "The new holder gets their own ticket with a new QR code. The current QR code (including any screenshots of it) will stop working."}
? `one of this ticket's ${places} places moves to them on their own ticket; the other ${places - 1} stay on this one.`
: "they get their own ticket with a new QR code, and the current QR code (including any screenshots of it) stops working."}
{" "}The booking and payment stay with the person who booked.
</p>
<div>
@@ -86,7 +88,7 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
</select>
</div>
)}
<p className="text-xs text-gray-500">An email or phone number is needed to send them the ticket. If they don&apos;t have an account yet, one is created and they get a link to activate it.</p>
<p className="text-xs text-gray-500">Enter the email and/or phone number to send the link to — we don&apos;t look people up. The link works for 7 days (or until the event starts).</p>
{youth && (
<div className="space-y-2 border-t pt-3">
<div>
@@ -109,7 +111,7 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={busy || !f.name.trim() || (!f.email.trim() && !f.phone.trim())} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">
{busy ? "Transferring…" : "Transfer"}
{busy ? "Sending…" : "Send transfer link"}
</button>
</div>
</form>
@@ -1,5 +1,5 @@
import React from "react";
import { Users, UserPlus, ShieldAlert } from "lucide-react";
import { Users, UserPlus, ShieldAlert, Eye } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -30,6 +30,9 @@ export const dashboardAdminUsersHelpContent: HelpContent = {
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={Eye} title="Impersonate" tone="violet">
See the site exactly as a user does, to help with a problem (&quot;my ticket isn&apos;t showing&quot;). Give a reason. A bright banner shows while you&apos;re viewing, for up to 30 minutes. It&apos;s strictly read-only: nothing can be registered, paid, changed or sent. The reason and every page you open are recorded in the audit log. You need two-factor authentication on, and admins can&apos;t be impersonated. Optionally, users get an email when this happens (Site Settings → Legal → Support access).
</GuideItem>
<GuideItem icon={Users} title="Sign out everywhere" tone="amber">
Revokes all of that user&apos;s active sessions — useful if a device was lost or access needs to be cut immediately.
</GuideItem>
+1 -1
View File
@@ -61,7 +61,7 @@ export const dashboardUserHelpContent: HelpContent = {
Get your selected tickets onto your phone or paper before the event — as a printed page, an email, or a WhatsApp message.
</GuideItem>
<GuideItem icon={ArrowRightLeft} title="Transfer a ticket" tone="violet">
Can&apos;t make it? Tap &quot;Transfer&quot; to give your ticket to someone else before the event. Enter their name and email or phone, and they get a new ticket with a new QR code. The old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can&apos;t be transferred once it has been scanned.
Can&apos;t make it? Tap &quot;Transfer&quot; to offer your ticket to someone else before the event. Type their name and email and/or phone number — there&apos;s no searching for people — and we send them a link to accept it. Until they accept, the ticket is still yours (you&apos;ll see &quot;Waiting for … to accept&quot;, and can cancel). Once they accept, they get their own ticket with a new QR code, and the old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can&apos;t be transferred once it has been scanned.
</GuideItem>
</div>
</div>
@@ -25,7 +25,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
Per ticket-type totals with a stepper to check attendees in against the scan record.
</GuideItem>
<GuideItem icon={Ticket} title="Tickets" tone="amber">
Browse all tickets for the event; print or send (email/WhatsApp) any of them individually, or transfer one to someone else before the event starts (supervisors can transfer even when transfers are turned off for the event). Scanning a transferred ticket&apos;s old QR code says who it went to.
Browse all tickets for the event; print or send (email/WhatsApp) any of them individually, or offer one to someone else before the event starts — they get a link and the ticket moves when they accept (supervisors can transfer even when transfers are turned off for the event). Scanning a transferred ticket&apos;s old QR code says who it went to.
</GuideItem>
<GuideItem icon={RotateCcw} title="Refund" tone="rose">
Issue a refund against an existing payment for the selected registration.
+9
View File
@@ -32,10 +32,19 @@ export function setConsent(v: "accepted" | "declined") {
try { window.dispatchEvent(new Event(CONSENT_EVENT)); } catch { /* no window */ }
}
/** Forgets the visitor's choice (and any visit id) so the cookie notice asks again. */
export function resetConsent() {
try {
localStorage.removeItem(CONSENT_KEY);
localStorage.removeItem(SESSION_KEY);
} catch { /* storage unavailable */ }
}
/** The anonymous session id — only when cookies were accepted. */
export function analyticsSessionId(): string | undefined {
if (getConsent() !== "accepted") return undefined;
try {
if (localStorage.getItem("hope_events_impersonation")) return undefined;
let id = localStorage.getItem(SESSION_KEY);
if (!id) {
id = crypto.randomUUID();
+5
View File
@@ -119,6 +119,11 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
} catch { /* not JSON — use raw text */ }
// An admin/supervisor session from before 2FA was required for their role:
// end it and send them to log in, where they enrol.
// The admin's "view as" session ended or expired: put their own session back.
if (res.status === 401 && data?.code === "IMPERSONATION_ENDED" && typeof window !== "undefined") {
const { restoreAdmin } = await import("@/lib/impersonation");
restoreAdmin();
}
if (res.status === 401 && data?.code === "TWO_FACTOR_REQUIRED" && typeof window !== "undefined") {
localStorage.removeItem("hope_events_token");
localStorage.removeItem("hope_events_user_cache");
+72
View File
@@ -0,0 +1,72 @@
"use client";
/**
* Admin "view as user". The admin's own token (and cached profile) are put aside while
* the impersonation token is the active one, and restored when it ends. Everything is
* read-only on the server (any write is refused), so this is only about which token the
* browser sends.
*/
import { API_BASE } from "@/lib/api";
const TOKEN_KEY = "hope_events_token";
const USER_CACHE_KEY = "hope_events_user_cache";
const ADMIN_TOKEN_KEY = "hope_events_admin_token";
const ADMIN_CACHE_KEY = "hope_events_admin_user_cache";
const IMP_KEY = "hope_events_impersonation";
export type Impersonation = { sessionId: string; expiresAt: string; name: string; email: string };
export function currentImpersonation(): Impersonation | null {
try {
const raw = localStorage.getItem(IMP_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export const isImpersonating = () => !!currentImpersonation();
/** Switches this browser to the impersonation token and opens the user's dashboard. */
export function beginImpersonation(res: { token: string; session: { id: string; expiresAt: string; target: { name: string; email: string } } }) {
try {
localStorage.setItem(ADMIN_TOKEN_KEY, localStorage.getItem(TOKEN_KEY) || "");
localStorage.setItem(ADMIN_CACHE_KEY, localStorage.getItem(USER_CACHE_KEY) || "");
localStorage.setItem(TOKEN_KEY, res.token);
localStorage.removeItem(USER_CACHE_KEY);
localStorage.setItem(IMP_KEY, JSON.stringify({ sessionId: res.session.id, expiresAt: res.session.expiresAt, name: res.session.target.name, email: res.session.target.email }));
} catch { /* storage unavailable */ }
window.location.href = "/dashboard/user";
}
/** Puts the admin's own session back (no server call). */
export function restoreAdmin(redirect = true) {
try {
const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY);
const adminCache = localStorage.getItem(ADMIN_CACHE_KEY);
if (adminToken) localStorage.setItem(TOKEN_KEY, adminToken); else localStorage.removeItem(TOKEN_KEY);
if (adminCache) localStorage.setItem(USER_CACHE_KEY, adminCache); else localStorage.removeItem(USER_CACHE_KEY);
localStorage.removeItem(ADMIN_TOKEN_KEY);
localStorage.removeItem(ADMIN_CACHE_KEY);
localStorage.removeItem(IMP_KEY);
} catch { /* storage unavailable */ }
if (redirect) window.location.href = "/dashboard/admin/users";
}
/** Ends the session on the server (audited), then restores the admin. */
export async function endImpersonation() {
try {
const token = localStorage.getItem(TOKEN_KEY);
if (token) await fetch(`${API_BASE}/api/admin/impersonate/end`, { method: "POST", headers: { Authorization: `Bearer ${token}` } });
} catch { /* the session expires by itself anyway */ }
restoreAdmin();
}
/** Records a page view in the session's audit trail. */
export function logImpersonatedPage(path: string) {
try {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return;
fetch(`${API_BASE}/api/admin/impersonate/log`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ path }), keepalive: true }).catch(() => {});
} catch { /* ignore */ }
}
+1 -1
View File
@@ -34,7 +34,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
- **Feedback after every event** — a short survey with star ratings goes out automatically the morning after, with one reminder. See response rates and compare this year's camp with last year's.
- **Sponsor a spot** — let your congregation fund places for people who can't afford to come, with an optional "12 of 20 spots sponsored" progress bar. You decide who receives the help; sponsors never see who.
- **Rooms & groups for camps** — drag campers into cabins, small groups or buses (or auto-fill by capacity and gender), print lists for leaders, and show the cabin on each ticket.
- **Ticket transfer** — can't make it? Pass your ticket on to a friend; they get a fresh QR code and the old one stops working.
- **Ticket transfer** — can't make it? Send your ticket to a friend's email or phone; once they accept, they get a fresh QR code and the old one stops working.
- **Works without Wi-Fi** — lock a check-in point to a phone and keep scanning with no signal; scans sync automatically when the connection is back.
- **Scan points** — set up separate check-in points (e.g. main hall, dinner) that only accept the right tickets.
- **At-the-door desk** — search by name, phone, or ticket number; register walk-ins; take cash, card, or EFT payments; check people in; and print or resend tickets on the spot.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.22.0",
"version": "1.25.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.22.0",
"version": "1.25.1",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.22.0",
"version": "1.25.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",