Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce125e36c | ||
|
|
96bfeff205 | ||
|
|
c3012cb3f6 | ||
|
|
0306c4dcb1 | ||
|
|
fc4e65b80a | ||
|
|
e52bf67015 | ||
|
|
e909f43fea | ||
|
|
889995e450 | ||
|
|
73fdce1879 | ||
|
|
7e661abd24 | ||
|
|
1f59916efa | ||
|
|
e728a06b9a | ||
|
|
1d9b87500d | ||
|
|
97faff1c89 | ||
|
|
5b2183677d | ||
|
|
af6dffe534 | ||
|
|
798156efe6 | ||
|
|
c79e0f2ce8 | ||
|
|
49b6ddc397 | ||
|
|
f3a2e812bf | ||
|
|
032d3c032e | ||
|
|
5a416916c9 | ||
|
|
44a9e0857c | ||
|
|
54b89d4f4b | ||
|
|
98ac26bf70 | ||
|
|
7e235637c4 |
@@ -34,6 +34,8 @@ Thumbs.db
|
||||
# misc scratch / generated files
|
||||
temp/
|
||||
backend/public/uploads/
|
||||
backend/backups/
|
||||
hope-events-deployment-setup.txt
|
||||
|
||||
# runtime data stores (mutated by the running app, not source)
|
||||
backend/data/scheduled-emails.json
|
||||
|
||||
@@ -7,6 +7,94 @@ 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
|
||||
|
||||
- Optional two-factor authentication. Any user can turn it on from Profile & Security and choose to receive a 6-digit code by email or WhatsApp; the chosen channel is confirmed with a code before it takes effect. At login the code is asked for after the password, expires after 10 minutes and allows 5 attempts, with a "Resend code" option. If a WhatsApp code can't be sent, it falls back to the account's email address.
|
||||
- "Trust this device for 30 days" on the code step, so a remembered device skips the code. Trusted devices are forgotten when the password is changed or reset, when sessions are signed out everywhere, and when two-factor is turned off.
|
||||
- Admins can reset two-factor authentication for a user who can no longer receive codes ("Reset 2FA" on the Users page). Turning two-factor on or off shows in Account activity.
|
||||
- The self-service kiosk asks supervisors with two-factor on for their code at setup. Changing the event now re-confirms the password against the existing kiosk session (`POST /api/users/verify-password`) instead of logging in again.
|
||||
|
||||
### Changed
|
||||
|
||||
- `POST /api/users/login` returns `{ twoFactorRequired, challengeId, method, destination }` instead of a token for accounts with two-factor on; the token is issued by the new `POST /api/users/login/2fa`. Accounts without two-factor are unaffected. Requires the `add_two_factor` database migration (`npm run prisma:deploy` in `backend`).
|
||||
|
||||
## [1.10.6] - 2026-10-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- The frontend production build (`next build`) no longer hangs or fails when the backend isn't reachable. With no `NEXT_PUBLIC_API_URL`/`API_URL` set, the root layout fetched a relative `/api/settings` URL that never settled during prerender, so every page timed out after 60 seconds; server-side fetches now fail fast when no API base is configured. The home page and the Events page also no longer fail the build when `/api/events` can't be reached — they build with an empty list and fill in on the first revalidation. At runtime a failed fetch still errors, so the last good page keeps being served.
|
||||
|
||||
### Security
|
||||
|
||||
- Updated dependencies to clear `npm audit` advisories: `express`, `multer`, `proxy-addr`, `qs` and others in the backend, `next` (15.5.27) and `sharp` (0.35.5) in the frontend, and `nodemailer` from 9 to 10 (requires Node.js 20 or newer). The root package now overrides `shell-quote` to a patched release, since every `concurrently` version pins a vulnerable one. Backend production dependencies now report no known vulnerabilities; the remaining advisories are in development tooling (`nodemon`, `jest`, `tailwindcss` 3) and have no non-breaking fix.
|
||||
- Dependency install scripts are now explicitly allow-listed via `allowScripts` in the backend and frontend `package.json` (Prisma, `@sentry/cli`, `unrs-resolver`), so npm no longer blocks them.
|
||||
|
||||
## [1.10.5] - 2026-10-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Events that have already started but not yet finished now show on the home page and the Events page. Both pages were hiding an event as soon as its start time passed; they now keep it listed until its end time, which matches what `GET /api/events` already returns.
|
||||
|
||||
## [1.10.4] - 2026-09-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Add to calendar" (`GET /api/events/:id/ics`) always failed with a 400 in production: the `ics` library's `url` field requires a scheme (`https://...`), but `FRONTEND_URL`/`APP_BASE_URL` was configured without one, so `createEvent()` rejected it before generating any output. A missing scheme is now assumed to be `https://` instead of failing.
|
||||
|
||||
## [1.10.3] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Creating an event was never recorded in the admin audit log — 1.10.0 added `event_created` as a logged action on the frontend's filter list, but the backend's `createEvent` never actually called `logAdminAction`, only `updateEvent`/`deleteEvent` did. Event creation is now logged the same way.
|
||||
- Staff cancelling a registration via `PUT /api/registrations/:id` (the staff status-change endpoint, separate from the owner-facing `DELETE /:id` cancel route) wasn't logged at all — only the `DELETE` path logged `registration_cancelled`. Both paths now log it, verified against all six audit categories promised in 1.10.0 (refunds, donation assign/unassign, manual registrations, staff-initiated cancellations, event create/update/delete, settings changes) with no other gaps found.
|
||||
- Added a "Back to dashboard" link to Admin → Audit log, matching the back-link pattern already used on the Cashup page.
|
||||
|
||||
## [1.10.2] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sentry wasn't instrumenting Express (`[Sentry] express is not instrumented` at startup): `express`, `cors`, and `@prisma/client` were required at the top of `backend/src/index.js` before `Sentry.init()` ran, but Sentry's auto-instrumentation patches those modules via a require hook that only works if `Sentry.init()` runs first. `Sentry.init()` now runs immediately after `dotenv.config()`, before any of the libraries it instruments are required.
|
||||
|
||||
## [1.10.1] - 2026-08-28
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed a path-traversal vulnerability in event-image, branding (logo/favicon), and event-attachment uploads: the stored filename embedded the client-supplied `originalname` unsanitized, so a crafted filename (e.g. containing `../`) could write the uploaded file outside the intended `public/uploads` subfolder anywhere the server process could write. Uploaded files are now always saved under a server-generated random name; the original filename is preserved only as display metadata.
|
||||
- `express-rate-limit` was declared as a root-only dependency despite being required directly by the backend (`backend/src/index.js`) — a plain `cd backend && npm install`, as documented in the deployment guide, would not have installed it. It's now a proper `backend/package.json` dependency.
|
||||
- Bumped `next` (frontend) off a version affected by a critical unauthenticated RCE in the React Flight protocol (GHSA-9qr9-h5gf-34mp) and several other CVEs, and switched it from an exact pin to `^15.5.24` so future patch releases install automatically.
|
||||
- Bumped `multer`, `nodemailer`, `jsonwebtoken`, and `uuid` (backend) to versions fixing DoS, SMTP/CRLF-injection, HMAC-verification, and buffer-bounds advisories; added an `overrides` entry so the vulnerable `uuid` nested under `exceljs` is also patched. Ran `npm audit fix` across all three workspaces (root/backend/frontend) — 0 known vulnerabilities remain.
|
||||
- `jwt.verify()` now pins `algorithms: ['HS256']` explicitly rather than trusting the algorithm from the token header.
|
||||
- Uploaded assets served from `/uploads` now get `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff`, so an uploaded SVG containing a `<script>` can no longer execute if opened directly.
|
||||
|
||||
## [1.10.0] - 2026-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- Event detail pages now have an "Add to calendar" button, and registration confirmation emails include an "Add to calendar" link (requires `BACKEND_URL` to be set), both downloading a `.ics` file for the event.
|
||||
- SEO: the site now serves a `sitemap.xml` (every public page plus every event) and `robots.txt`, and event/event-list pages have proper Open Graph/Twitter metadata for link previews.
|
||||
- Error monitoring via Sentry — set `SENTRY_DSN` (backend) and `NEXT_PUBLIC_SENTRY_DSN` (frontend) to enable; a no-op otherwise. Captures unhandled backend errors/promise rejections and frontend errors.
|
||||
- Nightly local database backups (`pg_dump`, gzipped, 02:00, 14-day retention by default) with a new Site Settings → Backups tab to view, manually trigger, and download them. Requires `pg_dump` to be installed on the app server; set `BACKUP_ENABLED=false` to disable. Local-disk only — not uploaded anywhere else.
|
||||
- Admin audit trail: refunds, donation assign/unassign, manual registrations, staff-initiated cancellations, event create/update/delete, and settings changes are now logged with who/when/what, viewable at Admin → Audit log.
|
||||
- Starter backend test suite (Jest) covering the early-bird tranche pricing logic and donation-balance calculation — the two areas with the most complex money-handling history in this changelog.
|
||||
|
||||
## [1.9.5] - 2026-08-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- The public event-alias route (`/:redirectUrl`, used for short links like `/camp-2025`) is a catch-all matching any unmatched top-level path, so every bot/scanner probe for a nonexistent page (`/wp-login.php`, `/.env`, etc.) was hitting the backend and firing a live database query. Both the frontend route and the `GET /api/events/by-alias/:redirectUrl` endpoint now reject anything that isn't a plausible alias (letters/numbers/hyphens/underscores) before touching the database, instead of forwarding scanner noise straight through — this traffic pattern could exhaust the database connection pool and take the server down under load.
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -74,6 +74,12 @@ or Branding tab has been saved even once — the wizard always writes
|
||||
|---|---|---|
|
||||
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
|
||||
|
||||
## Backups
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `backup_retain_count` | `14` | How many nightly backups to keep on disk (oldest deleted beyond this count). Editable at Admin → Site Settings → Backups. See `BACKUP_ENABLED` (env var) to turn the nightly job off entirely. |
|
||||
|
||||
## Setup state
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|
||||
@@ -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
|
||||
@@ -47,6 +52,14 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
|
||||
DAILY_SUMMARY_ENABLED=true
|
||||
SCHEDULED_EMAILS_ENABLED=true
|
||||
SCHEDULED_EMAILS_INTERVAL_MS=30000
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
# ─── Error monitoring (Sentry) — optional ─────────────────────────────────────
|
||||
# Leave unset to disable entirely (a no-op, not an error). Set NEXT_PUBLIC_SENTRY_DSN
|
||||
# in frontend/.env too if you want frontend errors captured.
|
||||
# SENTRY_DSN=https://xxxxx@oxxxxxx.ingest.sentry.io/xxxxx
|
||||
|
||||
# ─── Note ─────────────────────────────────────────────────────────────────────
|
||||
# The following are managed via Admin → Site Settings and stored in the database:
|
||||
|
||||
@@ -43,7 +43,9 @@ Tokens are issued by `POST /api/users/login`.
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/users` | public | Register new account |
|
||||
| POST | `/api/users/login` | public | Login, returns JWT |
|
||||
| POST | `/api/users/login` | public | Login, returns JWT — or a two-factor challenge when the account has 2FA on |
|
||||
| POST | `/api/users/login/2fa` | public | Exchange a two-factor code for the JWT |
|
||||
| POST | `/api/users/login/2fa/resend` | public | Send a new code for a pending login challenge (60s cooldown) |
|
||||
| POST | `/api/users/forgot` | public | Request password reset email |
|
||||
| POST | `/api/users/reset` | public | Reset password with token |
|
||||
| POST | `/api/users/activate` | public | Activate account from email link |
|
||||
@@ -51,9 +53,13 @@ Tokens are issued by `POST /api/users/login`.
|
||||
| PUT | `/api/users/profile` | user+ | Update own profile |
|
||||
| POST | `/api/users/revoke-sessions` | user+ | Revoke own sessions |
|
||||
| POST | `/api/users/close-account` | user+ | Deactivate own account |
|
||||
| POST | `/api/users/2fa/enable` | user+ | Start turning on 2FA — sends a code to the chosen channel |
|
||||
| POST | `/api/users/2fa/confirm` | user+ | Finish turning on 2FA with that code |
|
||||
| POST | `/api/users/2fa/disable` | user+ | Turn off 2FA (requires password) |
|
||||
| POST | `/api/users/verify-password` | user+ | Re-confirm own password without starting a new session |
|
||||
| GET | `/api/users` | supervisor+ | List users — supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` |
|
||||
| GET | `/api/users/:id` | admin | Get user by ID |
|
||||
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber) |
|
||||
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber). `twoFactorEnabled: false` resets a user's 2FA; it cannot be switched on this way |
|
||||
| DELETE | `/api/users/:id` | admin | Deactivate user (`isActive: false`) |
|
||||
| POST | `/api/users/:id/revoke-sessions` | admin | Revoke all sessions for a user |
|
||||
| POST | `/api/users/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account |
|
||||
@@ -71,10 +77,32 @@ Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
### POST /api/users/login
|
||||
```json
|
||||
{ "email": "jane@example.com", "password": "securepass123" }
|
||||
{ "email": "jane@example.com", "password": "securepass123", "deviceToken": "optional-trusted-device-token" }
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
If the account has two-factor authentication on and `deviceToken` is missing, expired or not recognised, no token is issued. A 6-digit code is sent by email or WhatsApp and the response is instead:
|
||||
```json
|
||||
{ "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 }
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberDevice` is `true`. Send that `deviceToken` with later logins to skip the code for 30 days. Codes expire after 10 minutes and allow 5 attempts. Trusted devices are cleared when the password changes or sessions are revoked.
|
||||
|
||||
### 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. 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.
|
||||
|
||||
### PUT /api/users/profile
|
||||
```json
|
||||
{ "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" }
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
};
|
||||
Generated
+5363
-172
File diff suppressed because it is too large
Load Diff
+20
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.9.4",
|
||||
"version": "1.12.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:deploy": "prisma migrate deploy && prisma generate",
|
||||
@@ -17,23 +17,37 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@sentry/node": "^10.71.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"ics": "^3.12.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.5",
|
||||
"nodemailer": "^10.0.16",
|
||||
"pdfkit": "^0.17.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"raw-body": "^3.0.0",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.4.2",
|
||||
"nodemon": "^3.0.1",
|
||||
"prisma": "^5.4.2"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@5.22.0": true,
|
||||
"@prisma/client@5.22.0": true,
|
||||
"@prisma/engines@5.22.0": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"@parcel/watcher": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AdminAuditAction" AS ENUM ('refund_created', 'donation_assigned', 'donation_unassigned', 'registration_created_manual', 'registration_cancelled', 'event_created', 'event_updated', 'event_deleted', 'settings_updated');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AdminAuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"actorRole" TEXT NOT NULL,
|
||||
"action" "AdminAuditAction" NOT NULL,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"targetId" TEXT,
|
||||
"metadata" JSONB,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AdminAuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_actorId_createdAt_idx" ON "AdminAuditLog"("actorId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_action_createdAt_idx" ON "AdminAuditLog"("action", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_targetType_targetId_idx" ON "AdminAuditLog"("targetType", "targetId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AdminAuditLog" ADD CONSTRAINT "AdminAuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TwoFactorMethod" AS ENUM ('email', 'whatsapp');
|
||||
|
||||
-- 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 "SecurityEventType" ADD VALUE 'two_factor_enabled';
|
||||
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_disabled';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "twoFactorMethod" "TwoFactorMethod";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TwoFactorChallenge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"method" "TwoFactorMethod" NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TwoFactorChallenge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TrustedDevice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"device" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TrustedDevice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TwoFactorChallenge_userId_idx" ON "TwoFactorChallenge"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TrustedDevice_tokenHash_key" ON "TrustedDevice"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TrustedDevice_userId_idx" ON "TrustedDevice"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TwoFactorChallenge" ADD CONSTRAINT "TwoFactorChallenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrustedDevice" ADD CONSTRAINT "TrustedDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -51,6 +51,13 @@ enum SecurityEventType {
|
||||
login
|
||||
password_changed
|
||||
password_reset
|
||||
two_factor_enabled
|
||||
two_factor_disabled
|
||||
}
|
||||
|
||||
enum TwoFactorMethod {
|
||||
email
|
||||
whatsapp
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -67,6 +74,8 @@ model User {
|
||||
lockUntil DateTime?
|
||||
tokenVersion Int @default(0)
|
||||
notificationPreference NotificationPreference @default(email)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorMethod TwoFactorMethod?
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
|
||||
@@ -86,6 +95,10 @@ model User {
|
||||
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
|
||||
|
||||
securityEvents SecurityEvent[]
|
||||
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -329,6 +342,40 @@ model PasswordReset {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A 6-digit code sent by email/WhatsApp, either as the second step of a login
|
||||
// ('login') or to prove the channel works before 2FA is switched on ('enable').
|
||||
// Only the HMAC of the code is stored (see backend/src/utils/twoFactor.js).
|
||||
model TwoFactorChallenge {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
purpose String // 'login' | 'enable'
|
||||
method TwoFactorMethod
|
||||
codeHash String
|
||||
attempts Int @default(0)
|
||||
lastSentAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A browser that passed the 2FA code step with "Trust this device" ticked — logins
|
||||
// presenting its token skip the code until it expires or is cleared.
|
||||
model TrustedDevice {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tokenHash String @unique
|
||||
device String?
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Append-only "Account activity" log for the Profile & Security page (logins, password
|
||||
// changes). Nullable FK with SetNull (not Cascade) so entries survive account
|
||||
// close/anonymization, the same pattern EventCashup uses for its performedBy audit trail.
|
||||
@@ -344,6 +391,39 @@ model SecurityEvent {
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
enum AdminAuditAction {
|
||||
refund_created
|
||||
donation_assigned
|
||||
donation_unassigned
|
||||
registration_created_manual
|
||||
registration_cancelled
|
||||
event_created
|
||||
event_updated
|
||||
event_deleted
|
||||
settings_updated
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
// data-integrity impact — separate from SecurityEvent (user-account-security-specific,
|
||||
// fixed enum). Nullable FK with SetNull mirrors SecurityEvent's pattern so entries
|
||||
// survive account close/anonymization.
|
||||
model AdminAuditLog {
|
||||
id String @id @default(uuid())
|
||||
actorId String?
|
||||
actor User? @relation("AdminAuditActor", fields: [actorId], references: [id], onDelete: SetNull)
|
||||
actorRole String
|
||||
action AdminAuditAction
|
||||
targetType String
|
||||
targetId String?
|
||||
metadata Json?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([actorId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
@@index([targetType, targetId])
|
||||
}
|
||||
|
||||
model EventAttachment {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const { getAdminAuditLog } = require('../utils/adminAudit');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// @desc Paginated admin/supervisor action audit trail, with optional filters
|
||||
// @route GET /api/admin/audit-log
|
||||
// @access Admin
|
||||
const listAuditLog = async (req, res) => {
|
||||
try {
|
||||
const { page, limit, actorId, action, from, to } = req.query;
|
||||
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listAuditLog };
|
||||
@@ -0,0 +1,53 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { runBackup, listBackups, BACKUP_DIR, BACKUP_FILENAME_RE } = require('../utils/backupUtils');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// @desc List local database backups (newest first)
|
||||
// @route GET /api/backups
|
||||
// @access Admin
|
||||
const getBackups = async (req, res) => {
|
||||
try {
|
||||
const backups = await listBackups();
|
||||
res.json(backups);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Manually trigger a backup now
|
||||
// @route POST /api/backups/run
|
||||
// @access Admin
|
||||
const triggerBackup = async (req, res) => {
|
||||
try {
|
||||
const result = await runBackup();
|
||||
res.status(201).json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download a backup file
|
||||
// @route GET /api/backups/:filename/download
|
||||
// @access Admin
|
||||
const downloadBackup = async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
// Reject anything that isn't exactly the shape this app generates, before ever
|
||||
// touching the filesystem — same posture as the event-alias path-safety fix.
|
||||
if (!BACKUP_FILENAME_RE.test(filename)) {
|
||||
res.status(400);
|
||||
throw new Error('Invalid backup filename');
|
||||
}
|
||||
const filePath = path.join(BACKUP_DIR, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404);
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
res.download(filePath, filename);
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getBackups, triggerBackup, downloadBackup };
|
||||
@@ -3,7 +3,10 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
|
||||
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
|
||||
function toAbsoluteUrl(req, url) {
|
||||
@@ -65,8 +68,19 @@ const createEvent = async (req, res) => {
|
||||
location: location || null,
|
||||
};
|
||||
|
||||
const logEventCreate = (createdEvent) => logAdminAction({
|
||||
actorId: req.user?.id,
|
||||
actorRole: req.user?.role,
|
||||
action: 'event_created',
|
||||
targetType: 'Event',
|
||||
targetId: createdEvent.id,
|
||||
metadata: { title: createdEvent.title },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
try {
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
|
||||
// Automatically create a main ticket (event option) with the event price — contact-only
|
||||
// events have no bookable options, so there's nothing to auto-create for them.
|
||||
@@ -130,24 +144,28 @@ const createEvent = async (req, res) => {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.registrationDeadline;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
if (msg.includes('Unknown argument `goLiveAt`')) {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.goLiveAt;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
if (msg.includes('Unknown argument `createdById`')) {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.createdById;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
if (msg.includes('Unknown argument `redirectUrl`')) {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.redirectUrl;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
throw err;
|
||||
@@ -442,6 +460,55 @@ const getEventById = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download a .ics calendar file for an event
|
||||
// @route GET /api/events/:id/ics
|
||||
// @access Public (same visibility gating as getEventById)
|
||||
const getEventIcs = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
|
||||
// details shouldn't be enumerable via this route either.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
if (!isStaffOrHigher) {
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
let goLiveAt = null;
|
||||
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
|
||||
if (goLiveAt && new Date() < goLiveAt) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
}
|
||||
|
||||
const { buildEventIcs } = require('../utils/icsUtils');
|
||||
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
// The ics library's url validator requires a scheme; guard against FRONTEND_URL/APP_BASE_URL
|
||||
// being configured without one (e.g. "example.com" instead of "https://example.com").
|
||||
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) {
|
||||
frontendUrl = `https://${frontendUrl}`;
|
||||
}
|
||||
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
|
||||
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
|
||||
res.send(ics);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update event
|
||||
// @route PUT /api/events/:id
|
||||
// @access Private/Admin
|
||||
@@ -460,6 +527,16 @@ const updateEvent = async (req, res) => {
|
||||
// totals — same rule already enforced for payments/costs. Admin can reopen first.
|
||||
await assertEventOpen(req.params.id, res);
|
||||
|
||||
const logEventUpdate = () => logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'event_updated',
|
||||
targetType: 'Event',
|
||||
targetId: req.params.id,
|
||||
metadata: { changedKeys: Object.keys(req.body || {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
@@ -526,6 +603,7 @@ const updateEvent = async (req, res) => {
|
||||
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
|
||||
}
|
||||
|
||||
logEventUpdate();
|
||||
return res.json(updatedEvent);
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || '');
|
||||
@@ -533,12 +611,14 @@ const updateEvent = async (req, res) => {
|
||||
// @ts-ignore
|
||||
delete data.registrationDeadline;
|
||||
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
||||
logEventUpdate();
|
||||
return res.json(updatedEvent);
|
||||
}
|
||||
if (msg.includes('Unknown argument `goLiveAt`')) {
|
||||
// @ts-ignore
|
||||
delete data.goLiveAt;
|
||||
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
||||
logEventUpdate();
|
||||
return res.json(updatedEvent);
|
||||
}
|
||||
throw err;
|
||||
@@ -623,6 +703,15 @@ const deleteEvent = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'event_deleted',
|
||||
targetType: 'Event',
|
||||
targetId: req.params.id,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
res.json({ message: 'Event deactivated' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
@@ -919,7 +1008,10 @@ const attachmentsStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const unique = `${Date.now()}-${file.originalname}`;
|
||||
// Extension only — file.originalname is untrusted and joining it into a
|
||||
// path allows `../` traversal to write outside the upload directory.
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const unique = `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
||||
cb(null, unique);
|
||||
}
|
||||
});
|
||||
@@ -1758,6 +1850,7 @@ module.exports = {
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
getEventById,
|
||||
getEventIcs,
|
||||
updateEvent,
|
||||
getEventNotifyRecipients,
|
||||
updateEventNotifyRecipients,
|
||||
|
||||
@@ -2,6 +2,9 @@ const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
const { computeDonationRemaining } = require('../utils/donationUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const axios = require('axios');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
@@ -611,17 +614,12 @@ const assignDonationToRegistration = async (req, res) => {
|
||||
throw new Error('Only donations can be assigned to registrations');
|
||||
}
|
||||
|
||||
// Donations are never mutated once created — their remaining balance is the original
|
||||
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
|
||||
// pointing back at this donation) already allocated from it. A refund of the donation
|
||||
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces
|
||||
// the remaining balance (money that's left the building) instead of increasing it (which a
|
||||
// raw signed sum would do, since subtracting a negative adds).
|
||||
// See computeDonationRemaining's doc comment for why refund legs (negative amount) reduce
|
||||
// rather than inflate the remaining balance.
|
||||
const existingLegs = await prisma.payment.findMany({
|
||||
where: { originalPaymentId: payment.id, isDonation: false }
|
||||
});
|
||||
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
|
||||
const remainingDonation = payment.amount - alreadyUsed;
|
||||
const remainingDonation = computeDonationRemaining(payment.amount, existingLegs);
|
||||
|
||||
if (remainingDonation <= 0.000001) {
|
||||
res.status(400);
|
||||
@@ -766,6 +764,16 @@ const assignDonationToRegistration = async (req, res) => {
|
||||
donationRemaining: remainingDonation - allocateAmount
|
||||
};
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'donation_assigned',
|
||||
targetType: 'Registration',
|
||||
targetId: registrationId,
|
||||
metadata: { paymentId, legId: leg.id, allocateAmount },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -885,6 +893,16 @@ const unassignDonationFromRegistration = async (req, res) => {
|
||||
catch (e) { console.error('Failed to send emails after unassigning donation:', e); }
|
||||
})();
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'donation_unassigned',
|
||||
targetType: 'Registration',
|
||||
targetId: leg.registrationId,
|
||||
metadata: { legId: leg.id, donationId: donation.id, amount: leg.amount },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Donation unassigned',
|
||||
updatedRegistration: finalRegistration,
|
||||
@@ -1357,6 +1375,16 @@ const createRefund = async (req, res) => {
|
||||
const { sendRefundEmail } = require('../utils/notifications');
|
||||
sendRefundEmail(negativePayment.id).catch(e => console.error('Failed to send refund email:', e));
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'refund_created',
|
||||
targetType: 'Payment',
|
||||
targetId: negativePayment.id,
|
||||
metadata: { amount: amt, method: method || 'refund', reason: reason || null, registrationId: linkRegistrationId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
return res.status(201).json(negativePayment);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
|
||||
@@ -5,6 +5,8 @@ const { emailTickets } = require('./ticketController');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption.
|
||||
@@ -624,6 +626,22 @@ const updateRegistrationStatus = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// This is the staff-only status-change endpoint (separate from the owner-facing
|
||||
// DELETE /:id cancel route), so any transition into 'cancelled' here is always a
|
||||
// staff-initiated cancellation — log it the same way DELETE /:id does, so both
|
||||
// paths land under the one 'registration_cancelled' filter in the audit log.
|
||||
if (status === 'cancelled' && registration.status !== 'cancelled') {
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'registration_cancelled',
|
||||
targetType: 'Registration',
|
||||
targetId: req.params.id,
|
||||
metadata: { registrationOwnerId: registration.userId, previousStatus: registration.status },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
// Generate tickets and email them when status is manually set to 'paid' by staff
|
||||
if (status === 'paid') {
|
||||
(async () => {
|
||||
@@ -698,6 +716,20 @@ const cancelRegistration = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
if (registration.userId !== req.user.id) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'registration_cancelled',
|
||||
targetType: 'Registration',
|
||||
targetId: req.params.id,
|
||||
metadata: { registrationOwnerId: registration.userId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
@@ -1143,6 +1175,16 @@ const createManualRegistration = async (req, res) => {
|
||||
}
|
||||
})();
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'registration_created_manual',
|
||||
targetType: 'Registration',
|
||||
targetId: registrationId,
|
||||
metadata: { eventId: registration.eventId, forUserId: userId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
return res.status(201).json(attachComputedTotals(registration));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -4,6 +4,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
|
||||
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
|
||||
// Keys safe to return without auth — includes legal keys needed by public legal pages
|
||||
const PUBLIC_KEYS = [
|
||||
@@ -107,6 +109,20 @@ const updateSettings = async (req, res) => {
|
||||
if (ops.length) await prisma.$transaction(ops);
|
||||
invalidateSettingsCache();
|
||||
await warmCache(); // ensure in-memory cache reflects the new values before responding
|
||||
|
||||
// Log which keys changed, never the values — some settings are secrets (e.g. the
|
||||
// WAWP token) that aren't even encrypted at rest, let alone fit for an audit log.
|
||||
if (ops.length) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'settings_updated',
|
||||
targetType: 'AppSetting',
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Settings saved' });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
@@ -156,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) });
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
|
||||
// Builds a filename multer can never be tricked into escaping the upload
|
||||
// directory with — extension only, no attacker-controlled path segments.
|
||||
// (file.originalname is untrusted; joining it into a path allows `../` traversal.)
|
||||
function safeFilename(prefix, ext) {
|
||||
return `${prefix}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
||||
}
|
||||
|
||||
// Setup multer storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
@@ -24,8 +32,7 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueName = `${Date.now()}-${file.originalname}`;
|
||||
cb(null, uniqueName);
|
||||
cb(null, safeFilename('event', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +61,7 @@ const logoStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `logo-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
cb(null, safeFilename('logo', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,7 +89,7 @@ const faviconStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
cb(null, safeFilename('favicon', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,18 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const {
|
||||
METHODS: TWO_FACTOR_METHODS,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
} = require('../utils/twoFactor');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -124,6 +136,35 @@ async function sendWelcomeEmail(user) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
// the controller convention of res.status(...) before throwing.
|
||||
function errorStatus(error, res) {
|
||||
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
|
||||
}
|
||||
|
||||
// Final step of a successful login (after the password, and the 2FA code when
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
function completeLogin(user, req, res, extra = {}) {
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
// @desc Register a new user
|
||||
// @route POST /api/users
|
||||
// @access Public
|
||||
@@ -198,7 +239,7 @@ const registerUser = async (req, res) => {
|
||||
// @access Public
|
||||
const loginUser = async (req, res) => {
|
||||
try {
|
||||
const {email, password} = req.body;
|
||||
const {email, password, deviceToken} = req.body;
|
||||
|
||||
const rawInput = email;
|
||||
|
||||
@@ -278,20 +319,179 @@ const loginUser = async (req, res) => {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedAttempts: 0, lockUntil: null },
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(updated, req).catch(() => {});
|
||||
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
// Two-factor: the password alone isn't enough unless this browser was
|
||||
// remembered earlier. Send a code and hand back a challenge instead of a token.
|
||||
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
|
||||
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
|
||||
return res.json({ twoFactorRequired: true, ...challenge });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
role: updated.role,
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion)
|
||||
// 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) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Second login step — exchange an emailed/WhatsApp code for a token
|
||||
// @route POST /api/users/login/2fa
|
||||
// @access Public
|
||||
const verifyLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code, rememberDevice } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
|
||||
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']);
|
||||
}
|
||||
completeLogin(user, req, res, extra);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send a new code for a pending login challenge
|
||||
// @route POST /api/users/login/2fa/resend
|
||||
// @access Public
|
||||
const resendLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId } = req.body || {};
|
||||
res.json(await resendChallenge(challengeId, 'login'));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Start turning on 2FA — sends a code to the chosen channel to prove it works
|
||||
// @route POST /api/users/2fa/enable
|
||||
// @access Private
|
||||
const startEnableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { method, password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
if (!TWO_FACTOR_METHODS.includes(method)) {
|
||||
res.status(400);
|
||||
throw new Error('Choose email or WhatsApp for your verification codes');
|
||||
}
|
||||
if (!(await availableMethods(user)).includes(method)) {
|
||||
res.status(400);
|
||||
throw new Error(method === 'whatsapp'
|
||||
? 'WhatsApp codes aren\'t available for your account. Add a valid phone number or choose email.'
|
||||
: 'Add a valid email address to your profile before turning on two-factor authentication.');
|
||||
}
|
||||
|
||||
// Unlike login, don't fall back to email here — the point of this step is to
|
||||
// prove the channel the user picked actually reaches them.
|
||||
res.json(await createChallenge(user, 'enable', method, { allowFallback: false }));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Finish turning on 2FA with the code that was sent
|
||||
// @route POST /api/users/2fa/confirm
|
||||
// @access Private
|
||||
const confirmEnableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'enable', req.user.id);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
});
|
||||
logSecurityEvent({ userId: req.user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({ twoFactorEnabled: true, twoFactorMethod: challenge.method });
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Turn off 2FA
|
||||
// @route POST /api/users/2fa/disable
|
||||
// @access Private
|
||||
const disableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
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');
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: false, twoFactorMethod: null, updatedAt: new Date() },
|
||||
});
|
||||
await clearTrustedDevices(user.id);
|
||||
if (user.twoFactorEnabled) {
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({ twoFactorEnabled: false, twoFactorMethod: null });
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-confirm the logged-in user's password without starting a new session
|
||||
// (the self-service kiosk uses this before letting the event be changed)
|
||||
// @route POST /api/users/verify-password
|
||||
// @access Private
|
||||
const verifyPassword = async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id }, select: { password: true } });
|
||||
if (!user || typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(401);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -311,6 +511,8 @@ const getUserProfile = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
@@ -318,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');
|
||||
@@ -375,6 +577,11 @@ const updateUserProfile = async (req, res) => {
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
|
||||
// 2FA codes can't go to WhatsApp without a usable number — move them to email
|
||||
// rather than leave the account pointing at a channel that no longer exists.
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
|
||||
|
||||
// Update user data
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
@@ -384,6 +591,7 @@ const updateUserProfile = async (req, res) => {
|
||||
password: newHashedPassword ? newHashedPassword : user.password,
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
@@ -393,6 +601,8 @@ const updateUserProfile = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
@@ -402,6 +612,7 @@ const updateUserProfile = async (req, res) => {
|
||||
|
||||
// If the password was changed, send a security alert email (fire-and-forget)
|
||||
if (newHashedPassword) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
|
||||
@@ -414,6 +625,7 @@ const updateUserProfile = async (req, res) => {
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
twoFactorRequired: requiresTwoFactor(updatedUser),
|
||||
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -432,7 +644,7 @@ const getUsers = async (req, res) => {
|
||||
|
||||
const select = {
|
||||
id: true, name: true, email: true, role: true,
|
||||
phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, createdAt: true, updatedAt: true, isActive: true
|
||||
};
|
||||
|
||||
// Build filters
|
||||
@@ -559,7 +771,7 @@ const updateUser = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
|
||||
|
||||
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
@@ -580,6 +792,21 @@ const updateUser = async (req, res) => {
|
||||
data.password = await hashPassword(password.trim());
|
||||
}
|
||||
|
||||
// Admins can switch 2FA off for someone who has lost access to their codes,
|
||||
// but never on — enabling requires the user to prove the channel themselves.
|
||||
const resetTwoFactor = twoFactorEnabled === false && user.twoFactorEnabled;
|
||||
if (resetTwoFactor) {
|
||||
data.twoFactorEnabled = false;
|
||||
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,
|
||||
@@ -590,12 +817,20 @@ const updateUser = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (resetTwoFactor || data.password) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
}
|
||||
if (resetTwoFactor) {
|
||||
logSecurityEvent({ userId: updatedUser.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -646,10 +881,13 @@ const anonymizeUser = async (req, res) => {
|
||||
email: `deleted-${req.params.id}@deleted.local`,
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
@@ -754,6 +992,7 @@ const resetPassword = async (req, res) => {
|
||||
prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }),
|
||||
prisma.passwordReset.update({ where: { token }, data: { used: true } })
|
||||
]);
|
||||
await clearTrustedDevices(user.id);
|
||||
|
||||
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
@@ -838,6 +1077,7 @@ const revokeMySession = async (req, res) => {
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
select: { id: true, role: true, tokenVersion: true },
|
||||
});
|
||||
await clearTrustedDevices(updated.id);
|
||||
// Bumping tokenVersion invalidates every existing token, including the one
|
||||
// this request just used. Issue a fresh token for the current device so it
|
||||
// isn't logged out too.
|
||||
@@ -864,6 +1104,7 @@ const adminRevokeUserSessions = async (req, res) => {
|
||||
where: { id: req.params.id },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
res.json({ message: `Sessions revoked for ${target.name}.` });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -901,10 +1142,13 @@ const closeAccount = async (req, res) => {
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
@@ -926,6 +1170,7 @@ const closeAccount = async (req, res) => {
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
// Send closure confirmation
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
@@ -957,6 +1202,12 @@ const getMyActivity = async (req, res) => {
|
||||
module.exports = {
|
||||
registerUser,
|
||||
loginUser,
|
||||
verifyLoginTwoFactor,
|
||||
resendLoginTwoFactor,
|
||||
startEnableTwoFactor,
|
||||
confirmEnableTwoFactor,
|
||||
disableTwoFactor,
|
||||
verifyPassword,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
|
||||
+119
-9
@@ -1,16 +1,33 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { version: API_VERSION } = require('../package.json');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const dotenv = require('dotenv');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
|
||||
const getRawBody = require('raw-body');
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Error monitoring — a no-op if SENTRY_DSN isn't set, so this is safe in every
|
||||
// environment (dev, a fresh deploy that hasn't configured Sentry yet, etc.).
|
||||
// Must run before express/@prisma/client are required below — Sentry's
|
||||
// auto-instrumentation patches those modules via a require hook, which only
|
||||
// works if Sentry.init() runs before they're first required into the cache.
|
||||
if (process.env.SENTRY_DSN) {
|
||||
const Sentry = require('@sentry/node');
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
// Small single-VM deployment, not high-traffic — start conservative and raise
|
||||
// this once real usage is visible in Sentry, rather than sampling every request.
|
||||
tracesSampleRate: 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
const express = require('express');
|
||||
const { version: API_VERSION } = require('../package.json');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
|
||||
const getRawBody = require('raw-body');
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -126,6 +143,8 @@ const setupRoutes = require('./routes/setupRoutes');
|
||||
const costRoutes = require('./routes/costRoutes');
|
||||
const cashupRoutes = require('./routes/cashupRoutes');
|
||||
const statsRoutes = require('./routes/statsRoutes');
|
||||
const adminRoutes = require('./routes/adminRoutes');
|
||||
const backupRoutes = require('./routes/backupRoutes');
|
||||
|
||||
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
@@ -155,11 +174,22 @@ app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api', costRoutes);
|
||||
app.use('/api/cashups', cashupRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/backups', backupRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
const { getSettingSync, warmCache } = require('./utils/settingsCache');
|
||||
warmCache().catch(() => {});
|
||||
app.use('/uploads', express.static('public/uploads'));
|
||||
// Uploaded branding assets can include SVGs, which may embed <script>/event
|
||||
// handlers. Serving them inline lets a compromised/malicious upload run script
|
||||
// in the site's origin if opened directly, so pin the safe response headers
|
||||
// (no inline execution, no MIME-sniffing to HTML/script) on every asset here.
|
||||
app.use('/uploads', express.static('public/uploads', {
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Shared page helpers ────────────────────────────────────────────────────────
|
||||
const jwt = require('jsonwebtoken');
|
||||
@@ -329,7 +359,7 @@ app.get('/docs', async (req, res) => {
|
||||
|
||||
let user;
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true },
|
||||
@@ -361,8 +391,24 @@ app.get('/docs', async (req, res) => {
|
||||
request:{ body:{ email:'jane@example.com', password:'secret123' }},
|
||||
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',
|
||||
notes:'Codes expire after 10 minutes and allow 5 attempts. With rememberDevice, the response includes a deviceToken; send it with later logins to skip the code for 30 days.',
|
||||
request:{ body:{ challengeId:'c0ffee00-...', code:'123456', rememberDevice:true }},
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...', deviceToken:'9f2c...' }},
|
||||
{ status:400, desc:'Wrong or expired code', body:{ message:'Incorrect code. 4 attempts left.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/login/2fa/resend', auth:'public', desc:'Send a new code for a pending login challenge',
|
||||
notes:'At most one send per 60 seconds.',
|
||||
request:{ body:{ challengeId:'c0ffee00-...' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:429, desc:'Too soon', body:{ message:'Please wait 42 seconds before requesting another code.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email',
|
||||
request:{ body:{ email:'jane@example.com' }},
|
||||
responses:[
|
||||
@@ -394,6 +440,29 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]},
|
||||
{ method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account',
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]},
|
||||
{ method:'POST', path:'/api/users/2fa/enable', auth:'user+', desc:'Start turning on two-factor authentication — sends a code to the chosen channel',
|
||||
notes:'WhatsApp needs a valid phone number on the account and a connected WhatsApp session.',
|
||||
request:{ body:{ method:'email', password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Code sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:400, desc:'Wrong password or unavailable channel', body:{ message:'Incorrect password' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/2fa/confirm', auth:'user+', desc:'Finish turning on two-factor authentication with the code that was sent',
|
||||
request:{ body:{ challengeId:'c0ffee00-...', code:'123456' }},
|
||||
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' }},
|
||||
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:[
|
||||
{ status:200, desc:'Correct', body:{ ok:true }},
|
||||
{ status:401, desc:'Wrong password', body:{ message:'Incorrect password' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.',
|
||||
queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' },
|
||||
responses:[
|
||||
@@ -1134,6 +1203,14 @@ function toggle(id) {
|
||||
|
||||
// Error middleware
|
||||
app.use(notFound);
|
||||
|
||||
// Sentry captures the error here, then passes it through unchanged — errorHandler
|
||||
// below remains the sole source of what's actually sent back to the client.
|
||||
if (process.env.SENTRY_DSN) {
|
||||
const Sentry = require('@sentry/node');
|
||||
Sentry.setupExpressErrorHandler(app);
|
||||
}
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
// Start server
|
||||
@@ -1224,6 +1301,36 @@ app.listen(PORT, () => {
|
||||
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
|
||||
try {
|
||||
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runBackup } = require('./utils/backupUtils');
|
||||
function scheduleNightlyBackup() {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(2, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const result = await runBackup();
|
||||
console.log(`[backup] Completed: ${result.filename}`);
|
||||
} catch (e) {
|
||||
console.error('[backup] Failed:', e?.message || e);
|
||||
} finally {
|
||||
scheduleNightlyBackup();
|
||||
}
|
||||
}, next.getTime() - now.getTime());
|
||||
}
|
||||
scheduleNightlyBackup();
|
||||
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
|
||||
} else {
|
||||
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[backup] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Scheduled emails worker (polling)
|
||||
try {
|
||||
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
@@ -1287,6 +1394,9 @@ app.listen(PORT, () => {
|
||||
process.on('unhandledRejection', (err) => {
|
||||
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
|
||||
console.error(err?.stack || err);
|
||||
if (process.env.SENTRY_DSN) {
|
||||
try { require('@sentry/node').captureException(err); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { app, prisma };
|
||||
@@ -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) => {
|
||||
@@ -13,8 +17,9 @@ const protect = async (req, res, next) => {
|
||||
// Get token from header
|
||||
token = req.headers.authorization.split(' ')[1];
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
// Verify token — pin the algorithm so a token signed with an
|
||||
// unexpected/attacker-chosen algorithm is never accepted.
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
|
||||
// Get user from the token (exclude password)
|
||||
req.user = await prisma.user.findUnique({
|
||||
@@ -28,7 +33,8 @@ const protect = async (req, res, next) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
phoneNumber: true,
|
||||
tokenVersion: true
|
||||
tokenVersion: true,
|
||||
twoFactorEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,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);
|
||||
@@ -105,7 +122,7 @@ const optionalAuth = async (req, res, next) => {
|
||||
}
|
||||
try {
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listAuditLog } = require('../controllers/adminAuditController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit-log', protect, admin, listAuditLog);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBackups, triggerBackup, downloadBackup } = require('../controllers/backupController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', protect, admin, getBackups);
|
||||
router.post('/run', protect, admin, triggerBackup);
|
||||
router.get('/:filename/download', protect, admin, downloadBackup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
getEventById,
|
||||
getEventIcs,
|
||||
updateEvent,
|
||||
getEventNotifyRecipients,
|
||||
updateEventNotifyRecipients,
|
||||
@@ -44,6 +45,7 @@ router.post('/attachments/sync', protect, admin, attachmentsSync);
|
||||
// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin
|
||||
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
|
||||
router.get('/:id', optionalAuth, getEventById);
|
||||
router.get('/:id/ics', optionalAuth, getEventIcs);
|
||||
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
||||
|
||||
// Create/Update/Delete event
|
||||
|
||||
@@ -3,6 +3,12 @@ const router = express.Router();
|
||||
const {
|
||||
registerUser,
|
||||
loginUser,
|
||||
verifyLoginTwoFactor,
|
||||
resendLoginTwoFactor,
|
||||
startEnableTwoFactor,
|
||||
confirmEnableTwoFactor,
|
||||
disableTwoFactor,
|
||||
verifyPassword,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
@@ -24,6 +30,8 @@ const { protect, admin, supervisor, loginLimiter} = require('../middleware/authM
|
||||
// Public routes
|
||||
router.post('/', registerUser);
|
||||
router.post('/login', loginLimiter, loginUser);
|
||||
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
|
||||
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
|
||||
router.post('/forgot', requestPasswordReset);
|
||||
router.post('/reset', resetPassword);
|
||||
router.post('/activate', activateAccount);
|
||||
@@ -36,6 +44,10 @@ router.route('/profile')
|
||||
router.post('/revoke-sessions', protect, revokeMySession);
|
||||
router.post('/close-account', protect, closeAccount);
|
||||
router.get('/activity', protect, getMyActivity);
|
||||
router.post('/verify-password', protect, loginLimiter, verifyPassword);
|
||||
router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor);
|
||||
router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor);
|
||||
router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// Fire-and-forget by design — a logging failure must never break the underlying admin
|
||||
// action, so this swallows its own errors rather than propagating them to the caller
|
||||
// (same posture as logSecurityEvent).
|
||||
async function logAdminAction({ actorId, actorRole, action, targetType, targetId, metadata, ip }) {
|
||||
try {
|
||||
await prisma.adminAuditLog.create({
|
||||
data: {
|
||||
actorId: actorId || null,
|
||||
actorRole,
|
||||
action,
|
||||
targetType,
|
||||
targetId: targetId || null,
|
||||
metadata: metadata || undefined,
|
||||
ip: ip || null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to log admin action:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
|
||||
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
|
||||
const where = {};
|
||||
if (actorId) where.actorId = actorId;
|
||||
if (action) where.action = action;
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
if (to) where.createdAt.lte = new Date(to);
|
||||
}
|
||||
|
||||
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
skip,
|
||||
include: { actor: { select: { id: true, name: true, email: true } } },
|
||||
}),
|
||||
prisma.adminAuditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
|
||||
}
|
||||
|
||||
module.exports = { logAdminAction, getAdminAuditLog };
|
||||
@@ -0,0 +1,87 @@
|
||||
const { spawn } = require('child_process');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const zlib = require('zlib');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
const BACKUP_DIR = path.join(__dirname, '..', '..', 'backups');
|
||||
|
||||
// Local-disk-only by design (no offsite/cloud upload) — matches the exact filename shape
|
||||
// this module generates, and is reused to validate download requests against path traversal.
|
||||
const BACKUP_FILENAME_RE = /^backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.sql\.gz$/;
|
||||
|
||||
function ensureBackupDir() {
|
||||
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function backupFilename(date = new Date()) {
|
||||
return `backup-${date.toISOString().replace(/[:.]/g, '-')}.sql.gz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `pg_dump` against DATABASE_URL, gzip its output, and write it to backend/backups/.
|
||||
* Requires the `pg_dump` binary to be installed on this host (Postgres itself is hosted
|
||||
* separately) — a deploy-environment prerequisite, not something this code can satisfy.
|
||||
*
|
||||
* @returns {Promise<{ filename: string, path: string }>}
|
||||
*/
|
||||
async function runBackup() {
|
||||
ensureBackupDir();
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL is not configured');
|
||||
|
||||
const filename = backupFilename();
|
||||
const filePath = path.join(BACKUP_DIR, filename);
|
||||
|
||||
const pgDump = spawn('pg_dump', [databaseUrl, '--no-owner', '--no-privileges'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
pgDump.stderr.on('data', (d) => { stderr += d.toString(); });
|
||||
|
||||
const exitPromise = new Promise((resolve, reject) => {
|
||||
pgDump.on('error', (err) => reject(new Error(`Failed to start pg_dump: ${err.message}. Is it installed on this host?`)));
|
||||
pgDump.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`pg_dump exited with code ${code}: ${stderr.slice(0, 500)}`));
|
||||
});
|
||||
});
|
||||
|
||||
const pipelinePromise = pipeline(pgDump.stdout, zlib.createGzip(), fs.createWriteStream(filePath));
|
||||
|
||||
try {
|
||||
// Both must succeed: the process exiting cleanly, and the gzip write finishing —
|
||||
// a mid-dump failure must not leave a truncated file looking like a real backup.
|
||||
await Promise.all([exitPromise, pipelinePromise]);
|
||||
} catch (err) {
|
||||
await fs.promises.unlink(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const retainCount = parseInt(getSettingSync('backup_retain_count', '14'), 10) || 14;
|
||||
await rotateBackups(retainCount);
|
||||
|
||||
return { filename, path: filePath };
|
||||
}
|
||||
|
||||
/** @returns {Promise<Array<{ filename: string, size: number, createdAt: Date }>>} newest first */
|
||||
async function listBackups() {
|
||||
ensureBackupDir();
|
||||
const files = await fs.promises.readdir(BACKUP_DIR);
|
||||
const backups = await Promise.all(
|
||||
files.filter((f) => BACKUP_FILENAME_RE.test(f)).map(async (f) => {
|
||||
const stat = await fs.promises.stat(path.join(BACKUP_DIR, f));
|
||||
return { filename: f, size: stat.size, createdAt: stat.mtime };
|
||||
})
|
||||
);
|
||||
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
/** Deletes backups beyond the retention count, oldest first. */
|
||||
async function rotateBackups(retainCount) {
|
||||
const backups = await listBackups();
|
||||
const toDelete = backups.slice(retainCount);
|
||||
await Promise.all(toDelete.map((b) => fs.promises.unlink(path.join(BACKUP_DIR, b.filename)).catch(() => {})));
|
||||
return { deleted: toDelete.length };
|
||||
}
|
||||
|
||||
module.exports = { runBackup, listBackups, rotateBackups, BACKUP_DIR, BACKUP_FILENAME_RE };
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Donations are never mutated once created — their remaining balance is the original
|
||||
* amount minus every "leg" (a Payment row with isDonation:false and originalPaymentId
|
||||
* pointing back at this donation) already allocated from it. A refund of the donation
|
||||
* itself also creates a leg, with a negative amount — Math.abs() so a refund reduces
|
||||
* the remaining balance (money that's left the building) instead of increasing it
|
||||
* (which a raw signed sum would do, since subtracting a negative adds).
|
||||
*
|
||||
* @param {number} originalAmount - the donation payment's own amount
|
||||
* @param {Array<{amount: number}>} legs - Payment rows with originalPaymentId === donation.id
|
||||
* @returns {number}
|
||||
*/
|
||||
function computeDonationRemaining(originalAmount, legs) {
|
||||
const alreadyUsed = (legs || []).reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
|
||||
return Number(originalAmount || 0) - alreadyUsed;
|
||||
}
|
||||
|
||||
module.exports = { computeDonationRemaining };
|
||||
@@ -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')) {
|
||||
@@ -162,6 +167,14 @@ function fallbackLink(url) {
|
||||
</p>`;
|
||||
}
|
||||
|
||||
/** Renders a subtle, labeled secondary link — distinct from the primary CTA button. */
|
||||
function secondaryLink(label, url) {
|
||||
const color = getOrg().headerColor;
|
||||
return `<p style="text-align:center;margin:16px 0 0 0;font-size:13px;color:#64748b">
|
||||
<a href="${url}" style="color:${color}">${label}</a>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
/** Horizontal rule. */
|
||||
function divider() {
|
||||
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
|
||||
@@ -226,6 +239,28 @@ function buildPasswordResetEmail({ name, resetUrl }) {
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildTwoFactorCodeEmail({ name, code }) {
|
||||
const org = getOrg();
|
||||
const preheader = `Your ${org.name} verification code. It expires in 10 minutes.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Your verification code</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Two-factor authentication for your account</p>
|
||||
|
||||
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151">Enter this code to continue. It will expire in <strong>10 minutes</strong>.</p>
|
||||
|
||||
<p style="margin:0 0 24px 0;font-size:34px;font-weight:800;letter-spacing:8px;color:#0f172a;text-align:center;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace">${code}</p>
|
||||
|
||||
${divider()}
|
||||
<p style="margin:0;font-size:13px;color:#94a3b8">
|
||||
Never share this code with anyone. If you did not try to log in, someone may know your password —
|
||||
change it and contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||
</p>`;
|
||||
|
||||
const text = `Hi ${name || 'there'},\n\nYour ${org.name} verification code is: ${code}\n\nIt expires in 10 minutes. Never share this code with anyone.\n\nIf you did not try to log in, change your password and contact ${org.email}.\n\n${org.name} — ${org.email}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildPasswordChangedEmail({ name, when, supportEmail }) {
|
||||
const org = getOrg();
|
||||
const contact = supportEmail || org.email;
|
||||
@@ -387,13 +422,16 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
|
||||
module.exports = {
|
||||
sendMail,
|
||||
isSmtpConfigured,
|
||||
emailWrapper,
|
||||
ctaButton,
|
||||
fallbackLink,
|
||||
secondaryLink,
|
||||
divider,
|
||||
callout,
|
||||
paymentOption,
|
||||
buildPasswordResetEmail,
|
||||
buildTwoFactorCodeEmail,
|
||||
buildPasswordChangedEmail,
|
||||
buildLoginNotificationEmail,
|
||||
buildWelcomeEmail,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const { createEvent } = require('ics');
|
||||
|
||||
// Event.startDate/endDate are stored as true UTC instants (the admin event form's
|
||||
// datetime-local input is parsed in the browser's local time before being sent as an
|
||||
// ISO string), so serializing them as UTC here requires no timezone math and lets every
|
||||
// viewer's calendar app localize correctly to *their own* timezone.
|
||||
function toUtcArray(date) {
|
||||
const d = new Date(date);
|
||||
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an RFC 5545 .ics file (as a string) for a single event.
|
||||
*
|
||||
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?
|
||||
* @param {string} eventUrl - absolute URL to the event's public page
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildEventIcs(event, eventUrl) {
|
||||
const { error, value } = createEvent({
|
||||
title: event.title,
|
||||
start: toUtcArray(event.startDate),
|
||||
end: toUtcArray(event.endDate),
|
||||
startInputType: 'utc',
|
||||
endInputType: 'utc',
|
||||
startOutputType: 'utc',
|
||||
endOutputType: 'utc',
|
||||
location: event.location || undefined,
|
||||
description: event.description || undefined,
|
||||
url: eventUrl,
|
||||
});
|
||||
if (error) throw error;
|
||||
return value;
|
||||
}
|
||||
|
||||
module.exports = { buildEventIcs };
|
||||
@@ -1,6 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const prisma = require('../config/db');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
|
||||
|
||||
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
||||
@@ -22,6 +22,20 @@ function fmtDateShort(d) {
|
||||
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
// The .ics calendar-download link lives on the backend (not the frontend site), same
|
||||
// as the ticket-PDF URLs sent to WhatsApp — see whatsapp.js's BACKEND_URL usage.
|
||||
function getBackendUrl() {
|
||||
return (process.env.BACKEND_URL || '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// Only rendered when BACKEND_URL is actually configured — the .ics endpoint lives on
|
||||
// the backend, and there's no reliable way to derive that URL otherwise.
|
||||
function calendarLinkRow(eventId) {
|
||||
const backendUrl = getBackendUrl();
|
||||
if (!backendUrl || !eventId) return '';
|
||||
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
|
||||
}
|
||||
|
||||
function getOrg() {
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
@@ -269,7 +283,8 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
${accountCta(isUserActive, org.url)}
|
||||
${calendarLinkRow(reg.eventId)}`;
|
||||
|
||||
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
|
||||
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name} — ${org.email}\n${org.url}`;
|
||||
@@ -309,7 +324,8 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
${accountCta(isUserActive, org.url)}
|
||||
${calendarLinkRow(reg.eventId)}`;
|
||||
|
||||
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
|
||||
const payText = balance > 0
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Resolve a client IP from the request (works behind proxies) — mirrors the equivalent
|
||||
// local helper in userController.js, shared here for the admin-audit call sites.
|
||||
function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
module.exports = { getClientIp };
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Two-factor authentication helpers: 6-digit codes delivered by email or
|
||||
* WhatsApp, and "trust this device" tokens that let a browser skip the code.
|
||||
*
|
||||
* Neither the code nor the device token is stored in the clear — only an
|
||||
* HMAC keyed with JWT_SECRET, so a leaked database row can't be replayed.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { describeUserAgent } = require('./userAgent');
|
||||
|
||||
const CODE_TTL_MS = 10 * 60 * 1000; // a code is valid for 10 minutes
|
||||
const MAX_ATTEMPTS = 5; // wrong guesses allowed per code
|
||||
const RESEND_COOLDOWN_MS = 60 * 1000; // minimum gap between sends
|
||||
const CHALLENGE_MAX_AGE_MS = 30 * 60 * 1000; // after this, log in again rather than resend
|
||||
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);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
||||
}
|
||||
|
||||
function hmac(value) {
|
||||
return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(String(value)).digest('hex');
|
||||
}
|
||||
|
||||
// Namespaced so a code hash can never collide with a device-token hash.
|
||||
const hashCode = (code) => hmac(`2fa-code:${code}`);
|
||||
const hashDeviceToken = (token) => hmac(`2fa-device:${token}`);
|
||||
|
||||
function safeEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
function hasRealEmail(user) {
|
||||
return !!(user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@deleted.local'));
|
||||
}
|
||||
|
||||
function maskEmail(email) {
|
||||
const [local, domain] = String(email || '').split('@');
|
||||
if (!local || !domain) return '•••';
|
||||
return `${local[0]}•••@${domain}`;
|
||||
}
|
||||
|
||||
function maskPhone(phone) {
|
||||
const digits = String(phone || '').replace(/\D/g, '');
|
||||
return `••• ••• ${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Channels this user could receive a code on right now. */
|
||||
async function availableMethods(user) {
|
||||
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
||||
const methods = [];
|
||||
if (hasRealEmail(user)) methods.push('email');
|
||||
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) methods.push('whatsapp');
|
||||
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
|
||||
* by the WhatsApp gateway being down. Throws when no channel could deliver.
|
||||
* Pass `allowFallback: false` when the point is to prove the chosen channel works.
|
||||
*/
|
||||
async function deliverCode(user, method, code, { allowFallback = true } = {}) {
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.info(`[2fa][dev] code for ${user.email}: ${code}`);
|
||||
}
|
||||
|
||||
if (method === 'whatsapp') {
|
||||
const { isValidZAPhone, isConfigured, sendText } = require('./whatsapp');
|
||||
const { buildWATwoFactorCode } = require('./waMessages');
|
||||
try {
|
||||
// sendText silently no-ops when WhatsApp isn't set up, so check first.
|
||||
if (!isValidZAPhone(user.phoneNumber) || !(await isConfigured())) throw new Error('WhatsApp unavailable');
|
||||
await sendText(user.phoneNumber, buildWATwoFactorCode({ name: user.name, code }));
|
||||
return { method: 'whatsapp', destination: maskPhone(user.phoneNumber) };
|
||||
} catch (e) {
|
||||
console.warn('[2fa] WhatsApp code failed:', e?.message || e);
|
||||
if (!allowFallback) throw fail('We couldn\'t send a WhatsApp message right now. Please try again later or choose email.', 503);
|
||||
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
const { sendMail, buildTwoFactorCodeEmail } = require('./email');
|
||||
try {
|
||||
await sendMail({ to: user.email, subject: `Your ${orgName} verification code`, ...buildTwoFactorCodeEmail({ name: user.name, code }) });
|
||||
} catch (e) {
|
||||
console.warn('[2fa] Email code failed:', e?.message || e);
|
||||
throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
}
|
||||
return { method: 'email', destination: maskEmail(user.email) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a fresh code and records the challenge. Any earlier challenge of the same
|
||||
* purpose for this user is discarded, so only the newest code works.
|
||||
*/
|
||||
async function createChallenge(user, purpose, method, options) {
|
||||
const code = generateCode();
|
||||
const sent = await deliverCode(user, method, code, options);
|
||||
|
||||
await prisma.twoFactorChallenge.deleteMany({ where: { userId: user.id, purpose } });
|
||||
const challenge = await prisma.twoFactorChallenge.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
purpose,
|
||||
method: sent.method,
|
||||
codeHash: hashCode(code),
|
||||
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
return { challengeId: challenge.id, method: sent.method, destination: sent.destination };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a submitted code. Returns the challenge (with its user) on success and
|
||||
* throws otherwise. Pass `userId` to also require the challenge belong to that user.
|
||||
*/
|
||||
async function verifyChallenge(challengeId, code, purpose, userId) {
|
||||
const invalid = () => fail('That code is invalid or has expired. Please request a new one.');
|
||||
if (!challengeId || typeof challengeId !== 'string') throw invalid();
|
||||
const submitted = String(code ?? '').replace(/\D/g, '');
|
||||
|
||||
// Count the attempt before comparing, atomically, so parallel guesses can't
|
||||
// slip past the attempt limit.
|
||||
const counted = await prisma.twoFactorChallenge.updateMany({
|
||||
where: {
|
||||
id: challengeId,
|
||||
purpose,
|
||||
...(userId ? { userId } : {}),
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
attempts: { lt: MAX_ATTEMPTS },
|
||||
},
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
if (counted.count !== 1) throw invalid();
|
||||
|
||||
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
||||
if (!challenge || !safeEqual(challenge.codeHash, hashCode(submitted))) {
|
||||
const left = challenge ? MAX_ATTEMPTS - challenge.attempts : 0;
|
||||
throw fail(left > 0 ? `Incorrect code. ${left} attempt${left === 1 ? '' : 's'} left.` : 'Too many incorrect attempts. Please request a new code.');
|
||||
}
|
||||
|
||||
// Single use — if two requests race with the right code, only one wins.
|
||||
const used = await prisma.twoFactorChallenge.updateMany({
|
||||
where: { id: challengeId, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (used.count !== 1) throw invalid();
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
/** Replaces the code on an existing challenge and sends it again. */
|
||||
async function resendChallenge(challengeId, purpose, userId) {
|
||||
const expired = () => fail('This verification has expired. Please start again.');
|
||||
if (!challengeId || typeof challengeId !== 'string') throw expired();
|
||||
|
||||
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
||||
if (!challenge || challenge.purpose !== purpose || challenge.usedAt || (userId && challenge.userId !== userId)) throw expired();
|
||||
if (Date.now() - challenge.createdAt.getTime() > CHALLENGE_MAX_AGE_MS) throw expired();
|
||||
|
||||
const wait = RESEND_COOLDOWN_MS - (Date.now() - challenge.lastSentAt.getTime());
|
||||
if (wait > 0) throw fail(`Please wait ${Math.ceil(wait / 1000)} seconds before requesting another code.`, 429);
|
||||
|
||||
// Claim the send slot first so two simultaneous resends can't both go out.
|
||||
const claimed = await prisma.twoFactorChallenge.updateMany({
|
||||
where: { id: challengeId, lastSentAt: challenge.lastSentAt, usedAt: null },
|
||||
data: { lastSentAt: new Date() },
|
||||
});
|
||||
if (claimed.count !== 1) throw fail('Please wait before requesting another code.', 429);
|
||||
|
||||
const code = generateCode();
|
||||
const sent = await deliverCode(challenge.user, challenge.method, code);
|
||||
await prisma.twoFactorChallenge.update({
|
||||
where: { id: challengeId },
|
||||
data: { codeHash: hashCode(code), method: sent.method, attempts: 0, expiresAt: new Date(Date.now() + CODE_TTL_MS) },
|
||||
});
|
||||
|
||||
return { challengeId, method: sent.method, destination: sent.destination };
|
||||
}
|
||||
|
||||
/** Remembers this browser for 30 days. Returns the raw token to hand to the client. */
|
||||
async function issueTrustedDevice(userId, userAgent) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
await prisma.trustedDevice.deleteMany({ where: { userId, expiresAt: { lt: new Date() } } });
|
||||
await prisma.trustedDevice.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: hashDeviceToken(token),
|
||||
device: describeUserAgent(userAgent),
|
||||
expiresAt: new Date(Date.now() + TRUSTED_DEVICE_TTL_MS),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** True when `token` is an unexpired trusted-device token belonging to this user. */
|
||||
async function isTrustedDevice(userId, token) {
|
||||
if (!token || typeof token !== 'string') return false;
|
||||
const row = await prisma.trustedDevice.findUnique({ where: { tokenHash: hashDeviceToken(token) } });
|
||||
if (!row || row.userId !== userId || row.expiresAt <= new Date()) return false;
|
||||
prisma.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Forgets every trusted device — call whenever credentials or sessions are reset. */
|
||||
async function clearTrustedDevices(userId) {
|
||||
try {
|
||||
await prisma.trustedDevice.deleteMany({ where: { userId } });
|
||||
} catch (e) {
|
||||
console.error('Failed to clear trusted devices:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
METHODS,
|
||||
REQUIRED_ROLES,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
mustEnrolTwoFactor,
|
||||
MAX_ATTEMPTS,
|
||||
generateCode,
|
||||
hashCode,
|
||||
hashDeviceToken,
|
||||
maskEmail,
|
||||
maskPhone,
|
||||
hasRealEmail,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
};
|
||||
@@ -149,6 +149,25 @@ function buildWALogin({ name, when, location, userAgent }) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Two-factor code ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildWATwoFactorCode({ name, code }) {
|
||||
const org = getOrg();
|
||||
return [
|
||||
`🔑 *Your verification code*`,
|
||||
'',
|
||||
`Hi ${name || 'there'},`,
|
||||
'',
|
||||
`Your *${org.name}* verification code is:`,
|
||||
'',
|
||||
`*${code}*`,
|
||||
'',
|
||||
`_It expires in 10 minutes. Never share this code with anyone._`,
|
||||
'',
|
||||
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Welcome ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildWAWelcome({ name, events }) {
|
||||
@@ -306,6 +325,7 @@ module.exports = {
|
||||
buildWADonationAppliedToRegistrant,
|
||||
buildWADonationUnassignedFromRegistrant,
|
||||
buildWALogin,
|
||||
buildWATwoFactorCode,
|
||||
buildWAWelcome,
|
||||
buildWAAccountClosed,
|
||||
buildWATicketCaption,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const { computeDonationRemaining } = require('../src/utils/donationUtils');
|
||||
|
||||
describe('computeDonationRemaining', () => {
|
||||
test('a donation with no legs has its full amount remaining', () => {
|
||||
expect(computeDonationRemaining(500, [])).toBe(500);
|
||||
});
|
||||
|
||||
test('an allocation leg reduces the remaining balance', () => {
|
||||
const legs = [{ amount: 200 }];
|
||||
expect(computeDonationRemaining(500, legs)).toBe(300);
|
||||
});
|
||||
|
||||
test('multiple allocation legs reduce the remaining balance cumulatively', () => {
|
||||
const legs = [{ amount: 200 }, { amount: 150 }];
|
||||
expect(computeDonationRemaining(500, legs)).toBe(150);
|
||||
});
|
||||
|
||||
test('the 1.4.2 regression: refunding the donation itself (a negative-amount leg) reduces remaining balance, not inflates it', () => {
|
||||
// 500 donation, never allocated, R200 of it refunded directly back to the donor
|
||||
// (a leg with amount: -200). That R200 is no longer available to allocate — remaining
|
||||
// must drop to 300. The pre-1.4.2 bug summed legs without Math.abs(), so
|
||||
// remaining = 500 - (-200) = 700 (inflated) instead of 500 - 200 = 300 (correct).
|
||||
const legs = [{ amount: -200 }];
|
||||
expect(computeDonationRemaining(500, legs)).toBe(300);
|
||||
});
|
||||
|
||||
test('an allocation and a separate direct refund both reduce the remaining balance', () => {
|
||||
// 500 donation: R200 allocated to a registration, R100 separately refunded to the donor.
|
||||
// Remaining = 500 - 200 - 100 = 200.
|
||||
const legs = [{ amount: 200 }, { amount: -100 }];
|
||||
expect(computeDonationRemaining(500, legs)).toBe(200);
|
||||
});
|
||||
|
||||
test('unassigning an allocation removes its leg entirely rather than adding an offsetting one', () => {
|
||||
// unassignDonationFromRegistration deletes the leg row outright (confirmed in
|
||||
// paymentController.js), so the "leg no longer exists" case — not a negative-amount
|
||||
// leg — is how an unassigned allocation becomes available again.
|
||||
const legsAfterUnassign = [];
|
||||
expect(computeDonationRemaining(500, legsAfterUnassign)).toBe(500);
|
||||
});
|
||||
|
||||
test('handles a null/undefined legs array', () => {
|
||||
expect(computeDonationRemaining(500, null)).toBe(500);
|
||||
expect(computeDonationRemaining(500, undefined)).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const {
|
||||
getEffectiveUnitPrice,
|
||||
computeOptionLineTotal,
|
||||
computeRegistrationTotalDue,
|
||||
attachComputedTotals,
|
||||
} = require('../src/utils/pricing');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const NOW = new Date('2026-06-01T00:00:00Z');
|
||||
const PAST = new Date(NOW.getTime() - DAY);
|
||||
const FUTURE = new Date(NOW.getTime() + DAY);
|
||||
|
||||
describe('getEffectiveUnitPrice', () => {
|
||||
test('returns base price when there are no early-bird tiers', () => {
|
||||
const option = { price: 100, earlyBirdTiers: [] };
|
||||
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
|
||||
});
|
||||
|
||||
test('returns tier price when the tier deadline is still in the future', () => {
|
||||
const option = {
|
||||
price: 100,
|
||||
earlyBirdTiers: [{ id: 't1', price: 50, deadline: FUTURE }],
|
||||
};
|
||||
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(50);
|
||||
});
|
||||
|
||||
test('falls back to base price once the tier deadline has passed', () => {
|
||||
const option = {
|
||||
price: 100,
|
||||
earlyBirdTiers: [{ id: 't1', price: 50, deadline: PAST }],
|
||||
};
|
||||
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
|
||||
});
|
||||
|
||||
test('requires the deadline to be after both referenceTime and atTime', () => {
|
||||
const midDeadline = new Date(NOW.getTime() - DAY / 2);
|
||||
const option = {
|
||||
price: 100,
|
||||
earlyBirdTiers: [{ id: 't1', price: 50, deadline: midDeadline }],
|
||||
};
|
||||
// referenceTime (PAST) is before the deadline, but atTime (NOW) is after it — tier no longer applies
|
||||
expect(getEffectiveUnitPrice(option, PAST, NOW)).toBe(100);
|
||||
});
|
||||
|
||||
test('with multiple applicable tiers, picks the one with the earliest deadline', () => {
|
||||
const soonerDeadline = new Date(NOW.getTime() + DAY);
|
||||
const laterDeadline = new Date(NOW.getTime() + 2 * DAY);
|
||||
const option = {
|
||||
price: 100,
|
||||
earlyBirdTiers: [
|
||||
{ id: 'later', price: 80, deadline: laterDeadline },
|
||||
{ id: 'sooner', price: 60, deadline: soonerDeadline },
|
||||
],
|
||||
};
|
||||
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(60);
|
||||
});
|
||||
|
||||
test('returns 0 for a missing eventOption', () => {
|
||||
expect(getEffectiveUnitPrice(null, null, NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeOptionLineTotal', () => {
|
||||
test('the 1.8.0 regression: buying more after an early-bird tier expires only re-prices the new quantity', () => {
|
||||
// 5 tickets bought at R50 (early-bird), then 1 more bought after the price rose to R100.
|
||||
// Must total 5*50 + 1*100 = 350, not 6*100 = 600.
|
||||
const ro = {
|
||||
quantity: 6,
|
||||
priceSnapshot: 100,
|
||||
tranches: [
|
||||
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
|
||||
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
|
||||
],
|
||||
};
|
||||
expect(computeOptionLineTotal(ro, null, NOW)).toBe(350);
|
||||
});
|
||||
|
||||
test('sums a single tranche correctly', () => {
|
||||
const ro = { quantity: 3, priceSnapshot: 40, tranches: [{ quantity: 3, priceSnapshot: 40 }] };
|
||||
expect(computeOptionLineTotal(ro, null, NOW)).toBe(120);
|
||||
});
|
||||
|
||||
test('legacy fallback: no tranches, uses priceSnapshot directly', () => {
|
||||
const ro = { quantity: 4, priceSnapshot: 25, tranches: [] };
|
||||
expect(computeOptionLineTotal(ro, null, NOW)).toBe(100);
|
||||
});
|
||||
|
||||
test('legacy fallback: no tranches and no priceSnapshot, re-evaluates from tier deadlines', () => {
|
||||
const ro = {
|
||||
quantity: 2,
|
||||
priceSnapshot: null,
|
||||
tranches: [],
|
||||
eventOption: { price: 100, earlyBirdTiers: [{ id: 't1', price: 70, deadline: FUTURE }] },
|
||||
};
|
||||
expect(computeOptionLineTotal(ro, null, NOW)).toBe(140);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRegistrationTotalDue', () => {
|
||||
test('sums tranche-aware totals across multiple RegistrationOptions', () => {
|
||||
const registration = {
|
||||
registrationOptions: [
|
||||
{
|
||||
quantity: 6,
|
||||
priceSnapshot: 100,
|
||||
tranches: [
|
||||
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
|
||||
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
|
||||
],
|
||||
},
|
||||
{
|
||||
quantity: 2,
|
||||
priceSnapshot: 20,
|
||||
tranches: [{ quantity: 2, priceSnapshot: 20, createdAt: PAST }],
|
||||
},
|
||||
],
|
||||
payments: [],
|
||||
};
|
||||
// 350 (first option, see 1.8.0 regression case) + 40 (second option)
|
||||
expect(computeRegistrationTotalDue(registration, NOW)).toBe(390);
|
||||
});
|
||||
|
||||
test('returns 0 for a registration with no options', () => {
|
||||
expect(computeRegistrationTotalDue({ registrationOptions: [] }, NOW)).toBe(0);
|
||||
expect(computeRegistrationTotalDue(null, NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachComputedTotals', () => {
|
||||
test('mutates the registration with per-line and total computed amounts', () => {
|
||||
const registration = {
|
||||
registrationOptions: [
|
||||
{ quantity: 2, priceSnapshot: 50, tranches: [{ quantity: 2, priceSnapshot: 50 }] },
|
||||
],
|
||||
payments: [],
|
||||
};
|
||||
const result = attachComputedTotals(registration);
|
||||
expect(result).toBe(registration); // mutated in place
|
||||
expect(result.registrationOptions[0].lineTotal).toBe(100);
|
||||
expect(result.totalDueComputed).toBe(100);
|
||||
});
|
||||
|
||||
test('handles a null registration gracefully', () => {
|
||||
expect(attachComputedTotals(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
|
||||
// The pure helpers under test never touch the database.
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
const {
|
||||
generateCode,
|
||||
hashCode,
|
||||
hashDeviceToken,
|
||||
maskEmail,
|
||||
maskPhone,
|
||||
hasRealEmail,
|
||||
} = require('../src/utils/twoFactor');
|
||||
|
||||
describe('generateCode', () => {
|
||||
test('always returns exactly six digits', () => {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
expect(generateCode()).toMatch(/^\d{6}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashCode / hashDeviceToken', () => {
|
||||
test('is deterministic and never returns the input', () => {
|
||||
expect(hashCode('123456')).toBe(hashCode('123456'));
|
||||
expect(hashCode('123456')).not.toContain('123456');
|
||||
expect(hashCode('123456')).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('different codes hash differently', () => {
|
||||
expect(hashCode('123456')).not.toBe(hashCode('123457'));
|
||||
});
|
||||
|
||||
test('code and device-token hashes are namespaced apart', () => {
|
||||
expect(hashCode('123456')).not.toBe(hashDeviceToken('123456'));
|
||||
});
|
||||
|
||||
test('depends on JWT_SECRET', () => {
|
||||
const before = hashCode('123456');
|
||||
process.env.JWT_SECRET = 'another-secret';
|
||||
expect(hashCode('123456')).not.toBe(before);
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
});
|
||||
});
|
||||
|
||||
describe('masking', () => {
|
||||
test('maskEmail keeps the first letter and the domain', () => {
|
||||
expect(maskEmail('jane@example.com')).toBe('j•••@example.com');
|
||||
});
|
||||
|
||||
test('maskEmail copes with junk input', () => {
|
||||
expect(maskEmail('')).toBe('•••');
|
||||
expect(maskEmail(null)).toBe('•••');
|
||||
});
|
||||
|
||||
test('maskPhone keeps only the last four digits', () => {
|
||||
expect(maskPhone('27821234567')).toBe('••• ••• 4567');
|
||||
expect(maskPhone('+27 82 123 4567')).toBe('••• ••• 4567');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasRealEmail', () => {
|
||||
test('accepts a normal address', () => {
|
||||
expect(hasRealEmail({ email: 'jane@example.com' })).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects guest and deleted placeholders and missing values', () => {
|
||||
expect(hasRealEmail({ email: 'abc@guest.local' })).toBe(false);
|
||||
expect(hasRealEmail({ email: 'deleted-1@deleted.invalid' })).toBe(false);
|
||||
expect(hasRealEmail({ email: 'deleted-1@deleted.local' })).toBe(false);
|
||||
expect(hasRealEmail({})).toBe(false);
|
||||
expect(hasRealEmail(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// ─── In-memory stand-in for the two Prisma models the 2FA helpers use ─────────
|
||||
// Supports just the `where` shapes those helpers issue: equality, null, { gt }, { lt }.
|
||||
function matches(row, where = {}) {
|
||||
return Object.entries(where).every(([key, cond]) => {
|
||||
const value = row[key];
|
||||
if (cond && typeof cond === 'object' && !(cond instanceof Date)) {
|
||||
if ('gt' in cond) return value > cond.gt;
|
||||
if ('lt' in cond) return value < cond.lt;
|
||||
}
|
||||
if (cond instanceof Date) return value instanceof Date && value.getTime() === cond.getTime();
|
||||
return value === cond;
|
||||
});
|
||||
}
|
||||
|
||||
function applyData(row, data) {
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
row[key] = val && typeof val === 'object' && 'increment' in val ? row[key] + val.increment : val;
|
||||
}
|
||||
}
|
||||
|
||||
function makeModel(defaults, relations = {}) {
|
||||
let rows = [];
|
||||
let seq = 0;
|
||||
const withRelations = (row, include) => {
|
||||
if (!row) return null;
|
||||
const out = { ...row };
|
||||
for (const key of Object.keys(include || {})) out[key] = relations[key](row);
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
_rows: () => rows,
|
||||
_reset: () => { rows = []; },
|
||||
create: async ({ data }) => {
|
||||
const row = { id: `id-${++seq}`, ...defaults(), ...data };
|
||||
rows.push(row);
|
||||
return { ...row };
|
||||
},
|
||||
findUnique: async ({ where, include }) => withRelations(rows.find(r => matches(r, where)), include),
|
||||
update: async ({ where, data }) => {
|
||||
const row = rows.find(r => matches(r, where));
|
||||
applyData(row, data);
|
||||
return { ...row };
|
||||
},
|
||||
updateMany: async ({ where, data }) => {
|
||||
const hit = rows.filter(r => matches(r, where));
|
||||
hit.forEach(r => applyData(r, data));
|
||||
return { count: hit.length };
|
||||
},
|
||||
deleteMany: async ({ where }) => {
|
||||
const before = rows.length;
|
||||
rows = rows.filter(r => !matches(r, where));
|
||||
return { count: before - rows.length };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const USER = { id: 'user-1', name: 'Jane', email: 'jane@example.com', phoneNumber: '27821234567' };
|
||||
|
||||
const mockDb = {
|
||||
twoFactorChallenge: makeModel(
|
||||
() => ({ attempts: 0, usedAt: null, lastSentAt: new Date(), createdAt: new Date() }),
|
||||
{ user: () => USER },
|
||||
),
|
||||
trustedDevice: makeModel(() => ({ createdAt: new Date(), lastUsedAt: new Date() })),
|
||||
};
|
||||
|
||||
const mockSent = { emails: [], whatsapps: [] };
|
||||
const mockWhatsApp = { configured: true, fail: false };
|
||||
|
||||
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}` }));
|
||||
jest.mock('../src/utils/whatsapp', () => ({
|
||||
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
|
||||
isConfigured: async () => mockWhatsApp.configured,
|
||||
sendText: async (to, message) => {
|
||||
if (mockWhatsApp.fail) throw new Error('session disconnected');
|
||||
mockSent.whatsapps.push({ to, message });
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
MAX_ATTEMPTS,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
} = require('../src/utils/twoFactor');
|
||||
|
||||
const lastEmailCode = () => mockSent.emails[mockSent.emails.length - 1].text.split(':')[1];
|
||||
const lastWhatsAppCode = () => mockSent.whatsapps[mockSent.whatsapps.length - 1].message.split(':')[1];
|
||||
const wrongCode = (code) => (code === '000000' ? '111111' : '000000');
|
||||
|
||||
beforeEach(() => {
|
||||
mockDb.twoFactorChallenge._reset();
|
||||
mockDb.trustedDevice._reset();
|
||||
mockSent.emails = [];
|
||||
mockSent.whatsapps = [];
|
||||
mockWhatsApp.configured = true;
|
||||
mockWhatsApp.fail = false;
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
describe('availableMethods', () => {
|
||||
test('offers WhatsApp only with a valid phone and a configured gateway', async () => {
|
||||
expect(await availableMethods(USER)).toEqual(['email', 'whatsapp']);
|
||||
mockWhatsApp.configured = false;
|
||||
expect(await availableMethods(USER)).toEqual(['email']);
|
||||
mockWhatsApp.configured = true;
|
||||
expect(await availableMethods({ ...USER, phoneNumber: null })).toEqual(['email']);
|
||||
});
|
||||
|
||||
test('offers nothing by email for a guest placeholder address', async () => {
|
||||
expect(await availableMethods({ ...USER, email: 'x@guest.local' })).toEqual(['whatsapp']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChallenge', () => {
|
||||
test('emails the code and stores only its hash', async () => {
|
||||
const res = await createChallenge(USER, 'login', 'email');
|
||||
expect(res).toMatchObject({ method: 'email', destination: 'j•••@example.com' });
|
||||
const [row] = mockDb.twoFactorChallenge._rows();
|
||||
expect(row.id).toBe(res.challengeId);
|
||||
expect(row.codeHash).not.toContain(lastEmailCode());
|
||||
expect(mockSent.emails[0].to).toBe(USER.email);
|
||||
});
|
||||
|
||||
test('sends by WhatsApp when asked', async () => {
|
||||
const res = await createChallenge(USER, 'login', 'whatsapp');
|
||||
expect(res).toMatchObject({ method: 'whatsapp', destination: '••• ••• 4567' });
|
||||
expect(mockSent.whatsapps).toHaveLength(1);
|
||||
expect(mockSent.emails).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('falls back to email when WhatsApp fails or is not configured', async () => {
|
||||
mockWhatsApp.fail = true;
|
||||
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
|
||||
mockWhatsApp.fail = false;
|
||||
mockWhatsApp.configured = false;
|
||||
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
|
||||
expect(mockSent.emails).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('does not fall back when allowFallback is false, and records no challenge', async () => {
|
||||
mockWhatsApp.fail = true;
|
||||
await expect(createChallenge(USER, 'enable', 'whatsapp', { allowFallback: false })).rejects.toMatchObject({ statusCode: 503 });
|
||||
expect(mockSent.emails).toHaveLength(0);
|
||||
expect(mockDb.twoFactorChallenge._rows()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a new challenge replaces the previous one for the same purpose', async () => {
|
||||
const first = await createChallenge(USER, 'login', 'email');
|
||||
const firstCode = lastEmailCode();
|
||||
await createChallenge(USER, 'login', 'email');
|
||||
await expect(verifyChallenge(first.challengeId, firstCode, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyChallenge', () => {
|
||||
test('accepts the right code exactly once', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const code = lastEmailCode();
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
expect(challenge.userId).toBe(USER.id);
|
||||
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
|
||||
test('tolerates spaces in the submitted code', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const code = lastEmailCode();
|
||||
await expect(verifyChallenge(challengeId, ` ${code.slice(0, 3)} ${code.slice(3)} `, 'login')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('locks the challenge after the maximum wrong attempts, even for the right code', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const code = lastEmailCode();
|
||||
for (let i = 1; i <= MAX_ATTEMPTS; i++) {
|
||||
await expect(verifyChallenge(challengeId, wrongCode(code), 'login')).rejects.toThrow(
|
||||
i < MAX_ATTEMPTS ? /Incorrect code/ : /Too many incorrect attempts/
|
||||
);
|
||||
}
|
||||
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
|
||||
test('rejects an expired code', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
mockDb.twoFactorChallenge._rows()[0].expiresAt = new Date(Date.now() - 1000);
|
||||
await expect(verifyChallenge(challengeId, lastEmailCode(), 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
|
||||
test('rejects a code used for the wrong purpose or by another user', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'enable', 'email');
|
||||
const code = lastEmailCode();
|
||||
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
await expect(verifyChallenge(challengeId, code, 'enable', 'someone-else')).rejects.toThrow(/invalid or has expired/);
|
||||
await expect(verifyChallenge(challengeId, code, 'enable', USER.id)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('rejects missing or malformed input', async () => {
|
||||
await expect(verifyChallenge(undefined, '123456', 'login')).rejects.toThrow();
|
||||
await expect(verifyChallenge({ not: 'a string' }, '123456', 'login')).rejects.toThrow();
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
await expect(verifyChallenge(challengeId, undefined, 'login')).rejects.toThrow(/Incorrect code/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resendChallenge', () => {
|
||||
test('refuses inside the cooldown', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
await expect(resendChallenge(challengeId, 'login')).rejects.toMatchObject({ statusCode: 429 });
|
||||
expect(mockSent.emails).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('after the cooldown, sends a new code that replaces the old one and resets attempts', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const oldCode = lastEmailCode();
|
||||
await expect(verifyChallenge(challengeId, wrongCode(oldCode), 'login')).rejects.toThrow();
|
||||
mockDb.twoFactorChallenge._rows()[0].lastSentAt = new Date(Date.now() - 61 * 1000);
|
||||
|
||||
await resendChallenge(challengeId, 'login');
|
||||
const newCode = lastEmailCode();
|
||||
expect(mockSent.emails).toHaveLength(2);
|
||||
expect(mockDb.twoFactorChallenge._rows()[0].attempts).toBe(0);
|
||||
if (newCode !== oldCode) {
|
||||
await expect(verifyChallenge(challengeId, oldCode, 'login')).rejects.toThrow(/Incorrect code/);
|
||||
}
|
||||
await expect(verifyChallenge(challengeId, newCode, 'login')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('refuses once the challenge is too old or already used', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const row = mockDb.twoFactorChallenge._rows()[0];
|
||||
row.lastSentAt = new Date(Date.now() - 61 * 1000);
|
||||
row.createdAt = new Date(Date.now() - 31 * 60 * 1000);
|
||||
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
|
||||
|
||||
row.createdAt = new Date();
|
||||
row.usedAt = new Date();
|
||||
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trusted devices', () => {
|
||||
test('a freshly issued token is trusted for its own user only', async () => {
|
||||
const token = await issueTrustedDevice(USER.id, 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36');
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(mockDb.trustedDevice._rows()[0].tokenHash).not.toBe(token);
|
||||
expect(await isTrustedDevice(USER.id, token)).toBe(true);
|
||||
expect(await isTrustedDevice('someone-else', token)).toBe(false);
|
||||
});
|
||||
|
||||
test('unknown, malformed and expired tokens are not trusted', async () => {
|
||||
const token = await issueTrustedDevice(USER.id, '');
|
||||
expect(await isTrustedDevice(USER.id, 'nope')).toBe(false);
|
||||
expect(await isTrustedDevice(USER.id, undefined)).toBe(false);
|
||||
expect(await isTrustedDevice(USER.id, { tokenHash: 'x' })).toBe(false);
|
||||
mockDb.trustedDevice._rows()[0].expiresAt = new Date(Date.now() - 1000);
|
||||
expect(await isTrustedDevice(USER.id, token)).toBe(false);
|
||||
});
|
||||
|
||||
test('clearTrustedDevices forgets every device for the user', async () => {
|
||||
const a = await issueTrustedDevice(USER.id, '');
|
||||
const b = await issueTrustedDevice(USER.id, '');
|
||||
await clearTrustedDevices(USER.id);
|
||||
expect(await isTrustedDevice(USER.id, a)).toBe(false);
|
||||
expect(await isTrustedDevice(USER.id, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
@@ -12,4 +13,8 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
// A no-op wrap when SENTRY_DSN isn't configured for this deployment — safe in
|
||||
// every environment (dev, or a fresh deploy that hasn't set up Sentry yet).
|
||||
export default process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
? withSentryConfig(nextConfig, { silent: true, disableLogger: true })
|
||||
: nextConfig;
|
||||
|
||||
Generated
+3956
-1735
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.9.4",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -27,12 +27,13 @@
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@sentry/nextjs": "^10.71.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "15.4.5",
|
||||
"next": "^15.5.24",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.8.1",
|
||||
@@ -51,9 +52,18 @@
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.5",
|
||||
"eslint-config-next": "^15.5.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "3.4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"next": {
|
||||
"postcss": "^8.5.23"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"@sentry/cli@2.58.6": true,
|
||||
"unrs-resolver@1.12.2": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { History } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
|
||||
const ACTIONS = [
|
||||
"refund_created",
|
||||
"donation_assigned",
|
||||
"donation_unassigned",
|
||||
"registration_created_manual",
|
||||
"registration_cancelled",
|
||||
"event_created",
|
||||
"event_updated",
|
||||
"event_deleted",
|
||||
"settings_updated",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
refund_created: "Refund created",
|
||||
donation_assigned: "Donation assigned",
|
||||
donation_unassigned: "Donation unassigned",
|
||||
registration_created_manual: "Manual registration created",
|
||||
registration_cancelled: "Registration cancelled (staff)",
|
||||
event_created: "Event created",
|
||||
event_updated: "Event updated",
|
||||
event_deleted: "Event deactivated",
|
||||
settings_updated: "Settings updated",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
actorRole: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ip: string | null;
|
||||
createdAt: string;
|
||||
actor: { id: string; name: string; email: string } | null;
|
||||
}
|
||||
|
||||
function formatMetadata(metadata: Record<string, unknown> | null): string {
|
||||
if (!metadata) return "";
|
||||
try {
|
||||
return Object.entries(metadata)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
||||
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}`)
|
||||
.join(" · ");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminAuditLogPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 50;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const [actionFilter, setActionFilter] = useState<string>("");
|
||||
const [fromFilter, setFromFilter] = useState<string>("");
|
||||
const [toFilter, setToFilter] = useState<string>("");
|
||||
|
||||
const buildQuery = useCallback((p: number) => {
|
||||
const qs = new URLSearchParams({ page: String(p), limit: String(pageSize) });
|
||||
if (actionFilter) qs.set("action", actionFilter);
|
||||
if (fromFilter) qs.set("from", fromFilter);
|
||||
if (toFilter) qs.set("to", toFilter);
|
||||
return `/api/admin/audit-log?${qs.toString()}`;
|
||||
}, [actionFilter, fromFilter, toFilter]);
|
||||
|
||||
const load = useCallback(async (p = 1) => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await apiFetch<{ rows: AuditLogEntry[]; total: number; page: number }>(buildQuery(p), { authToken: token });
|
||||
setEntries(res?.rows || []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load audit log");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
|
||||
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full 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">
|
||||
<History className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Admin Audit Log</h1>
|
||||
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} recorded</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need admin access to view the audit log.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Action</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
|
||||
<option value="">All actions</option>
|
||||
{ACTIONS.map(a => <option key={a} value={a}>{ACTION_LABELS[a]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">From</label>
|
||||
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">To</label>
|
||||
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} onChange={e => setToFilter(e.target.value)} />
|
||||
</div>
|
||||
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => load(page)} disabled={fetching}>
|
||||
{fetching ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Actor</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(e => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="whitespace-nowrap text-gray-600">{new Date(e.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
{e.actor ? (
|
||||
<>
|
||||
<div className="font-medium">{e.actor.name}</div>
|
||||
<div className="text-xs text-gray-500">{e.actor.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">Deleted user</span>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 capitalize">{e.actorRole}</div>
|
||||
</TableCell>
|
||||
<TableCell>{ACTION_LABELS[e.action] || e.action}</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">{e.targetType}{e.targetId ? ` #${e.targetId.slice(0, 8)}` : ""}</TableCell>
|
||||
<TableCell className="text-xs text-gray-500 max-w-[280px] truncate" title={formatMetadata(e.metadata)}>{formatMetadata(e.metadata)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{entries.length === 0 && !fetching && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-gray-500">No matching audit entries.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{fetching && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-gray-400">Loading…</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 text-sm">
|
||||
<span className="text-gray-500">Page {page} of {totalPages}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||
disabled={page <= 1 || fetching}
|
||||
onClick={() => load(page - 1)}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||
disabled={page >= totalPages || fetching}
|
||||
onClick={() => load(page + 1)}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -33,6 +33,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
|
||||
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
|
||||
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
|
||||
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
|
||||
] as const;
|
||||
|
||||
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
||||
|
||||
@@ -6,13 +6,14 @@ 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, type LucideIcon } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, 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 { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "backups";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -21,6 +22,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: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
@@ -662,6 +664,9 @@ function SiteSettingsPageInner() {
|
||||
|
||||
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
||||
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
</div>
|
||||
|
||||
{activeTab === "branding" && (
|
||||
|
||||
@@ -15,6 +15,7 @@ interface UserItem {
|
||||
role: string;
|
||||
phoneNumber?: string | null;
|
||||
notificationPreference?: string;
|
||||
twoFactorEnabled?: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -186,6 +187,21 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reset two-factor authentication");
|
||||
}
|
||||
};
|
||||
|
||||
const deactivate = async (id: string) => {
|
||||
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
|
||||
try {
|
||||
@@ -352,6 +368,9 @@ export default function AdminUsersPage() {
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -6,16 +6,21 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isValidZAPhone } from "@/lib/phone";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { User, Lock, ShieldAlert, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
|
||||
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
|
||||
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
|
||||
|
||||
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset"; device: string | null; createdAt: string };
|
||||
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
|
||||
|
||||
const ACTIVITY_META: Record<ActivityEvent["type"], { label: (device: string | null) => string; icon: typeof LogIn }> = {
|
||||
login: { label: d => `Logged in${d ? ` from ${d}` : ""}`, icon: LogIn },
|
||||
password_changed: { label: () => "Password changed", icon: KeyRound },
|
||||
password_reset: { label: () => "Password reset via email link", icon: RotateCcw },
|
||||
two_factor_enabled: { label: () => "Two-factor authentication turned on", icon: ShieldCheck },
|
||||
two_factor_disabled: { label: () => "Two-factor authentication turned off", icon: ShieldOff },
|
||||
};
|
||||
|
||||
const TWO_FACTOR_METHOD_LABEL: Record<TwoFactorMethod, string> = { email: "email", whatsapp: "WhatsApp" };
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { user, token, logout, updateToken } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -152,6 +157,105 @@ export default function UserProfilePage() {
|
||||
|
||||
useEffect(() => { loadActivity(); }, [token]);
|
||||
|
||||
// ── Two-factor authentication ─────────────────────────────────────────────
|
||||
// Mirrors the saved account state locally: the auth context's `user` is only
|
||||
// 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" | "change">("idle");
|
||||
const [tfPassword, setTfPassword] = useState("");
|
||||
const [tfCode, setTfCode] = useState("");
|
||||
const [tfChallenge, setTfChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
const [tfMsg, setTfMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [tfBusy, setTfBusy] = useState(false);
|
||||
|
||||
// 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) {
|
||||
setTfEnabled(!!user.twoFactorEnabled);
|
||||
setTfMethod(user.twoFactorMethod || "email");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const resetTwoFactorForm = () => {
|
||||
setTfStep("idle");
|
||||
setTfPassword("");
|
||||
setTfCode("");
|
||||
setTfChallenge(null);
|
||||
};
|
||||
|
||||
const startEnableTwoFactor = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
setTfBusy(true);
|
||||
setTfMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<TwoFactorChallenge>("/api/users/2fa/enable", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { method: tfMethod, password: tfPassword },
|
||||
});
|
||||
setTfChallenge(res);
|
||||
setTfCode("");
|
||||
setTfPassword("");
|
||||
setTfStep("code");
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to send a verification code." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmEnableTwoFactor = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token || !tfChallenge) return;
|
||||
setTfBusy(true);
|
||||
setTfMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<{ twoFactorMethod: TwoFactorMethod }>("/api/users/2fa/confirm", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { challengeId: tfChallenge.challengeId, code: tfCode },
|
||||
});
|
||||
const wasEnabled = tfEnabled;
|
||||
setTfEnabled(true);
|
||||
setTfMethod(res.twoFactorMethod);
|
||||
resetTwoFactorForm();
|
||||
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." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableTwoFactor = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
setTfBusy(true);
|
||||
setTfMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/users/2fa/disable", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { password: tfPassword },
|
||||
});
|
||||
setTfEnabled(false);
|
||||
resetTwoFactorForm();
|
||||
setTfMsg({ type: "ok", text: "Two-factor authentication is off." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to turn off two-factor authentication." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Account closure ───────────────────────────────────────────────────────
|
||||
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
|
||||
const [deleteData, setDeleteData] = useState(false);
|
||||
@@ -299,6 +403,113 @@ export default function UserProfilePage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Two-factor authentication ────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={ShieldCheck} title="Two-factor authentication" />
|
||||
|
||||
{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]}. {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>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
className={`${inputCls} tracking-widest`}
|
||||
value={tfCode}
|
||||
onChange={e => setTfCode(e.target.value.replace(/\D/g, ""))}
|
||||
required
|
||||
/>
|
||||
</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 || tfCode.length !== 6} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{tfBusy ? "Verifying…" : "Turn on two-factor"}
|
||||
</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">
|
||||
{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>
|
||||
<select className={inputCls} value={tfMethod} onChange={e => setTfMethod(e.target.value as TwoFactorMethod)}>
|
||||
<option value="email">Email</option>
|
||||
{canWhatsAppCodes && <option value="whatsapp">WhatsApp</option>}
|
||||
</select>
|
||||
{!canWhatsAppCodes && (
|
||||
<p className="mt-1 text-xs text-gray-500">Save a valid phone number on your profile to receive codes by WhatsApp.</p>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
{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} />}
|
||||
</section>
|
||||
|
||||
{/* ── Security ─────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={ShieldAlert} title="Security" tone="amber" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Share2, QrCode } from "lucide-react";
|
||||
import { Share2, QrCode, CalendarPlus } from "lucide-react";
|
||||
import QRCode from "qrcode";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
export default function ClientActions({ event }: { event: any }) {
|
||||
return (
|
||||
@@ -43,6 +44,14 @@ export default function ClientActions({ event }: { event: any }) {
|
||||
<QrCode className="w-4 h-4" />
|
||||
Save QR
|
||||
</button>
|
||||
<a
|
||||
href={`${API_BASE}/api/events/${event.id}/ics`}
|
||||
download
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4" />
|
||||
Add to calendar
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
@@ -41,7 +42,7 @@ type Event = {
|
||||
location?: string | null;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
|
||||
@@ -105,17 +106,49 @@ function RegisterCta({ event }: { event: Event }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let event: Event;
|
||||
// Next dedupes an identical fetch (same URL + cache options) made during the same
|
||||
// request, so calling this again from the page component below is free.
|
||||
async function loadEvent(id: string): Promise<Event | null> {
|
||||
try {
|
||||
event = await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
|
||||
return await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
|
||||
} catch (e) {
|
||||
// The event endpoint 404s for missing, inactive, or not-yet-live events —
|
||||
// render the standard not-found page instead of crashing.
|
||||
if (e instanceof ApiError && e.status === 404) notFound();
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const event = await loadEvent(id);
|
||||
if (!event) return {};
|
||||
|
||||
const description = event.description
|
||||
? event.description.slice(0, 200)
|
||||
: `${event.title} — ${formatDateTimeRange(event.startDate, event.endDate)}`;
|
||||
const imageUrl = event.picture ? resolveToApiOrigin(event.picture) : null;
|
||||
|
||||
return {
|
||||
title: event.title,
|
||||
description,
|
||||
openGraph: {
|
||||
title: event.title,
|
||||
description,
|
||||
type: "website",
|
||||
...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: event.title,
|
||||
description,
|
||||
...(imageUrl ? { images: [imageUrl] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const event = await loadEvent(id);
|
||||
if (!event) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Metadata } from "next";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiFetchListForPrerender } from "@/lib/api";
|
||||
import { Calendar, CalendarX } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "All Events",
|
||||
description: "Browse and register for upcoming events.",
|
||||
};
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -21,11 +27,11 @@ type Event = {
|
||||
};
|
||||
|
||||
export default async function EventsPage() {
|
||||
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
|
||||
const events = await apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
|
||||
const now = Date.now();
|
||||
const upcoming = (events || []).filter(e => {
|
||||
if (e.cashupStatus === "closed") return false;
|
||||
const t = new Date(e.startDate).getTime();
|
||||
const t = new Date(e.endDate || e.startDate).getTime();
|
||||
return !isNaN(t) && t > now;
|
||||
});
|
||||
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SetupGuard } from "@/components/shared/SetupGuard";
|
||||
import HelpFab from "@/components/shared/HelpFab";
|
||||
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { buildThemeCssVars } from "@/lib/colorScale";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -32,6 +33,9 @@ const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
|
||||
* to fail the whole page render over.
|
||||
*/
|
||||
async function getServerSettings(): Promise<SiteSettings> {
|
||||
// No API base configured: a relative fetch never settles during prerender and
|
||||
// would time out every page, so skip straight to the defaults.
|
||||
if (!API_BASE) return {};
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return {};
|
||||
@@ -44,14 +48,30 @@ async function getServerSettings(): Promise<SiteSettings> {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getServerSettings();
|
||||
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
|
||||
const logoUrl = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
|
||||
const displayName = settings.org_name || appName;
|
||||
const description = settings.org_tagline || `Manage and register for events with ${displayName}`;
|
||||
|
||||
return {
|
||||
title: displayName,
|
||||
description: `Manage and register for events with ${displayName}`,
|
||||
metadataBase: new URL(appUrl),
|
||||
title: { default: displayName, template: `%s | ${displayName}` },
|
||||
description,
|
||||
icons: {
|
||||
icon: faviconUrl || "/favicon.ico",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: displayName,
|
||||
title: { default: displayName, template: `%s | ${displayName}` },
|
||||
description,
|
||||
...(logoUrl ? { images: [{ url: logoUrl }] } : {}),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: displayName,
|
||||
description,
|
||||
...(logoUrl ? { images: [logoUrl] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ type Event = {
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiFetchListForPrerender } from "@/lib/api";
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: CalendarCheck, title: "Meaningful Events", description: "Events designed to inspire, encourage and strengthen your faith." },
|
||||
@@ -28,6 +28,7 @@ const FEATURES = [
|
||||
];
|
||||
|
||||
async function getOrgName(): Promise<string> {
|
||||
if (!API_BASE) return appName;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return appName;
|
||||
@@ -40,12 +41,12 @@ async function getOrgName(): Promise<string> {
|
||||
|
||||
export default async function HomePage() {
|
||||
const [events, displayName] = await Promise.all([
|
||||
apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
|
||||
apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
|
||||
getOrgName(),
|
||||
]);
|
||||
const now = Date.now();
|
||||
const upcoming = (events || []).filter(e => {
|
||||
const t = new Date(e.startDate).getTime();
|
||||
const t = new Date(e.endDate || e.startDate).getTime();
|
||||
return !isNaN(t) && t > now;
|
||||
});
|
||||
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const base = appUrl.replace(/\/$/, "");
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/dashboard", "/self-service", "/set-banner", "/lockdown-rules"],
|
||||
},
|
||||
sitemap: `${base}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -133,6 +134,9 @@ export default function SelfServicePage() {
|
||||
const [setupLoading, setSetupLoading] = useState(false);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
||||
// Set while a supervisor with two-factor on still has to enter their code.
|
||||
const [setupChallenge, setSetupChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
const [setupCode, setSetupCode] = useState("");
|
||||
const [events, setEvents] = useState<KioskEvent[]>([]);
|
||||
const [selectedEventId, setSelectedEventId] = useState("");
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
@@ -277,16 +281,32 @@ export default function SelfServicePage() {
|
||||
setSetupError(null);
|
||||
setSetupLoading(true);
|
||||
try {
|
||||
const data = await apiFetch("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email: setupEmail, password: setupPassword },
|
||||
});
|
||||
// Second pass for accounts with two-factor on: exchange the code that was
|
||||
// sent after the password step. The kiosk never asks to be remembered.
|
||||
const data = setupChallenge
|
||||
? await apiFetch("/api/users/login/2fa", {
|
||||
method: "POST",
|
||||
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
|
||||
})
|
||||
: await apiFetch("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email: setupEmail, password: setupPassword },
|
||||
});
|
||||
if (isTwoFactorChallenge(data)) {
|
||||
setSetupChallenge(data);
|
||||
setSetupCode("");
|
||||
return;
|
||||
}
|
||||
const role: string = data.role || "";
|
||||
if (!["admin", "supervisor"].includes(role)) {
|
||||
setSetupError("Only admins and supervisors can set up the kiosk.");
|
||||
setSetupChallenge(null);
|
||||
setSetupCode("");
|
||||
setSetupLoading(false);
|
||||
return;
|
||||
}
|
||||
setSetupChallenge(null);
|
||||
setSetupCode("");
|
||||
setSupervisorToken(data.token);
|
||||
await loadEvents(data.token);
|
||||
} catch (err: any) {
|
||||
@@ -316,18 +336,16 @@ export default function SelfServicePage() {
|
||||
setChangeError(null);
|
||||
setChangeLoading(true);
|
||||
try {
|
||||
const data = await apiFetch("/api/users/login", {
|
||||
// Re-confirm the password against the supervisor session the kiosk already
|
||||
// holds, rather than logging in again — a fresh login would send a new
|
||||
// two-factor code every time the event is changed.
|
||||
if (!supervisorToken) throw new Error("Your session has expired. Reload the kiosk and sign in again.");
|
||||
await apiFetch("/api/users/verify-password", {
|
||||
method: "POST",
|
||||
body: { email: setupEmail, password: changePassword },
|
||||
authToken: supervisorToken,
|
||||
body: { password: changePassword },
|
||||
});
|
||||
const role: string = data.role || "";
|
||||
if (!["admin", "supervisor"].includes(role)) {
|
||||
setChangeError("Only admins and supervisors can change the event.");
|
||||
setChangeLoading(false);
|
||||
return;
|
||||
}
|
||||
setSupervisorToken(data.token);
|
||||
await loadEvents(data.token);
|
||||
await loadEvents(supervisorToken);
|
||||
setChangePassword("");
|
||||
setChangeError(null);
|
||||
setShowChangeModal(false);
|
||||
@@ -701,13 +719,41 @@ export default function SelfServicePage() {
|
||||
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
{setupChallenge && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={setupCode}
|
||||
onChange={(e) => setSetupCode(e.target.value.replace(/\D/g, ""))}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base tracking-widest focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="6-digit code"
|
||||
autoFocus
|
||||
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"
|
||||
className="text-brand-600 hover:underline"
|
||||
onClick={() => { setSetupChallenge(null); setSetupCode(""); setSetupError(null); }}
|
||||
>
|
||||
Start again
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={setupLoading}
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-60 transition mt-2"
|
||||
>
|
||||
{setupLoading ? "Signing in…" : "Sign In"}
|
||||
{setupLoading ? "Signing in…" : setupChallenge ? "Verify" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
|
||||
type Event = { id: string; updatedAt?: string; startDate: string };
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const base = appUrl.replace(/\/$/, "");
|
||||
|
||||
const staticEntries: MetadataRoute.Sitemap = [
|
||||
{ url: `${base}/`, changeFrequency: "daily", priority: 1 },
|
||||
{ url: `${base}/events`, changeFrequency: "daily", priority: 0.9 },
|
||||
{ url: `${base}/contact`, changeFrequency: "monthly", priority: 0.5 },
|
||||
];
|
||||
|
||||
let events: Event[] = [];
|
||||
try {
|
||||
events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate } } });
|
||||
} catch {
|
||||
// Backend unreachable at build/revalidate time — ship the static entries only,
|
||||
// same degrade-gracefully posture as layout.tsx's getServerSettings.
|
||||
}
|
||||
|
||||
const eventEntries: MetadataRoute.Sitemap = (events || []).map((e) => ({
|
||||
url: `${base}/events/${e.id}`,
|
||||
lastModified: e.updatedAt ? new Date(e.updatedAt) : undefined,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...staticEntries, ...eventEntries];
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { DatabaseBackup, Download } from "lucide-react";
|
||||
|
||||
interface BackupEntry {
|
||||
filename: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function BackupsTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [backups, setBackups] = useState<BackupEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const [retainCount, setRetainCount] = useState("14");
|
||||
const [savingRetain, setSavingRetain] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [list, allSettings] = await Promise.all([
|
||||
apiFetch<BackupEntry[]>("/api/backups", { authToken: token }),
|
||||
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token }),
|
||||
]);
|
||||
setBackups(list || []);
|
||||
setRetainCount(allSettings?.backup_retain_count || "14");
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to load backups" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const saveRetainCount = async () => {
|
||||
if (!token) return;
|
||||
setSavingRetain(true);
|
||||
try {
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: { backup_retain_count: retainCount } });
|
||||
setMessage({ type: "ok", text: "Retention setting saved." });
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to save retention setting" });
|
||||
} finally {
|
||||
setSavingRetain(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const runBackup = async () => {
|
||||
if (!token) return;
|
||||
setRunning(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch<{ filename: string }>("/api/backups/run", { method: "POST", authToken: token });
|
||||
setMessage({ type: "ok", text: `Backup created: ${res.filename}` });
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Backup failed" });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<DatabaseBackup className="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Database backups</h2>
|
||||
<p className="text-xs text-gray-500">Nightly automatic backups, stored locally on this server. Not uploaded anywhere else.</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>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={runBackup}
|
||||
disabled={running}
|
||||
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{running ? "Running…" : "Run backup now"}
|
||||
</button>
|
||||
|
||||
<div className="flex items-end gap-2 pt-2 border-t">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Keep the most recent</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-24 border rounded-lg px-3 py-1.5 text-sm"
|
||||
value={retainCount}
|
||||
onChange={(e) => setRetainCount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 pb-1.5">backups, delete the rest</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveRetainCount}
|
||||
disabled={savingRetain}
|
||||
className="ml-auto px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
{savingRetain ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden mt-2">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-600 border-b bg-gray-50">
|
||||
<th className="p-2.5">Created</th>
|
||||
<th className="p-2.5">Size</th>
|
||||
<th className="p-2.5">Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backups.map((b) => (
|
||||
<tr key={b.filename} className="border-t">
|
||||
<td className="p-2.5">{new Date(b.createdAt).toLocaleString()}</td>
|
||||
<td className="p-2.5 text-gray-500">{formatSize(b.size)}</td>
|
||||
<td className="p-2.5">
|
||||
<a
|
||||
href={`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`}
|
||||
className="inline-flex items-center gap-1.5 text-brand-600 hover:underline"
|
||||
onClick={(e) => {
|
||||
// authenticated download: fetch as blob rather than a bare link,
|
||||
// since this route requires an admin bearer token
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
fetch(`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = b.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(() => setMessage({ type: "err", text: "Download failed" }));
|
||||
}}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{backups.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td className="p-3 text-gray-500" colSpan={3}>No backups yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className="p-3 text-gray-400" colSpan={3}>Loading…</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
// Matches the backend's minimum gap between code sends.
|
||||
const RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
export function LoginForm() {
|
||||
const { login } = useAuth();
|
||||
const { login, verifyTwoFactor, resendTwoFactor } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const raw = searchParams.get("redirect") || "";
|
||||
const hasExplicitRedirect = raw.startsWith("/") && !raw.startsWith("//");
|
||||
@@ -19,6 +23,25 @@ export function LoginForm() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Two-factor step — set once the password is accepted for an account with 2FA on.
|
||||
const [challenge, setChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [rememberDevice, setRememberDevice] = useState(false);
|
||||
const [resendIn, setResendIn] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [resendIn]);
|
||||
|
||||
// Skip the generic /dashboard hop (which just redirects again once the role is known)
|
||||
// and go straight to the role-specific dashboard, unless the caller asked for a specific
|
||||
// page back (e.g. returning to a registration flow after login).
|
||||
const goToDashboard = (loggedInUser: AuthUser) => {
|
||||
window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
|
||||
};
|
||||
|
||||
const getErrorMessage = (err: any) => {
|
||||
if (!err) return "Login failed";
|
||||
|
||||
@@ -45,12 +68,15 @@ export function LoginForm() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const loggedInUser = await login(email, password);
|
||||
// Skip the generic /dashboard hop (which just redirects again once the role is known)
|
||||
// and go straight to the role-specific dashboard, unless the caller asked for a specific
|
||||
// page back (e.g. returning to a registration flow after login).
|
||||
const target = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
|
||||
window.location.href = target;
|
||||
const result = await login(email, password);
|
||||
if (isTwoFactorChallenge(result)) {
|
||||
// Password was right, but the account has two-factor on — show the code step.
|
||||
setChallenge(result);
|
||||
setCode("");
|
||||
setResendIn(RESEND_COOLDOWN_SECONDS);
|
||||
return;
|
||||
}
|
||||
goToDashboard(result);
|
||||
} catch (err: any) {
|
||||
setError(getErrorMessage(err) || "Login failed. Please try again.");
|
||||
} finally {
|
||||
@@ -58,8 +84,116 @@ export function LoginForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!challenge) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
goToDashboard(await verifyTwoFactor(challenge.challengeId, code, rememberDevice));
|
||||
} catch (err: any) {
|
||||
setError(getErrorMessage(err) || "Verification failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onResend = async () => {
|
||||
if (!challenge || resendIn > 0) return;
|
||||
setError(null);
|
||||
try {
|
||||
// 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) {
|
||||
setError(getErrorMessage(err) || "Couldn't send a new code. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const backToPassword = () => {
|
||||
setChallenge(null);
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (challenge) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Verification code</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||
className="w-full border rounded px-3 py-2 text-lg tracking-widest"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={rememberDevice}
|
||||
onChange={(e) => setRememberDevice(e.target.checked)}
|
||||
/>
|
||||
<span>Trust this device for 30 days. Only tick this on a device that's yours.</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length !== 6}
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white rounded py-2 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Verifying..." : "Verify and log in"}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<button type="button" onClick={backToPassword} className="text-brand-600 hover:underline">
|
||||
Back to login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResend}
|
||||
disabled={resendIn > 0}
|
||||
className="text-brand-600 hover:underline disabled:text-gray-400 disabled:no-underline"
|
||||
>
|
||||
{resendIn > 0 ? `Resend code in ${resendIn}s` : "Resend code"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -27,6 +27,12 @@ export const authHelpContent: HelpContent = {
|
||||
<GuideItem icon={LogIn} title="Already registered as a guest?" tone="green">
|
||||
If you registered as a guest before, you can still create a full account later using the same email — your past registrations will be there once you're logged in.
|
||||
</GuideItem>
|
||||
<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>
|
||||
),
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { AuthResponse, AuthUser, clearToken, getToken, saveToken } from "@/lib/auth";
|
||||
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, isTwoFactorChallenge, saveDeviceToken, saveToken } from "@/lib/auth";
|
||||
|
||||
export type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<AuthUser>;
|
||||
login: (email: string, password: string) => Promise<AuthUser | (TwoFactorChallenge & { twoFactorRequired: true })>;
|
||||
verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise<AuthUser>;
|
||||
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
|
||||
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateToken: (token: string) => void;
|
||||
@@ -49,18 +51,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await apiFetch<AuthResponse>("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
});
|
||||
const { token, ...loggedInUser } = res;
|
||||
const startSession = useCallback((res: AuthResponse) => {
|
||||
const { token, deviceToken, ...loggedInUser } = res;
|
||||
if (deviceToken) saveDeviceToken(deviceToken);
|
||||
saveToken(token);
|
||||
setToken(token);
|
||||
setUser(loggedInUser);
|
||||
return loggedInUser;
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email, password, deviceToken: getDeviceToken() || undefined },
|
||||
});
|
||||
// 2FA accounts get a challenge back instead of a token — nothing is saved
|
||||
// until verifyTwoFactor exchanges the emailed/WhatsApp code for one.
|
||||
if (isTwoFactorChallenge(res)) return res;
|
||||
return startSession(res as AuthResponse);
|
||||
}, [startSession]);
|
||||
|
||||
const verifyTwoFactor = useCallback(async (challengeId: string, code: string, rememberDevice: boolean) => {
|
||||
const res = await apiFetch<AuthResponse>("/api/users/login/2fa", {
|
||||
method: "POST",
|
||||
body: { challengeId, code, rememberDevice },
|
||||
});
|
||||
return startSession(res);
|
||||
}, [startSession]);
|
||||
|
||||
const resendTwoFactor = useCallback((challengeId: string) => {
|
||||
return apiFetch<TwoFactorChallenge>("/api/users/login/2fa/resend", {
|
||||
method: "POST",
|
||||
body: { challengeId },
|
||||
});
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => {
|
||||
const res = await apiFetch<AuthResponse>("/api/users", {
|
||||
method: "POST",
|
||||
@@ -87,8 +112,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, token, loading, login, register, logout, updateToken }),
|
||||
[loading, login, logout, register, token, updateToken, user]
|
||||
() => ({ user, token, loading, login, verifyTwoFactor, resendTwoFactor, register, logout, updateToken }),
|
||||
[loading, login, logout, register, resendTwoFactor, token, updateToken, user, verifyTwoFactor]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// A no-op when NEXT_PUBLIC_SENTRY_DSN isn't set, so this is safe in every environment
|
||||
// (dev, or a fresh deploy that hasn't configured Sentry yet). The dynamic import (rather
|
||||
// than a top-level `import * as Sentry`) keeps the Sentry client SDK out of every visitor's
|
||||
// bundle entirely when it's unconfigured — Next inlines NEXT_PUBLIC_* at build time, so an
|
||||
// unset DSN lets the bundler dead-code-eliminate this whole block, import included.
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
import("@sentry/nextjs").then((Sentry) => {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
// Small single-VM deployment, not high-traffic — start conservative and raise
|
||||
// this once real usage is visible in Sentry, rather than sampling every request.
|
||||
tracesSampleRate: 0.1,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Next.js server instrumentation entry point. A no-op when NEXT_PUBLIC_SENTRY_DSN isn't
|
||||
// set — see instrumentation-client.ts for the browser-side counterpart.
|
||||
//
|
||||
// Node runtime only, deliberately — this app's middleware.ts (which runs in Next's edge
|
||||
// runtime) is a trivial pass-through with no real error surface, so an edge-runtime branch
|
||||
// here would only inflate that shared middleware bundle for no actual coverage benefit.
|
||||
export async function register() {
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
const Sentry = await import("@sentry/nextjs");
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
tracesSampleRate: 0.1,
|
||||
});
|
||||
}
|
||||
@@ -84,6 +84,13 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
|
||||
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
|
||||
|
||||
// On the server a relative URL has no origin to resolve against, and Next's
|
||||
// patched fetch never settles on one during prerender (the build then times out
|
||||
// every page). Fail fast instead so callers can degrade.
|
||||
if (typeof window === "undefined" && !url.startsWith("http")) {
|
||||
throw new Error(`API base URL is not configured (set NEXT_PUBLIC_API_URL or API_URL); cannot fetch ${path}`);
|
||||
}
|
||||
|
||||
const mergedHeaders: Record<string, string> = { ...headers };
|
||||
if (!(body instanceof FormData)) {
|
||||
mergedHeaders["Content-Type"] = "application/json";
|
||||
@@ -110,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") || "";
|
||||
@@ -120,6 +136,20 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
return res.text();
|
||||
}
|
||||
|
||||
// For server components that prerender a public list: during `next build` the
|
||||
// backend is often not running (or no API URL is set), so fall back to an empty
|
||||
// list rather than failing the build — the page fills in on its first revalidate.
|
||||
// At runtime the error still propagates, so ISR keeps serving the last good page
|
||||
// instead of caching an empty one.
|
||||
export async function apiFetchListForPrerender<T>(path: string, options: ApiOptions = {}): Promise<T[]> {
|
||||
try {
|
||||
return (await apiFetch<T[]>(path, options)) || [];
|
||||
} catch (err) {
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Several list endpoints (/api/users, /api/payments) are paginated and cap
|
||||
// `limit` at 200 server-side, so a single request can silently miss records
|
||||
// beyond the first page. This walks every page and returns the full list.
|
||||
|
||||
@@ -5,13 +5,47 @@ export type AuthUser = {
|
||||
role?: "admin" | "supervisor" | "staff" | "user";
|
||||
phoneNumber?: string | null;
|
||||
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 & {
|
||||
token: string;
|
||||
// Only present when the user ticked "Trust this device" on the 2FA step.
|
||||
deviceToken?: string;
|
||||
};
|
||||
|
||||
export type TwoFactorMethod = "email" | "whatsapp";
|
||||
|
||||
// What the login endpoint returns instead of a token when the account has 2FA on:
|
||||
// a code was sent to `destination` (masked) and must be exchanged for the token.
|
||||
export type TwoFactorChallenge = {
|
||||
twoFactorRequired?: true;
|
||||
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 } {
|
||||
return !!res && typeof res === "object" && (res as TwoFactorChallenge).twoFactorRequired === true;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "hope_events_token";
|
||||
// Remembers this browser after a 2FA code so the next login can skip it. Kept on
|
||||
// logout on purpose — it identifies the device, not the session.
|
||||
const DEVICE_KEY = "hope_events_device";
|
||||
|
||||
export function saveDeviceToken(token: string) {
|
||||
if (typeof window !== "undefined") localStorage.setItem(DEVICE_KEY, token);
|
||||
}
|
||||
export function getDeviceToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(DEVICE_KEY);
|
||||
}
|
||||
|
||||
export function saveToken(token: string) {
|
||||
if (typeof window !== "undefined") localStorage.setItem(TOKEN_KEY, token);
|
||||
|
||||
@@ -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
+9
-915
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.9.4",
|
||||
"version": "1.12.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
@@ -18,7 +18,7 @@
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"express-rate-limit": "^8.3.1"
|
||||
"overrides": {
|
||||
"shell-quote": "^1.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user