Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce125e36c | ||
|
|
96bfeff205 | ||
|
|
c3012cb3f6 | ||
|
|
0306c4dcb1 |
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.12.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Two-factor authentication is now required for Admin and Supervisor accounts. An admin or supervisor without it is sent a code at their next login (by email, or WhatsApp when SMTP isn't set up), and entering it turns two-factor on — the same code step as before, with a note explaining why. Trusted devices don't skip this first code. They can change whether codes come by email or WhatsApp from Profile & Security, but can no longer turn two-factor off (`POST /api/users/2fa/disable` returns 403 for them).
|
||||
- Existing admin and supervisor sessions without two-factor are ended on their next request (`401` with `code: "TWO_FACTOR_REQUIRED"`), and the login page explains why. Promoting a user to supervisor or admin also ends their sessions if they don't have two-factor yet, so they enrol at their next login. An admin "Reset 2FA" for a supervisor/admin now means they set it up again at their next login.
|
||||
- The requirement is skipped when no channel can deliver a code — no SMTP configured and no WhatsApp — so a fresh install (or the setup wizard, which also gets a short grace period) never locks the first admin out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off as an emergency switch.
|
||||
|
||||
- `FEATURES.md`, a repo-root overview of everything the platform does — roles, public site, events, registration, payments, tickets, messaging, dashboards, cashup, reports, settings, security, background jobs, and a page map.
|
||||
|
||||
### Changed
|
||||
|
||||
- `info.md` (the overview for prospective churches) brought up to date with features added since it was written — donations, invoices and receipts, cashup and costs, profit reporting, audit trail, scheduled messaging, self-service kiosk, scan points, backups, branding, and privacy controls.
|
||||
|
||||
## [1.11.0] - 2026-10-08
|
||||
|
||||
### Added
|
||||
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
# Hope Events — Feature Overview
|
||||
|
||||
A complete map of what the platform does, grouped by who uses it. For setup and
|
||||
deployment see [README.md](./README.md); for the API see
|
||||
[backend/API_DOCUMENTATION.md](./backend/API_DOCUMENTATION.md); for setting
|
||||
defaults see [SETTINGS.md](./SETTINGS.md); for history see
|
||||
[CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
**Stack:** Next.js (React, Tailwind, shadcn/ui) frontend · Express + Prisma +
|
||||
PostgreSQL backend · Yoco for card payments · SMTP for email · WAWP for
|
||||
WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Roles](#roles)
|
||||
2. [Public site](#public-site)
|
||||
3. [Events](#events)
|
||||
4. [Registration](#registration)
|
||||
5. [Payments, donations & refunds](#payments-donations--refunds)
|
||||
6. [Tickets & check-in](#tickets--check-in)
|
||||
7. [Attendee forms](#attendee-forms)
|
||||
8. [Messaging & notifications](#messaging--notifications)
|
||||
9. [User dashboard (My Events)](#user-dashboard-my-events)
|
||||
10. [Staff tools](#staff-tools)
|
||||
11. [Supervisor tools](#supervisor-tools)
|
||||
12. [Admin tools](#admin-tools)
|
||||
13. [Cashup & event costs](#cashup--event-costs)
|
||||
14. [Reports](#reports)
|
||||
15. [Site settings & setup wizard](#site-settings--setup-wizard)
|
||||
16. [Accounts & security](#accounts--security)
|
||||
17. [Help system](#help-system)
|
||||
18. [Background jobs](#background-jobs)
|
||||
19. [Operations](#operations)
|
||||
20. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
## Roles
|
||||
|
||||
Four roles, each including everything the roles below it can do:
|
||||
|
||||
| Role | Can do |
|
||||
|------|--------|
|
||||
| **User** | Browse events, register, pay, donate, view tickets, fill in forms, manage their own profile |
|
||||
| **Staff** | Scan tickets, view/print event tickets, view registrations and form responses |
|
||||
| **Supervisor** | Create and manage events, record payments/refunds, assign donations, at-the-door desk, manual registration, bulk email/WhatsApp, reports, site banner, self-service kiosk |
|
||||
| **Admin** | Manage users and roles, all registrations, event costs and cashup (close/reopen events), audit log, site settings, WhatsApp connection, backups |
|
||||
|
||||
---
|
||||
|
||||
## Public site
|
||||
|
||||
- **Home page** — hero section with the organisation's name and branding, upcoming
|
||||
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
|
||||
logged in).
|
||||
- **Events list** (`/events`) — event cards with picture, dates, location,
|
||||
price, and a Register / Sold Out / Contact us button.
|
||||
- **Event detail** (`/events/[id]`) — description, ticket options with
|
||||
variant and early-bird pricing, low-stock and sold-out badges, downloadable
|
||||
attachments, embedded Google Map with "Directions" link, and an
|
||||
**Add to calendar** (`.ics`) button.
|
||||
- **Short links** — events can have an alias (e.g. `/camp-2025`) that redirects
|
||||
to the event. Only plausible aliases hit the database, so bot probes are
|
||||
rejected cheaply.
|
||||
- **Contact page** (`/contact`) — organisation email, phone, address and map.
|
||||
- **Legal pages** — Terms of Use and Privacy Policy, filled from Site Settings.
|
||||
- **Site banner** — a site-wide announcement bar set by supervisors.
|
||||
- **Lockdown rules** (`/lockdown-rules`) — static rules page for the youth
|
||||
lockdown event.
|
||||
- **SEO** — `sitemap.xml` (every public page and event), `robots.txt`, and
|
||||
Open Graph/Twitter link-preview metadata.
|
||||
- **Theming** — brand colours, logo and favicon come from Site Settings.
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
Created and edited by supervisors through a 6-step wizard: **Basic Details →
|
||||
Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
|
||||
- **Basic details** — title, description, start/end dates, registration
|
||||
deadline, picture (upload or URL), location (defaults to the org's address),
|
||||
contact person.
|
||||
- **Contact-only events** — for things like baptisms: shown publicly with a
|
||||
"Contact us" popup instead of registration, with no tickets.
|
||||
- **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner,
|
||||
T-shirt), each with a price and optional **stock limit**. One option is marked
|
||||
as the main ticket, which is what gets checked in at the door.
|
||||
- **Variants** — sub-types of an option (sizes, colours, Adult/Child/VIP), each
|
||||
with an optional price override and its own stock limit.
|
||||
- **Early-bird tiers** — deadline- and/or stock-limited cheaper prices per
|
||||
option or variant. Prices are locked in per purchase ("tranches"), so buying
|
||||
more later doesn't re-price what was already bought.
|
||||
- **Sections** — named scan points (e.g. "Main hall", "Dinner") limited to
|
||||
certain ticket options, so a scanner at that point only accepts those tickets.
|
||||
- **Visibility** — active/inactive, hidden (reachable only by link), scheduled
|
||||
go-live date, require login vs. allow guest registration, or no registration
|
||||
at all.
|
||||
- **Attachments** — downloadable files (maps, schedules, waivers) shown on the
|
||||
event page.
|
||||
- **Notify recipients** — choose which staff get registration/payment alerts and
|
||||
the daily summary for the event.
|
||||
- **Sold-out handling** — an event is sold out when every stock-limited option
|
||||
is exhausted. "X remaining" badges appear at a threshold that scales with
|
||||
capacity.
|
||||
|
||||
---
|
||||
|
||||
## Registration
|
||||
|
||||
- **Online registration** (`/register/[eventId]`) — pick options, variants and
|
||||
quantities; see early-bird prices and stock warnings; fill in the event form.
|
||||
- **Guest registration** — when an event doesn't require login, an inactive
|
||||
account is created and an activation link is sent by email or WhatsApp.
|
||||
- **Statuses** — Pending, Confirmed, Partially Paid, Paid, Cancelled.
|
||||
- **Edit options** — users can add or change items on their registration
|
||||
(not for past or closed events).
|
||||
- **Self-cancellation** — allowed only before any payment; admins can cancel
|
||||
any time.
|
||||
- **Pricing refresh** — when an unpaid registration is viewed or paid, expired
|
||||
early-bird prices are re-evaluated, and the user is warned if the total
|
||||
changed.
|
||||
- **Manual registration** (supervisor) — register someone on their behalf,
|
||||
creating an account if needed. Includes a "Record Payment" tab.
|
||||
- **Duplicate-account protection** — if an email and phone belong to two
|
||||
different accounts, the registration is blocked instead of merging them.
|
||||
|
||||
---
|
||||
|
||||
## Payments, donations & refunds
|
||||
|
||||
- **Online card payments via Yoco** — checkout for the full outstanding balance,
|
||||
confirmed by a signed webhook.
|
||||
- **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.
|
||||
- **Donations** — users can donate from their dashboard (`/dashboard/user/donate`).
|
||||
Supervisors can **assign** a donation (fully or partly) to someone's
|
||||
registration and **unassign** it later. Assignments are kept as separate
|
||||
records, so the original donation never changes.
|
||||
- **Yoco reconciliation** — imported Yoco transactions can be matched to a
|
||||
registration, recorded as a donation (optionally choosing the donor), or
|
||||
ignored.
|
||||
- **Invoices & receipts** — branded PDFs: an invoice (itemised, early-bird
|
||||
discount, balance due, pay-now link and QR code) and a payment receipt. Sent
|
||||
automatically and re-sendable on demand by email or WhatsApp.
|
||||
- **Method normalisation** — Apple Pay / Google Pay / checkout payments count as
|
||||
"Card" in reports and user views.
|
||||
|
||||
---
|
||||
|
||||
## Tickets & check-in
|
||||
|
||||
- **Automatic tickets** — QR-coded tickets are generated when a registration is
|
||||
fully paid (or free), and sent as a PDF by email and/or WhatsApp.
|
||||
- **Quantity tickets** — one ticket can cover several people and be redeemed
|
||||
partially (e.g. check in 2 of 3).
|
||||
- **QR scanner** — works in any mobile browser. A preview checks the ticket is
|
||||
for the selected event/section, then a confirmation screen shows holder, type
|
||||
and remaining quantity.
|
||||
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
|
||||
checked in and how many remain.
|
||||
- **Scan stats** — recent scans and per-event scan counts.
|
||||
- **Printing** — A4 sheets (8 tickets per page) and A6 single tickets.
|
||||
- **Resend** — send tickets to a different email/phone at the door.
|
||||
|
||||
---
|
||||
|
||||
## Attendee forms
|
||||
|
||||
- **Form builder** per event — field types: Yes/No, Text, Paragraph, Number,
|
||||
Date, plus Statement and Heading blocks; required/optional, help text,
|
||||
reordering. The whole form can be marked required.
|
||||
- **Drafts** — partially filled forms are auto-saved.
|
||||
- **Responses** — view, filter (event, user, past/inactive) and print one
|
||||
response per A4 page. Supervisors can edit responses.
|
||||
|
||||
---
|
||||
|
||||
## Messaging & notifications
|
||||
|
||||
Every user picks a **notification preference**: Email, WhatsApp, or Both.
|
||||
|
||||
**Automatic notifications**
|
||||
|
||||
| Trigger | Goes to |
|
||||
|---------|---------|
|
||||
| Registration created / updated | Registrant (with invoice if unpaid) + event notify recipients |
|
||||
| Payment recorded / donation applied / unassigned | Registrant (with receipt) + admins |
|
||||
| Refund processed | Registrant + admins |
|
||||
| Tickets generated | Registrant (ticket PDF) |
|
||||
| Ticket checked in | Ticket holder |
|
||||
| Daily summary, 07:00, for each live event that hasn't started yet | Registrations inbox + event notify recipients |
|
||||
| Account registered, activation, login, password changed/reset, account closed | The account holder |
|
||||
|
||||
**Bulk messaging** (supervisor)
|
||||
|
||||
- **Email attendees** and **WhatsApp attendees** — filter by status or pick
|
||||
specific attendees, preview, send now or schedule for later.
|
||||
- **Placeholders** — `{{name}}`, `{{event.title}}`, `{{event.link}}`,
|
||||
`{{event.start}}`, `{{balance}}`, and `{{payment.link}}` (a live Yoco link for each recipient's
|
||||
outstanding balance).
|
||||
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
|
||||
one event.
|
||||
- **Automations** — schedule a sequence of lifecycle emails for an event
|
||||
(pre-event, final reminder, thank-you, promo for another event).
|
||||
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
|
||||
run in the background and are purged 24 h after sending.
|
||||
|
||||
**WhatsApp** is connected through the WAWP API: an admin links a number in
|
||||
Site Settings → WhatsApp (QR code or pairing code), with status, restart and
|
||||
logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
---
|
||||
|
||||
## User dashboard (My Events)
|
||||
|
||||
- Registrations with status badges, event thumbnails, and Closed/Past/Inactive
|
||||
tags. Past and closed events are hidden by default.
|
||||
- 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.
|
||||
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
|
||||
date, method and payment/refund.
|
||||
- **Donate** page.
|
||||
- **Forms** — complete outstanding attendee forms.
|
||||
- **Profile & Security** — edit details and notification preference, change
|
||||
password, see recent account activity (logins, password changes, device),
|
||||
sign out everywhere, deactivate or delete my data.
|
||||
|
||||
---
|
||||
|
||||
## Staff tools
|
||||
|
||||
- **Staff dashboard** — scan stats and quick actions.
|
||||
- **Ticket scanning** (`/dashboard/staff/ticket-scanning`) — camera QR scanner
|
||||
with event and section filters.
|
||||
- **Event tickets & printing** (`/dashboard/staff/event-tickets`) — list, search
|
||||
and print an event's tickets.
|
||||
|
||||
---
|
||||
|
||||
## Supervisor tools
|
||||
|
||||
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
|
||||
tickets sold, each vs. the previous month), revenue trend chart, top events,
|
||||
and recent scans. Tiles link to the matching report.
|
||||
- **Manage events** — the 6-step event wizard, ticket options, variants,
|
||||
sections, form and attachments.
|
||||
- **At the door** — search by name/phone/ticket ID, walk-in registration,
|
||||
payments, a **Check-In** tab for redeeming main tickets by quantity, and
|
||||
ticket printing/resending.
|
||||
- **Self-service kiosk** (`/self-service`) — a touchscreen page a supervisor
|
||||
unlocks for an event, where visitors register themselves. Existing accounts
|
||||
are matched by email/phone, and any change to their details needs
|
||||
confirmation.
|
||||
- **Manual registration** — register someone on their behalf and record their
|
||||
payment.
|
||||
- **Payments** — tabs for Payment, Refund, Donations (assign/unassign),
|
||||
Reconcile (Yoco transactions), and Payment Link.
|
||||
- **Email attendees / WhatsApp attendees** — bulk and scheduled messaging.
|
||||
- **Forms** — view, filter, edit and print form responses.
|
||||
- **Reports** — see [Reports](#reports).
|
||||
- **Site banner** (`/set-banner`) — set the announcement bar.
|
||||
|
||||
---
|
||||
|
||||
## Admin tools
|
||||
|
||||
- **Admin dashboard** — same KPIs as supervisor, plus admin quick actions.
|
||||
- **Manage users** — server-side search and filters (role, active), paging,
|
||||
edit role and notification preference, revoke sessions, delete or
|
||||
**anonymise** personal data.
|
||||
- **Registrations** — all registrations, with filters (event, status, text),
|
||||
status counts, revenue and outstanding totals, and per-registration options,
|
||||
form answers and payments (with who recorded each one).
|
||||
- **Cashup** — see below.
|
||||
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
|
||||
refunds, donation assign/unassign, manual registrations, staff
|
||||
cancellations, event create/update/delete, and settings changes.
|
||||
- **Site settings** — see below.
|
||||
|
||||
---
|
||||
|
||||
## Cashup & event costs
|
||||
|
||||
- **Event costs** — once-off or per-item costs (optionally linked to a ticket
|
||||
option), with a "paid from takings" method so cash on hand is correct.
|
||||
- **Expected vs. actual** — expected takings per method (cash/card/EFT/other)
|
||||
compared with actual amounts, with variance shown.
|
||||
- **Cash denomination counts** — entered per staff member, at any time. The
|
||||
event's actual cash is the sum of these counts.
|
||||
- **Payment accountability by staff member** — what each person recorded, by
|
||||
method, with live cash variance.
|
||||
- **Close / quick close / reopen** an event — closing locks it from further
|
||||
edits and hides it from the kiosk and user actions. Drafts are saved as you
|
||||
go.
|
||||
- **Report tab** — a read-only cashup summary, shown automatically once the
|
||||
event is closed.
|
||||
- **Cashup audit trail** — every close/reopen with totals and who did it.
|
||||
|
||||
---
|
||||
|
||||
## Reports
|
||||
|
||||
A full-width workspace with shared filters (events, date range with presets,
|
||||
include past/inactive/closed) and a searchable grid of reports, each opening in
|
||||
a viewer with its own filters, stat tiles and bar charts.
|
||||
|
||||
| Category | Reports |
|
||||
|----------|---------|
|
||||
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs) · Profit report |
|
||||
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) |
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
|
||||
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
|
||||
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
|
||||
deep-link into reports with `?report=&range=`.
|
||||
|
||||
---
|
||||
|
||||
## Site settings & setup wizard
|
||||
|
||||
**Setup wizard** (`/setup`) — on a fresh database, walks through organisation
|
||||
details, the first admin account and branding. No manual seeding needed.
|
||||
|
||||
**Site Settings** (`/dashboard/admin/settings`) — stored in the database and
|
||||
applied without a restart. Secrets are encrypted with AES-256-GCM.
|
||||
|
||||
| Tab | Contains |
|
||||
|-----|----------|
|
||||
| Organisation | Name, tagline, contact email/phone, address (with map link) |
|
||||
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Notifications | Registrations inbox and admin notification emails |
|
||||
| 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 |
|
||||
| Backups | List, run now, download database backups |
|
||||
|
||||
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
|
||||
---
|
||||
|
||||
## Accounts & security
|
||||
|
||||
- Register, log in, forgot/reset password, activate account (for accounts
|
||||
created on someone's behalf).
|
||||
- JWT authentication (HS256 pinned) with **session revocation** (token
|
||||
versioning) — users can sign out everywhere, and admins can do it for any user.
|
||||
- **Two-factor authentication** — a 6-digit login code by email or WhatsApp
|
||||
(10-minute expiry, 5 attempts, resend), with "Trust this device for 30 days".
|
||||
**Required for Admins and Supervisors**: their next login sends a code that turns
|
||||
it on, and they can change the channel but not turn it off (skipped only when no
|
||||
email or WhatsApp channel can deliver a code; `TWO_FACTOR_REQUIRED_ROLES=false`
|
||||
is the emergency switch). Optional for everyone else from Profile & Security.
|
||||
Admins can reset it for a locked-out user, and the kiosk asks for the code at
|
||||
setup.
|
||||
- **Login lockout** after repeated failed attempts, plus login rate limiting
|
||||
(`TRUST_PROXY` to read real IPs behind a proxy).
|
||||
- **Security event log** — logins, password changes and resets, with device
|
||||
and IP.
|
||||
- **Account closure** — deactivate, or anonymise personal data while keeping
|
||||
financial records.
|
||||
- **Upload hardening** — server-generated filenames, and uploads served with a
|
||||
sandbox CSP and `nosniff`.
|
||||
|
||||
---
|
||||
|
||||
## Help system
|
||||
|
||||
A floating **"Need help?"** button on every page opens a page-specific guide
|
||||
(tabs, quick links). Every page has dedicated content (`frontend/src/content/help/`),
|
||||
falling back to a general guide.
|
||||
|
||||
---
|
||||
|
||||
## Background jobs
|
||||
|
||||
| Job | Schedule |
|
||||
|-----|----------|
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
|
||||
| Daily event summary email | 07:00 daily |
|
||||
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
|
||||
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
|
||||
one above 500 MB of memory.
|
||||
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
|
||||
set.
|
||||
- Unhandled promise rejections are logged and don't crash the server.
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`).
|
||||
- Jest tests for early-bird tranche pricing and donation balances.
|
||||
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Page map
|
||||
|
||||
| Path | Who | What |
|
||||
|------|-----|------|
|
||||
| `/` | Public | Home |
|
||||
| `/events`, `/events/[id]` | Public | Event list and detail |
|
||||
| `/[alias]` | Public | Event short link |
|
||||
| `/register/[eventId]` | Public / User | Registration |
|
||||
| `/registration/success` | Public / User | Confirmation, form and pay |
|
||||
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Yoco return pages |
|
||||
| `/forms` | Public / User | Attendee forms |
|
||||
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
|
||||
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
|
||||
| `/setup` | First run | Setup wizard |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
|
||||
@@ -112,6 +112,7 @@ Documentation: see Frontend README for environment and scripts.
|
||||
- Backend README: ./backend/README.md
|
||||
- Frontend README: ./frontend/README.md
|
||||
- Site Settings — defaults reference: ./SETTINGS.md
|
||||
- Feature overview: ./FEATURES.md
|
||||
|
||||
## Recent Changes
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ DATABASE_URL="postgresql://username:password@localhost:5432/hope_events"
|
||||
# ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
JWT_SECRET=your_jwt_secret_here_minimum_32_characters
|
||||
|
||||
# Two-factor authentication is required for admins and supervisors whenever
|
||||
# email (SMTP) or WhatsApp can deliver a code. Set to false only as an emergency
|
||||
# switch, e.g. if the mail server is down and admins are locked out.
|
||||
# TWO_FACTOR_REQUIRED_ROLES=true
|
||||
|
||||
# ─── Server ───────────────────────────────────────────────────────────────────
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
@@ -86,6 +86,8 @@ If the account has two-factor authentication on and `deviceToken` is missing, ex
|
||||
{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" }
|
||||
```
|
||||
|
||||
**Required for admins and supervisors.** An admin or supervisor without 2FA gets the same challenge with `"enrolling": true` (trusted devices don't skip it), sent to their email when SMTP is configured, otherwise WhatsApp. Verifying it turns 2FA on with that channel. If neither channel can deliver a code, the requirement is skipped so the account isn't locked out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off.
|
||||
|
||||
### POST /api/users/login/2fa
|
||||
```json
|
||||
{ "challengeId": "uuid", "code": "123456", "rememberDevice": true }
|
||||
@@ -95,7 +97,9 @@ Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberD
|
||||
### Two-factor setup (`/api/users/2fa/*`)
|
||||
- `POST /api/users/2fa/enable` — `{ "method": "email" | "whatsapp", "password": "..." }` sends a code to that channel and returns `{ challengeId, method, destination }`. WhatsApp needs a valid phone number on the account and a connected WhatsApp session.
|
||||
- `POST /api/users/2fa/confirm` — `{ "challengeId": "uuid", "code": "123456" }` turns 2FA on.
|
||||
- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off.
|
||||
- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off. Returns 403 for admins and supervisors, who must keep it on (they can switch channel with `enable`/`confirm`).
|
||||
|
||||
An admin or supervisor session without 2FA (from before the requirement, or after a promotion or 2FA reset) is rejected by every protected route with `401 { "code": "TWO_FACTOR_REQUIRED" }`; logging in again enrols them. Promoting a user to supervisor/admin ends their sessions when they don't have 2FA yet.
|
||||
|
||||
At login, if a WhatsApp code can't be sent, the code falls back to the account's email address.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const generateToken = (userId, role, tokenVersion = 0) => {
|
||||
// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
|
||||
const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
|
||||
return jwt.sign(
|
||||
{ id: userId, role, tokenVersion },
|
||||
{ ...extra, id: userId, role, tokenVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
@@ -172,7 +172,9 @@ const setupRegister = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
|
||||
// Marked as a setup token so the 2FA requirement doesn't block the rest of the
|
||||
// wizard; the admin enrols at their first normal login.
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0, { setup: true });
|
||||
res.json({ token, name: user.name, email: user.email });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
|
||||
@@ -13,6 +13,8 @@ const {
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
} = require('../utils/twoFactor');
|
||||
const axios = require('axios');
|
||||
|
||||
@@ -327,6 +329,17 @@ const loginUser = async (req, res) => {
|
||||
return res.json({ twoFactorRequired: true, ...challenge });
|
||||
}
|
||||
|
||||
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
|
||||
// first channel that can deliver one, and verifying it turns 2FA on. A
|
||||
// trusted device can't skip this — nothing has been verified yet.
|
||||
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
|
||||
const method = await enforcementMethod(updated);
|
||||
if (method) {
|
||||
const challenge = await createChallenge(updated, 'login', method);
|
||||
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
|
||||
}
|
||||
}
|
||||
|
||||
completeLogin(updated, req, res);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
@@ -341,12 +354,23 @@ const verifyLoginTwoFactor = async (req, res) => {
|
||||
const { challengeId, code, rememberDevice } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
|
||||
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
// First login since 2FA became required for this role: the code proves the
|
||||
// channel works, so turn 2FA on with it.
|
||||
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
const extra = {};
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
@@ -430,6 +454,11 @@ const disableTwoFactor = async (req, res) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
if (requiresTwoFactor(user)) {
|
||||
res.status(403);
|
||||
throw new Error("Two-factor authentication is required for your role and can't be turned off.");
|
||||
}
|
||||
|
||||
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
@@ -491,7 +520,7 @@ const getUserProfile = async (req, res) => {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json(user);
|
||||
res.json({ ...user, twoFactorRequired: requiresTwoFactor(user) });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
@@ -596,6 +625,7 @@ const updateUserProfile = async (req, res) => {
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
twoFactorRequired: requiresTwoFactor(updatedUser),
|
||||
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -770,6 +800,13 @@ const updateUser = async (req, res) => {
|
||||
data.twoFactorMethod = null;
|
||||
}
|
||||
|
||||
// Promoted to a role that requires 2FA without having it: end their sessions
|
||||
// so the next login enrols them.
|
||||
const willHaveTwoFactor = resetTwoFactor ? false : user.twoFactorEnabled;
|
||||
if (!willHaveTwoFactor && data.role !== user.role && requiresTwoFactor({ role: data.role })) {
|
||||
data.tokenVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
|
||||
@@ -392,6 +392,7 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
|
||||
{ status:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:200, desc:'Admin/supervisor without 2FA — code sent; verifying it turns 2FA on', body:{ twoFactorRequired:true, enrolling:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT',
|
||||
@@ -451,7 +452,11 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]},
|
||||
{ method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication',
|
||||
request:{ body:{ password:'secret123' }},
|
||||
responses:[{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }}]},
|
||||
notes:'Admins and supervisors must keep two-factor on — 403 for them.',
|
||||
responses:[
|
||||
{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }},
|
||||
{ status:403, desc:'Required for this role', body:{ message:"Two-factor authentication is required for your role and can't be turned off." }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
|
||||
request:{ body:{ password:'secret123' }},
|
||||
responses:[
|
||||
|
||||
@@ -2,6 +2,10 @@ const rateLimit = require("express-rate-limit");
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
const { mustEnrolTwoFactor } = require('../utils/twoFactor');
|
||||
|
||||
// The setup wizard's token may finish setup without 2FA for this long.
|
||||
const SETUP_TOKEN_GRACE_SECONDS = 2 * 60 * 60;
|
||||
|
||||
// Protect routes - verify token
|
||||
const protect = async (req, res, next) => {
|
||||
@@ -29,7 +33,8 @@ const protect = async (req, res, next) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
phoneNumber: true,
|
||||
tokenVersion: true
|
||||
tokenVersion: true,
|
||||
twoFactorEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,6 +56,17 @@ const protect = async (req, res, next) => {
|
||||
return next(new Error('Session has been revoked. Please log in again.'));
|
||||
}
|
||||
|
||||
// Admins and supervisors must use 2FA. A session that predates the
|
||||
// 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))) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -49,6 +49,11 @@ function _getTransporter() {
|
||||
return { transporter: _cachedTransporter, cfg };
|
||||
}
|
||||
|
||||
/** True when a real SMTP server is configured (not the jsonTransport dev fallback). */
|
||||
function isSmtpConfigured() {
|
||||
return !!_smtpConfig().host;
|
||||
}
|
||||
|
||||
async function sendMail({ to, subject, html, text, attachments }) {
|
||||
// Never send to anonymised/deleted accounts
|
||||
if (!to || String(to).endsWith('@deleted.invalid')) {
|
||||
@@ -417,6 +422,7 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
|
||||
module.exports = {
|
||||
sendMail,
|
||||
isSmtpConfigured,
|
||||
emailWrapper,
|
||||
ctaButton,
|
||||
fallbackLink,
|
||||
|
||||
@@ -18,6 +18,9 @@ const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const METHODS = ['email', 'whatsapp'];
|
||||
|
||||
// Roles that must use two-factor authentication.
|
||||
const REQUIRED_ROLES = ['admin', 'supervisor'];
|
||||
|
||||
// Application error carrying the HTTP status the controller should respond with.
|
||||
function fail(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
@@ -67,6 +70,34 @@ async function availableMethods(user) {
|
||||
return methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this user's role must use two-factor authentication.
|
||||
* TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off (break-glass).
|
||||
*/
|
||||
function requiresTwoFactor(user) {
|
||||
if (String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() === 'false') return false;
|
||||
return REQUIRED_ROLES.includes(user?.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a required-2FA user should be enrolled on, or null when no channel
|
||||
* can actually deliver a code — SMTP not set up and no WhatsApp — in which case the
|
||||
* requirement is skipped rather than locking the account out.
|
||||
*/
|
||||
async function enforcementMethod(user) {
|
||||
const { isSmtpConfigured } = require('./email');
|
||||
if (hasRealEmail(user) && isSmtpConfigured()) return 'email';
|
||||
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
||||
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) return 'whatsapp';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when this user must enrol in 2FA before using their account. */
|
||||
async function mustEnrolTwoFactor(user) {
|
||||
if (!user || user.twoFactorEnabled || !requiresTwoFactor(user)) return false;
|
||||
return (await enforcementMethod(user)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the code and reports where it actually went. A WhatsApp failure (session
|
||||
* disconnected, not configured) falls back to email so a user is never locked out
|
||||
@@ -233,6 +264,10 @@ async function clearTrustedDevices(userId) {
|
||||
|
||||
module.exports = {
|
||||
METHODS,
|
||||
REQUIRED_ROLES,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
mustEnrolTwoFactor,
|
||||
MAX_ATTEMPTS,
|
||||
generateCode,
|
||||
hashCode,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Two-factor is required for admins and supervisors — but only when some channel
|
||||
// can actually deliver a code, so a site with no SMTP/WhatsApp never locks them out.
|
||||
|
||||
const mockChannels = { smtp: true, whatsapp: true };
|
||||
const mockUsers = new Map();
|
||||
|
||||
jest.mock('../src/config/db', () => ({
|
||||
user: { findUnique: async ({ where }) => mockUsers.get(where.id) || null },
|
||||
}));
|
||||
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
|
||||
jest.mock('../src/utils/email', () => ({ isSmtpConfigured: () => mockChannels.smtp }));
|
||||
jest.mock('../src/utils/whatsapp', () => ({
|
||||
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
|
||||
isConfigured: async () => mockChannels.whatsapp,
|
||||
}));
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { requiresTwoFactor, enforcementMethod, mustEnrolTwoFactor } = require('../src/utils/twoFactor');
|
||||
const { protect } = require('../src/middleware/authMiddleware');
|
||||
|
||||
const ADMIN = { id: 'a1', name: 'Ann', email: 'ann@example.com', phoneNumber: '27821234567', role: 'admin', isActive: true, tokenVersion: 0, twoFactorEnabled: false };
|
||||
|
||||
beforeEach(() => {
|
||||
mockChannels.smtp = true;
|
||||
mockChannels.whatsapp = true;
|
||||
mockUsers.clear();
|
||||
delete process.env.TWO_FACTOR_REQUIRED_ROLES;
|
||||
});
|
||||
|
||||
describe('requiresTwoFactor', () => {
|
||||
test('applies to admins and supervisors only', () => {
|
||||
expect(requiresTwoFactor({ role: 'admin' })).toBe(true);
|
||||
expect(requiresTwoFactor({ role: 'supervisor' })).toBe(true);
|
||||
expect(requiresTwoFactor({ role: 'staff' })).toBe(false);
|
||||
expect(requiresTwoFactor({ role: 'user' })).toBe(false);
|
||||
});
|
||||
|
||||
test('TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off', () => {
|
||||
process.env.TWO_FACTOR_REQUIRED_ROLES = 'false';
|
||||
expect(requiresTwoFactor({ role: 'admin' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforcementMethod', () => {
|
||||
test('prefers email when SMTP is configured', async () => {
|
||||
expect(await enforcementMethod(ADMIN)).toBe('email');
|
||||
});
|
||||
|
||||
test('falls back to WhatsApp without SMTP', async () => {
|
||||
mockChannels.smtp = false;
|
||||
expect(await enforcementMethod(ADMIN)).toBe('whatsapp');
|
||||
});
|
||||
|
||||
test('skips a guest placeholder email', async () => {
|
||||
expect(await enforcementMethod({ ...ADMIN, email: 'x@guest.local' })).toBe('whatsapp');
|
||||
});
|
||||
|
||||
test('is null when no channel can deliver a code', async () => {
|
||||
mockChannels.smtp = false;
|
||||
mockChannels.whatsapp = false;
|
||||
expect(await enforcementMethod(ADMIN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mustEnrolTwoFactor', () => {
|
||||
test('true for an admin without 2FA', async () => {
|
||||
expect(await mustEnrolTwoFactor(ADMIN)).toBe(true);
|
||||
});
|
||||
|
||||
test('false once enrolled, for other roles, or with no channel', async () => {
|
||||
expect(await mustEnrolTwoFactor({ ...ADMIN, twoFactorEnabled: true })).toBe(false);
|
||||
expect(await mustEnrolTwoFactor({ ...ADMIN, role: 'staff' })).toBe(false);
|
||||
mockChannels.smtp = false;
|
||||
mockChannels.whatsapp = false;
|
||||
expect(await mustEnrolTwoFactor(ADMIN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('protect', () => {
|
||||
function run(user, claims = {}) {
|
||||
mockUsers.set(user.id, user);
|
||||
const token = jwt.sign({ id: user.id, role: user.role, tokenVersion: 0, ...claims }, process.env.JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
const res = { statusCode: 200, body: null };
|
||||
res.status = (c) => { res.statusCode = c; return res; };
|
||||
res.json = (b) => { res.body = b; return res; };
|
||||
return new Promise((resolve) => {
|
||||
protect(req, res, (err) => resolve({ req, res, err, passed: !err })).then(() => {
|
||||
if (res.body) resolve({ req, res, passed: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an un-enrolled admin session with TWO_FACTOR_REQUIRED', async () => {
|
||||
const { res, passed } = await run(ADMIN);
|
||||
expect(passed).toBe(false);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.code).toBe('TWO_FACTOR_REQUIRED');
|
||||
});
|
||||
|
||||
test('lets an enrolled admin through', async () => {
|
||||
const { passed } = await run({ ...ADMIN, twoFactorEnabled: true });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
test('lets a regular user through without 2FA', async () => {
|
||||
const { passed } = await run({ ...ADMIN, id: 'u1', role: 'user' });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
test('gives the setup wizard token a grace period', async () => {
|
||||
const { passed } = await run(ADMIN, { setup: true });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
test('does not enforce when no channel can deliver a code', async () => {
|
||||
mockChannels.smtp = false;
|
||||
mockChannels.whatsapp = false;
|
||||
const { passed } = await run(ADMIN);
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,7 @@ jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
|
||||
jest.mock('../src/utils/email', () => ({
|
||||
sendMail: async (mail) => { mockSent.emails.push(mail); },
|
||||
isSmtpConfigured: () => true,
|
||||
buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }),
|
||||
}));
|
||||
jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` }));
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -187,8 +187,13 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetTwoFactor = async (id: string, name: string) => {
|
||||
if (!confirm(`Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`)) return;
|
||||
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.
|
||||
const message = role === "admin" || role === "supervisor"
|
||||
? `Reset two-factor authentication for ${name}? Their sessions end, and they'll set it up again with a new code at their next login.`
|
||||
: `Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`;
|
||||
if (!confirm(message)) return;
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
|
||||
await loadUsers(page);
|
||||
@@ -364,7 +369,7 @@ export default function AdminUsersPage() {
|
||||
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</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)} title="Turn off two-factor authentication for a user who can no longer receive their codes">Reset 2FA</button>
|
||||
<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>
|
||||
)}
|
||||
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function UserProfilePage() {
|
||||
// loaded once per page load, so it wouldn't reflect a change made here.
|
||||
const [tfEnabled, setTfEnabled] = useState(false);
|
||||
const [tfMethod, setTfMethod] = useState<TwoFactorMethod>("email");
|
||||
const [tfStep, setTfStep] = useState<"idle" | "code" | "disable">("idle");
|
||||
const [tfStep, setTfStep] = useState<"idle" | "code" | "disable" | "change">("idle");
|
||||
const [tfPassword, setTfPassword] = useState("");
|
||||
const [tfCode, setTfCode] = useState("");
|
||||
const [tfChallenge, setTfChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
@@ -171,6 +171,8 @@ export default function UserProfilePage() {
|
||||
|
||||
// WhatsApp codes need the number that's actually saved, not one still being typed.
|
||||
const canWhatsAppCodes = isValidZAPhone(user?.phoneNumber || "");
|
||||
// Admins and supervisors must keep 2FA on; they can only change the channel.
|
||||
const tfRequired = !!user?.twoFactorRequired;
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -219,10 +221,11 @@ export default function UserProfilePage() {
|
||||
authToken: token,
|
||||
body: { challengeId: tfChallenge.challengeId, code: tfCode },
|
||||
});
|
||||
const wasEnabled = tfEnabled;
|
||||
setTfEnabled(true);
|
||||
setTfMethod(res.twoFactorMethod);
|
||||
resetTwoFactorForm();
|
||||
setTfMsg({ type: "ok", text: "Two-factor authentication is on." });
|
||||
setTfMsg({ type: "ok", text: wasEnabled ? `Codes will now be sent by ${TWO_FACTOR_METHOD_LABEL[res.twoFactorMethod]}.` : "Two-factor authentication is on." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to verify the code." });
|
||||
@@ -404,40 +407,10 @@ export default function UserProfilePage() {
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={ShieldCheck} title="Two-factor authentication" />
|
||||
|
||||
{tfEnabled ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<span className="font-medium text-green-700">On.</span> When you log in we send a 6-digit code by{" "}
|
||||
{TWO_FACTOR_METHOD_LABEL[tfMethod]}. Devices you've chosen to trust skip the code for 30 days.
|
||||
</p>
|
||||
{tfStep !== "disable" ? (
|
||||
<button
|
||||
onClick={() => { setTfStep("disable"); setTfPassword(""); setTfMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
|
||||
>
|
||||
Turn off…
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={disableTwoFactor} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50">
|
||||
{tfBusy ? "Turning off…" : "Turn off two-factor"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
) : tfStep === "code" && tfChallenge ? (
|
||||
{tfStep === "code" && tfChallenge ? (
|
||||
<form onSubmit={confirmEnableTwoFactor} className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. Enter it to finish turning on two-factor authentication.
|
||||
We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. {tfEnabled ? "Enter it to switch your codes to this channel." : "Enter it to finish turning on two-factor authentication."}
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
|
||||
@@ -461,10 +434,52 @@ export default function UserProfilePage() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : tfEnabled && tfStep !== "change" ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<span className="font-medium text-green-700">On.</span> When you log in we send a 6-digit code by{" "}
|
||||
{TWO_FACTOR_METHOD_LABEL[tfMethod]}. Devices you've chosen to trust skip the code for 30 days.
|
||||
</p>
|
||||
{tfRequired ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-3">Required for your role, so it can't be turned off.</p>
|
||||
<button
|
||||
onClick={() => { setTfStep("change"); setTfPassword(""); setTfMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
|
||||
>
|
||||
Change how I get codes…
|
||||
</button>
|
||||
</>
|
||||
) : tfStep !== "disable" ? (
|
||||
<button
|
||||
onClick={() => { setTfStep("disable"); setTfPassword(""); setTfMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
|
||||
>
|
||||
Turn off…
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={disableTwoFactor} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50">
|
||||
{tfBusy ? "Turning off…" : "Turn off two-factor"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={startEnableTwoFactor} className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Add a second step to logging in: after your password, we send a 6-digit code that you need to enter.
|
||||
{tfEnabled
|
||||
? "Choose where your login codes go. We'll send a code there first to check it reaches you."
|
||||
: "Add a second step to logging in: after your password, we send a 6-digit code that you need to enter."}
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Send my codes by</label>
|
||||
@@ -480,9 +495,16 @@ export default function UserProfilePage() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{tfBusy ? "Sending code…" : "Send verification code"}
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
{tfEnabled && (
|
||||
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{tfBusy ? "Sending code…" : "Send verification code"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{tfMsg && <Alert msg={tfMsg} />}
|
||||
|
||||
@@ -735,6 +735,7 @@ export default function SelfServicePage() {
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{setupChallenge.enrolling && "Two-factor authentication is required for your role — this code turns it on. "}
|
||||
We sent a code to {setupChallenge.destination} by {setupChallenge.method === "whatsapp" ? "WhatsApp" : "email"}.{" "}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -103,7 +103,9 @@ export function LoginForm() {
|
||||
if (!challenge || resendIn > 0) return;
|
||||
setError(null);
|
||||
try {
|
||||
setChallenge(await resendTwoFactor(challenge.challengeId));
|
||||
// The resend response doesn't repeat `enrolling`, so carry it over.
|
||||
const next = await resendTwoFactor(challenge.challengeId);
|
||||
setChallenge({ ...next, enrolling: challenge.enrolling });
|
||||
setCode("");
|
||||
setResendIn(RESEND_COOLDOWN_SECONDS);
|
||||
} catch (err: any) {
|
||||
@@ -123,6 +125,11 @@ export function LoginForm() {
|
||||
<form onSubmit={onSubmitCode} className="space-y-4 max-w-sm w-full">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Enter your verification code</h2>
|
||||
{challenge.enrolling && (
|
||||
<p className="text-sm text-brand-800 bg-brand-50 border border-brand-200 rounded px-3 py-2 mt-2">
|
||||
Two-factor authentication is required for your role. Entering this code turns it on for your account.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
We sent a 6-digit code to {challenge.destination} by {challenge.method === "whatsapp" ? "WhatsApp" : "email"}. It expires in 10 minutes.
|
||||
</p>
|
||||
@@ -182,6 +189,11 @@ export function LoginForm() {
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
|
||||
{searchParams.get("reason") === "2fa" && (
|
||||
<p className="text-sm text-brand-800 bg-brand-50 border border-brand-200 rounded px-3 py-2">
|
||||
Two-factor authentication is now required for your role. Log in again to set it up — we'll send you a code.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email or phone</label>
|
||||
<input
|
||||
|
||||
@@ -30,6 +30,9 @@ export const authHelpContent: HelpContent = {
|
||||
<GuideItem icon={ShieldCheck} title="Asked for a verification code?" tone="amber">
|
||||
If you've turned on two-factor authentication, we send a 6-digit code by email or WhatsApp after your password. Enter it within 10 minutes, or use "Resend code" if it doesn't arrive. Tick "Trust this device" to skip the code on that device for 30 days. You can turn two-factor on or off from Profile & Security.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ShieldCheck} title="Admin or supervisor?" tone="amber">
|
||||
Two-factor authentication is required for admin and supervisor accounts. If it isn't on yet, your next login sends you a code, and entering it turns two-factor on. You can change whether codes come by email or WhatsApp from Profile & Security, but you can't turn it off.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -117,6 +117,15 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
if (json?.message && typeof json.message === "string") message = json.message;
|
||||
data = json;
|
||||
} 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.
|
||||
if (res.status === 401 && data?.code === "TWO_FACTOR_REQUIRED" && typeof window !== "undefined") {
|
||||
localStorage.removeItem("hope_events_token");
|
||||
if (!window.location.pathname.startsWith("/login")) {
|
||||
const back = window.location.pathname + window.location.search;
|
||||
window.location.href = `/login?reason=2fa&redirect=${encodeURIComponent(back)}`;
|
||||
}
|
||||
}
|
||||
throw new ApiError(res.status, message, data);
|
||||
}
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
|
||||
@@ -7,6 +7,8 @@ export type AuthUser = {
|
||||
notificationPreference?: "email" | "whatsapp" | "both";
|
||||
twoFactorEnabled?: boolean;
|
||||
twoFactorMethod?: TwoFactorMethod | null;
|
||||
// True for roles that must use 2FA (admin, supervisor) — it can't be turned off.
|
||||
twoFactorRequired?: boolean;
|
||||
};
|
||||
|
||||
export type AuthResponse = AuthUser & {
|
||||
@@ -24,6 +26,8 @@ export type TwoFactorChallenge = {
|
||||
challengeId: string;
|
||||
method: TwoFactorMethod;
|
||||
destination: string;
|
||||
// The role requires 2FA and it wasn't on yet: verifying this code turns it on.
|
||||
enrolling?: boolean;
|
||||
};
|
||||
|
||||
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge & { twoFactorRequired: true } {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## What is it?
|
||||
|
||||
Hope Events is a complete event management platform built specifically for churches and faith-based organisations. It handles everything from advertising upcoming events and taking online registrations to collecting payments, generating QR-coded tickets, and checking people in at the door.
|
||||
Hope Events is a complete event management platform built specifically for churches and faith-based organisations. It handles everything from advertising upcoming events and taking online registrations to collecting payments and donations, sending QR-coded tickets, checking people in at the door, and balancing the books afterwards.
|
||||
|
||||
It runs in any web browser — on a phone, tablet, or computer — with nothing to install.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,32 +12,54 @@ Hope Events is a complete event management platform built specifically for churc
|
||||
|
||||
### For your congregation
|
||||
|
||||
- **Browse events** — a clean, mobile-friendly listing of upcoming events with photos, descriptions, and dates. Events that are fully booked display a "Sold Out" badge so attendees know at a glance.
|
||||
- **Register online** — simple registration flow with support for multiple ticket types (e.g. adult, child, family) at different prices, including early-bird pricing and low-stock warnings.
|
||||
- **Pay online** — secure card payments via Yoco. Outstanding balances can be settled any time.
|
||||
- **Receive tickets** — QR-coded tickets are emailed automatically after payment. Attendees can also retrieve their tickets via a self-service portal using just their email address.
|
||||
- **Registration forms** — events can include custom forms (dietary requirements, T-shirt sizes, emergency contacts, etc.) completed at registration time.
|
||||
- **Downloads** — events can include downloadable attachments (maps, schedules, waiver forms, etc.) visible on the event detail page.
|
||||
- **Browse events** — a clean, mobile-friendly listing of upcoming events with photos, descriptions, dates, and location (with a map and directions). Fully booked events show a "Sold Out" badge, and popular tickets show how many are left.
|
||||
- **Register online** — choose from multiple ticket types (e.g. adult, child, family, dinner, T-shirt sizes) at different prices, including early-bird pricing. People can register as a guest or with an account, depending on how you set up the event.
|
||||
- **Pay online** — secure card payments (including Apple Pay and Google Pay) via Yoco. Outstanding balances can be settled any time from a link in their email or WhatsApp.
|
||||
- **Give** — members can make donations online, which you can put towards someone's registration (for example, to sponsor a child's camp place).
|
||||
- **Invoices and receipts** — branded PDF invoices and payment receipts are sent automatically and can be requested again at any time.
|
||||
- **Receive tickets** — QR-coded tickets are sent by email and/or WhatsApp as soon as a registration is paid. Each person chooses how they'd like to be contacted.
|
||||
- **Add to calendar** — one tap adds the event to their phone or computer calendar.
|
||||
- **Registration forms** — events can include custom forms (dietary requirements, medical information, emergency contacts, indemnities, etc.), saved as they go.
|
||||
- **Downloads** — events can include attachments such as maps, schedules, or waiver forms.
|
||||
- **Their own dashboard** — see registrations, tickets, payment history, and forms in one place; add tickets, cancel before paying, and manage their profile and privacy.
|
||||
|
||||
### For staff on the day
|
||||
|
||||
- **QR code scanner** — check people in by scanning their ticket QR code. Works in any mobile browser — no app to install.
|
||||
- **At-the-door panel** — search by name, phone, or ticket ID; mark people in; print or resend individual tickets on the spot.
|
||||
- **QR code scanner** — check people in by scanning their ticket with any phone camera. A single ticket can cover a whole family and be checked in a few people at a time.
|
||||
- **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.
|
||||
- **Self-service kiosk** — set up a tablet where visitors register themselves. Returning visitors are recognised by their email or phone number.
|
||||
- **Check-in confirmations** — attendees get a message confirming how many people were checked in.
|
||||
|
||||
### For event organisers (supervisors)
|
||||
### For event organisers
|
||||
|
||||
- **Event management** — create and manage events with photos, pricing, early-bird tiers, and registration deadlines.
|
||||
- **Ticket types** — flexible options (e.g. Adult, Child, VIP, Dinner) each with their own price.
|
||||
- **Payments** — view all payments, record cash or EFT manually, and issue adjustments.
|
||||
- **Bulk messaging** — send emails or WhatsApp messages to event attendees. Schedule reminders, thank-you messages, or general broadcasts.
|
||||
- **Event management** — a step-by-step wizard for creating events: details, ticket types and pricing, early-bird tiers, stock limits, check-in points, registration forms, visibility, and attachments.
|
||||
- **Flexible publishing** — schedule an event to go live later, keep it hidden and share it only by link, give it a short web address (e.g. `/camp-2026`), or create "contact us" events (e.g. baptisms) with no registration.
|
||||
- **Payments** — record cash, card, EFT, or voucher payments; issue refunds; send payment links; assign donations; and match card payments to registrations. Every payment records who took it.
|
||||
- **Messaging** — send emails or WhatsApp messages to attendees, filtered by who has paid or still owes. Personalise messages with each person's name, balance, or their own payment link. Schedule reminders, thank-you messages, and promotions in advance.
|
||||
- **Daily summary** — the event team gets a daily email with registrations, payments, and outstanding balances in the run-up to each event.
|
||||
- **Forms** — view, filter, and print attendee form responses.
|
||||
- **Reports** — download attendance and payment reports.
|
||||
- **Reports** — 14 ready-made reports covering payments, revenue, attendees, ticket usage, registration status, donations, costs, and profit. Each can be printed or exported to PDF, Excel, email, or WhatsApp.
|
||||
- **Dashboard** — revenue, donations, registrations, and tickets sold at a glance, compared with the previous month.
|
||||
|
||||
### For administrators
|
||||
### For administrators and treasurers
|
||||
|
||||
- **User management** — manage accounts and roles (user, staff, supervisor, admin).
|
||||
- **Registrations** — a complete overview of all registrations across all events.
|
||||
- **Site settings** — update the organisation name, logo, brand colour, and notification preferences directly from the admin panel — no technical knowledge required.
|
||||
- **Cashup** — record event costs, count cash by denomination for each staff member, and compare expected and actual takings for cash, card, and EFT. Close the event once the books balance.
|
||||
- **Profit reporting** — see revenue, costs, and profit per event.
|
||||
- **Audit trail** — a record of who did what and when for refunds, donations, manual registrations, cancellations, event changes, and settings changes.
|
||||
- **User management** — manage accounts and roles (member, staff, supervisor, admin), and erase a person's personal data on request.
|
||||
- **Registrations overview** — every registration across all events, with paid and outstanding totals.
|
||||
- **Site settings** — update your organisation's name, address, logo, brand colours, email settings, legal pages, and WhatsApp connection yourself, with a live preview. No technical knowledge required.
|
||||
- **Backups** — the database is backed up every night, and backups can be downloaded from the admin panel.
|
||||
|
||||
---
|
||||
|
||||
## Built to be trusted
|
||||
|
||||
- **Your branding** — your logo, colours, and name on the website, emails, invoices, receipts, and reports.
|
||||
- **Privacy** — Terms of Use and Privacy Policy pages filled in with your details, and members can close their account or have their personal data removed.
|
||||
- **Security** — two-step login codes by email or WhatsApp (required for admins and supervisors, optional for members), protection against password-guessing, a record of each account's recent logins, and the ability to sign out on all devices.
|
||||
- **Help on every page** — a "Need help?" button explains each page in plain language.
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +74,7 @@ Hope Events is offered as a **hosted service**. You don't need to worry about se
|
||||
|
||||
Pricing is quoted per organisation based on expected usage. Contact us for a quote.
|
||||
|
||||
> Online payment processing fees (charged by Yoco directly to your merchant account) are separate and not included in the subscription.
|
||||
> WhatsApp messaging requires a WhatsApp number linked to the platform.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,7 +82,7 @@ Pricing is quoted per organisation based on expected usage. Contact us for a quo
|
||||
|
||||
1. **Get a quote** — contact us and we'll provide pricing for your organisation.
|
||||
2. **We set it up** — once you're onboarded, we deploy and configure the platform for you. You'll receive a link to your site.
|
||||
3. **First login** — a setup wizard walks you through entering your organisation details and creating your admin account. No technical steps required.
|
||||
3. **First login** — a setup wizard walks you through entering your organisation details, creating your admin account, and adding your logo and colours. No technical steps required.
|
||||
4. **You're live** — start creating events and inviting attendees.
|
||||
|
||||
---
|
||||
@@ -74,8 +98,8 @@ Pricing is quoted per organisation based on expected usage. Contact us for a quo
|
||||
|
||||
## Payment processing
|
||||
|
||||
The platform integrates with **Yoco** — a South African payment gateway that supports all major credit and debit cards. You will need your own Yoco merchant account to accept payments. Yoco's standard transaction fee applies (currently 2.95% per transaction); this goes directly to Yoco and is not part of our subscription.
|
||||
The platform integrates with **Yoco** — a South African payment gateway that supports all major credit and debit cards, Apple Pay, and Google Pay. You will need your own Yoco merchant account to accept payments.
|
||||
|
||||
---
|
||||
|
||||
*Built for Hope Family Church, Henley-on-Klip. Available to other churches and faith-based organisations.*
|
||||
*Built for Hope Family Church, Henley-on-Klip. Available to other churches and faith-based organisations.*
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user