Compare commits

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:50 +02:00
29 changed files with 894 additions and 36 deletions
+16
View File
@@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.25.0] - 2026-10-20
### Added
- A **Yoco** tab in Site Settings for online payments:
- **Secret key:** shown masked like a card number (`sk_test_***…aba`) with a Test/Live badge. Yoco checks a new key before it is saved.
- **Webhook secret:** **Generate** registers this server's webhook URL (`BACKEND_URL/api/webhooks/yoco`, editable) with Yoco and saves the secret Yoco returns. **Enter manually** takes the secret of a webhook you registered yourself.
- **Registered webhooks:** every webhook on the Yoco account, with its URL and mode. It shows which one is active and warns when a URL isn't this server. Each can be deleted; deleting the active one also removes its stored secret.
- New admin endpoints under `/api/yoco` (config and webhooks). Changes are written to the admin audit log, without the secret values.
### Changed
- The Yoco secret key and webhook secret are now stored in the database, AES-256-GCM encrypted, instead of only in `backend/.env`. `YOCO_SECRET_KEY` and `YOCO_WEBHOOK_SECRET` still work as fallbacks when the database has no value. `npm run migrate:env-to-db` moves them across.
- Yoco secrets are never returned by `/api/settings/all` and can't be changed through `PUT /api/settings`.
- Starting a checkout or sponsorship without a Yoco key now gives a clear "online payments are not configured" message instead of failing at Yoco.
## [1.24.0] - 2026-10-19
### Changed
+3 -1
View File
@@ -164,7 +164,8 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
## Payments, donations & refunds
- **Online card payments via Yoco** — checkout for the full outstanding balance,
confirmed by a signed webhook.
confirmed by a signed webhook. Keys and the webhook are set up from
Site Settings → Yoco.
- **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
@@ -456,6 +457,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
| Yoco | Secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
| Backups | List, run now, download database backups |
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
+1 -2
View File
@@ -59,8 +59,6 @@ Documentation: see Frontend README for environment and scripts.
- DATABASE_URL
- JWT_SECRET
- PORT (optional; defaults to 3000 if not set)
- YOCO_SECRET_KEY (for payments)
- YOCO_WEBHOOK_SECRET (to verify webhooks)
- EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM (for ticket emails)
- FRONTEND_URL or APP_BASE_URL (used in email links)
@@ -69,6 +67,7 @@ Documentation: see Frontend README for environment and scripts.
Example for default backend: NEXT_PUBLIC_API_URL=http://localhost:5000
- Organisation name, logo, brand colour, and notification emails are configured via the admin panel (Admin → Site Settings) and do not need to be set in .env files.
- Yoco payments are set up in Admin → Site Settings → Yoco: paste the secret key, then generate the webhook (or enter its secret). YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in backend/.env still work as fallbacks.
### 3) Run the apps
+10
View File
@@ -57,6 +57,16 @@ or Branding tab has been saved even once — the wizard always writes
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
## Payments (Yoco)
Managed in the Yoco tab (through `/api/yoco`, not the generic settings endpoints).
| Setting | Default when unset | Notes |
|---|---|---|
| `yoco_secret_key` | `YOCO_SECRET_KEY` env var, else *(blank)* | Stored AES-256-GCM encrypted. If blank, online checkouts and sponsorships fail with "online payments are not configured". |
| `yoco_webhook_secret` | `YOCO_WEBHOOK_SECRET` env var, else *(blank)* | Stored encrypted. If blank, Yoco webhooks are rejected, so payments aren't confirmed automatically. |
| `yoco_webhook_id` | *(blank)* | The Yoco webhook whose secret is stored, set when it's generated from the tab. Used to mark it "Active" and to clear the secret when it's deleted. |
## Legal (Terms of Use / Privacy Policy)
| Setting | Default when unset | Notes |
+6 -3
View File
@@ -33,9 +33,11 @@ FRONTEND_URL=http://localhost:3000
# EMAIL_PASS=your_email_password
# EMAIL_FROM=no-reply@example.com
# ─── Payments (Yoco) ──────────────────────────────────────────────────────────
YOCO_SECRET_KEY=your_yoco_secret_key
YOCO_WEBHOOK_SECRET=your_yoco_webhook_secret
# ─── Payments (Yoco) — optional fallbacks ─────────────────────────────────────
# Preferred: set the secret key and generate the webhook in Admin → Site Settings →
# Yoco (stored AES-256 encrypted). These are only used if the database has no value.
# YOCO_SECRET_KEY=sk_test_...
# YOCO_WEBHOOK_SECRET=whsec_...
# ─── Public URLs ──────────────────────────────────────────────────────────────
# Used to build links in emails (password reset, activation, etc.)
@@ -72,6 +74,7 @@ BACKUP_ENABLED=true
# - Registration notification emails
# - SMTP settings (username and password stored AES-256-GCM encrypted, key
# derived from JWT_SECRET — do not change JWT_SECRET after saving SMTP creds)
# - Yoco secret key and webhook secret (encrypted the same way)
# - Legal page content (operator name, Information Officer, website URL, etc.)
# The first-time setup wizard handles initial configuration on fresh deployments.
# You no longer need ORG_NAME, ORG_TAGLINE, EMAIL_HEADER_COLOR, REGISTRATIONS_EMAIL,
+13 -1
View File
@@ -706,7 +706,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
## Settings
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, Yoco secrets) are stored AES-256-GCM encrypted. Yoco secrets are never returned by `/api/settings/all` and can't be written through `PUT /api/settings`; use `/api/yoco` below.
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
@@ -718,6 +718,18 @@ For what each individual setting key defaults to when it's never been saved, see
| PUT | `/api/settings` | admin | Upsert settings; `smtp_user`/`smtp_pass` encrypted before storage; sending `••••••••` for `smtp_pass` keeps existing value |
| POST | `/api/settings/test-smtp` | admin | Test SMTP with provided credentials; sends real test email to admin on success. The admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
## Yoco
Managed from Admin → Site Settings → Yoco. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/yoco/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/api/yoco/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/api/yoco/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/api/yoco/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/api/yoco/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
### POST /api/settings/test-smtp
```json
{
+17 -4
View File
@@ -79,8 +79,8 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `JWT_SECRET` | Yes | Secret key for JWTs (32+ random characters). Also used to derive the AES-256 encryption key for sensitive settings (SMTP credentials) stored in the DB — must be set before saving SMTP settings. |
| `FRONTEND_URL` | Yes | Comma-separated allowed CORS origins |
| `YOCO_SECRET_KEY` | Yes | Yoco secret API key |
| `YOCO_WEBHOOK_SECRET` | Yes | Yoco webhook HMAC secret |
| `YOCO_SECRET_KEY` | — | Fallback Yoco secret API key (preferred: set via Admin → Site Settings → Yoco) |
| `YOCO_WEBHOOK_SECRET` | — | Fallback Yoco webhook secret (preferred: generate or enter it via Admin → Site Settings → Yoco) |
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
| `PORT` | — | Port to listen on (default `3000`) |
@@ -99,7 +99,9 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
> **Organisation name, branding colour, logo, registration notification email, SMTP settings, and legal page content** are now managed through the admin panel at **Admin → Site Settings** and stored in the database. You no longer need `ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, or SMTP vars in `.env` (they still work as fallbacks). On a fresh deployment these are set via the first-time setup wizard.
>
> **SMTP credentials** (`smtp_user`, `smtp_pass`) are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving SMTP credentials — the stored values will become unreadable.
> **SMTP credentials** (`smtp_user`, `smtp_pass`) and the **Yoco secret key and webhook secret** are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving them — the stored values will become unreadable.
>
> To move existing Yoco values from `.env` into the database, run `npm run migrate:env-to-db` (or save them in the Yoco tab).
---
@@ -333,6 +335,15 @@ Full interactive API docs available at `GET /docs` (requires admin JWT — pass
| PUT | `/` | admin | Upsert settings; `smtp_user` and `smtp_pass` are AES-256-GCM encrypted before storage; sending `••••••••` for `smtp_pass` is a no-op (keeps existing value) |
| POST | `/test-smtp` | admin | Test SMTP connection with provided credentials; success returns `{ message }`, failure returns `{ message, raw }` where `message` is human-readable and `raw` is the original SMTP error. The short-lived admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
#### Yoco — `/api/yoco`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
#### Setup — `/api/setup`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -500,6 +511,8 @@ The resolution priority at registration/payment time is: **variant tier → vari
## Payments (Yoco)
Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with Yoco before saving), then **Generate** a webhook — this registers `BACKEND_URL/api/webhooks/yoco` with Yoco and stores the secret Yoco returns (it's only shown once). Or **Enter** the secret of a webhook you registered yourself. The tab also lists the account's webhooks and can delete them.
1. Frontend calls `POST /api/payments/yoco-checkout`
2. Backend calls `refreshPricingForRegistration` — if any early-bird price changed, returns `{ priceUpdated: true, newTotal }` (HTTP 200) and does **not** create a checkout. Frontend shows warning; user retries.
3. On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns `{ redirectUrl }`.
@@ -524,6 +537,6 @@ pm2 save && pm2 startup
- [ ] `JWT_SECRET` is long (32+ chars) and secret
- [ ] `FRONTEND_URL` lists only production origins
- [ ] `NODE_ENV=production`
- [ ] Yoco webhook registered and HTTPS
- [ ] Yoco secret key saved and webhook generated (Site Settings → Yoco), over HTTPS
- [ ] WAWP credentials saved via admin panel
- [ ] SMTP credentials correct
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.24.0",
"version": "1.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.24.0",
"version": "1.25.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^5.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.24.0",
"version": "1.25.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,12 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_config_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_registered';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_deleted';
+3
View File
@@ -505,6 +505,9 @@ enum AdminAuditAction {
ticket_transferred
impersonation_started
impersonation_ended
yoco_config_updated
yoco_webhook_registered
yoco_webhook_deleted
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
+5 -1
View File
@@ -52,7 +52,7 @@ function encrypt(plaintext) {
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
}
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
// ─── Mapping: env var(s) → DB key ────────────────────────────────────────────
// Each entry: { key: DB key, envVars: [...candidates in priority order], encrypt?: bool }
@@ -79,6 +79,10 @@ const MAPPINGS = [
{ key: 'smtp_user', envVars: ['SMTP_USER', 'EMAIL_USER'], encrypt: true },
{ key: 'smtp_pass', envVars: ['SMTP_PASS', 'EMAIL_PASS'], encrypt: true },
// Yoco payments
{ key: 'yoco_secret_key', envVars: ['YOCO_SECRET_KEY'], encrypt: true },
{ key: 'yoco_webhook_secret', envVars: ['YOCO_WEBHOOK_SECRET'], encrypt: true },
// App URLs (used in email templates)
{ key: 'app_base_url', envVars: ['FRONTEND_URL', 'APP_BASE_URL'] },
];
+5 -4
View File
@@ -6,6 +6,7 @@ const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
@@ -987,7 +988,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } }
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
await prisma.registration.update({
@@ -1090,7 +1091,7 @@ const createYocoCheckout = async (req, res) => {
lineItems.push({ displayName: 'Partial Payment', quantity: 1, pricingDetails: { price: -Math.round((remainingAmount - charge) * 100) } });
}
const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems };
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } });
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
}
@@ -1151,7 +1152,7 @@ const createYocoCheckout = async (req, res) => {
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } }
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
return res.status(200).json({
@@ -1163,7 +1164,7 @@ const createYocoCheckout = async (req, res) => {
}
} catch (error) {
console.error('Yoco checkout error:', error.response?.data || error.message);
res.status(error.response?.status || 400).json({
res.status(error.statusCode || error.response?.status || 400).json({
message: error.response?.data?.message || error.message
});
}
@@ -8,6 +8,10 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
// Yoco secrets are only readable (masked) and writable through /api/yoco — never
// through the generic settings endpoints.
const YOCO_PRIVATE_KEYS = new Set(['yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id']);
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
@@ -44,6 +48,7 @@ const getAllSettings = async (req, res) => {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
if (YOCO_PRIVATE_KEYS.has(r.key)) continue;
if (ENCRYPTED_KEYS.has(r.key)) {
// Return a sentinel so the UI knows the value is set, without exposing it.
// smtp_user (email address) we can safely return as-is after decryption so
@@ -79,7 +84,7 @@ const updateSettings = async (req, res) => {
}
const ops = Object.entries(updates)
.filter(([, v]) => v !== undefined && v !== null)
.filter(([k, v]) => v !== undefined && v !== null && !YOCO_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
@@ -118,7 +123,7 @@ const updateSettings = async (req, res) => {
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !YOCO_PRIVATE_KEYS.has(k)) },
ip: getClientIp(req),
});
}
@@ -195,7 +200,7 @@ const runSetup = async (req, res) => {
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !YOCO_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
@@ -1,4 +1,5 @@
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
@@ -87,7 +88,7 @@ const createSponsorCheckout = async (req, res) => {
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
};
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
headers: { 'Content-Type': 'application/json', Authorization: await yocoAuthHeader(), 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
});
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
} catch (error) {
+4 -3
View File
@@ -4,6 +4,7 @@ const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { getYocoWebhookSecret } = require('../utils/yoco');
// @desc Handle Yoco webhook events
// @route POST /api/webhooks/yoco
@@ -54,10 +55,10 @@ const handleYocoWebhook = async (req, res) => {
// Construct the signed content
const signedContent = `${id}.${timestamp}.${requestBody}`;
// Get the webhook secret from environment variables
const secret = process.env.YOCO_WEBHOOK_SECRET;
// Webhook secret from Site Settings → Yoco (falls back to .env)
const secret = await getYocoWebhookSecret();
if (!secret) {
console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables');
console.error('Yoco webhook secret is not configured (Site Settings → Yoco, or YOCO_WEBHOOK_SECRET)');
const responseObj = {
success: false,
message: 'Server configuration error',
+149
View File
@@ -0,0 +1,149 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { encrypt } = require('../utils/encryption');
const { invalidate: invalidateSettingsCache, warmCache, getSetting } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
} = require('../utils/yoco');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// Write settings (null/'' clears), then refresh the cache so the new values apply immediately.
async function saveSettings(values) {
const ops = Object.entries(values).map(([key, value]) => {
const stored = value ? (key === KEY_WEBHOOK_ID ? value : encrypt(value)) : '';
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
});
await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache();
}
const audit = (req, action, metadata) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'AppSetting', metadata, ip: getClientIp(req),
});
// @desc Yoco configuration with secrets masked
// @route GET /api/yoco/config
// @access Admin
const getConfig = async (req, res) => {
try {
const [secretKey, webhookSecret, webhookId, source] = await Promise.all([
getYocoSecretKey(), getYocoWebhookSecret(), getSetting(KEY_WEBHOOK_ID), getYocoConfigSource(),
]);
res.json({
secretKeyMasked: maskSecret(secretKey),
secretKeySource: source.secretKey,
mode: yocoMode(secretKey),
webhookSecretMasked: maskSecret(webhookSecret),
webhookSecretSource: source.webhookSecret,
webhookId: webhookId || '',
webhookUrl: defaultWebhookUrl(req),
configured: !!(secretKey && webhookSecret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Save the secret key and/or webhook secret ('' clears)
// @route PUT /api/yoco/config
// @access Admin
const updateConfig = async (req, res) => {
try {
const { secretKey, webhookSecret } = req.body || {};
const updates = {};
if (typeof secretKey === 'string') {
const key = secretKey.trim();
if (key && !/^sk_(test|live)_\S+$/.test(key)) {
res.status(400); throw new Error('The secret key should start with sk_test_ or sk_live_.');
}
// Check Yoco accepts the key before replacing a working one.
if (key) await listWebhooks(key);
updates[KEY_SECRET] = key;
}
if (typeof webhookSecret === 'string') {
const secret = webhookSecret.trim();
if (secret && !/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) {
res.status(400); throw new Error('The webhook secret should start with whsec_.');
}
updates[KEY_WEBHOOK] = secret;
// A typed-in secret isn't tied to a webhook we registered.
updates[KEY_WEBHOOK_ID] = '';
}
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
await saveSettings(updates);
audit(req, 'yoco_config_updated', { changedKeys: Object.keys(updates) });
res.json({ message: 'Yoco settings saved' });
} catch (e) {
fail(res, e);
}
};
// @desc Webhooks registered on the Yoco account
// @route GET /api/yoco/webhooks
// @access Admin
const getWebhooks = async (req, res) => {
try {
const [hooks, activeId] = await Promise.all([listWebhooks(), getSetting(KEY_WEBHOOK_ID)]);
const here = defaultWebhookUrl(req);
res.json({
webhooks: hooks.map(h => ({ ...h, isActive: !!activeId && h.id === activeId, pointsHere: !!here && h.url === here })),
defaultUrl: here,
});
} catch (e) {
fail(res, e);
}
};
// @desc Register a webhook at Yoco and store the secret it returns
// @route POST /api/yoco/webhooks
// @access Admin
const createWebhook = async (req, res) => {
try {
const name = String(req.body?.name || '').trim() || 'hope-events';
const url = String(req.body?.url || '').trim() || defaultWebhookUrl(req);
const problem = webhookUrlProblem(url);
if (problem) { res.status(400); throw new Error(problem); }
const hook = await registerWebhook(name, url);
if (!hook?.secret) throw new Error('Yoco did not return a webhook secret.');
await saveSettings({ [KEY_WEBHOOK]: hook.secret, [KEY_WEBHOOK_ID]: hook.id });
audit(req, 'yoco_webhook_registered', { webhookId: hook.id, url: hook.url, mode: hook.mode });
res.status(201).json({
message: 'Webhook registered and its secret saved',
webhook: { id: hook.id, name: hook.name, url: hook.url, mode: hook.mode },
webhookSecretMasked: maskSecret(hook.secret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Delete a webhook at Yoco (clears the stored secret if it was the active one)
// @route DELETE /api/yoco/webhooks/:id
// @access Admin
const removeWebhook = async (req, res) => {
try {
const { id } = req.params;
await deleteWebhook(id);
const wasActive = (await getSetting(KEY_WEBHOOK_ID)) === id;
if (wasActive) await saveSettings({ [KEY_WEBHOOK]: '', [KEY_WEBHOOK_ID]: '' });
audit(req, 'yoco_webhook_deleted', { webhookId: id, clearedSecret: wasActive });
res.json({ message: wasActive ? 'Webhook deleted and its secret removed' : 'Webhook deleted', clearedSecret: wasActive });
} catch (e) {
fail(res, e);
}
};
module.exports = { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook };
+17
View File
@@ -149,6 +149,7 @@ const cashupRoutes = require('./routes/cashupRoutes');
const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
const yocoRoutes = require('./routes/yocoRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
@@ -188,6 +189,7 @@ app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
app.use('/api/feedback', require('./routes/feedbackRoutes'));
app.use('/api/analytics', require('./routes/analyticsRoutes'));
app.use('/api/backups', backupRoutes);
app.use('/api/yoco', yocoRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
@@ -1045,6 +1047,21 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
]},
{ title: 'Yoco', base: '/api/yoco', endpoints: [
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
{ method:'PUT', path:'/api/yoco/config', auth:'admin', desc:'Save the secret key and/or webhook secret (stored AES-256-GCM encrypted). A new secret key is checked against Yoco first. An empty string clears the value (the .env fallback then applies).',
request:{ body:{ secretKey:'sk_test_...', webhookSecret:'whsec_...' }},
responses:[{ status:200, desc:'Saved', body:{ message:'Yoco settings saved' }}]},
{ method:'GET', path:'/api/yoco/webhooks', auth:'admin', desc:'Webhooks registered on the Yoco account, flagged isActive (its secret is the stored one) and pointsHere (URL is this server).',
responses:[{ status:200, desc:'Success', body:{ webhooks:[{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test', isActive:true, pointsHere:true }], defaultUrl:'https://api.example.com/api/webhooks/yoco' }}]},
{ method:'POST', path:'/api/yoco/webhooks', auth:'admin', desc:'Register a webhook at Yoco (defaults: name "hope-events", URL = BACKEND_URL/api/webhooks/yoco) and store the secret Yoco returns. The URL must be public https.',
request:{ body:{ name:'hope-events', url:'https://api.example.com/api/webhooks/yoco' }},
responses:[{ status:201, desc:'Registered', body:{ message:'Webhook registered and its secret saved', webhook:{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test' }, webhookSecretMasked:'whsec_*****************1Xz' }}]},
{ method:'DELETE', path:'/api/yoco/webhooks/:id', auth:'admin', desc:'Delete a webhook at Yoco. If it was the active one, the stored webhook secret is cleared too.',
responses:[{ status:200, desc:'Deleted', body:{ message:'Webhook deleted', clearedSecret:false }}]},
]},
{ title: 'Settings', base: '/api/settings', endpoints: [
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook } = require('../controllers/yocoController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
router.get('/webhooks', protect, admin, getWebhooks);
router.post('/webhooks', protect, admin, createWebhook);
router.delete('/webhooks/:id', protect, admin, removeWebhook);
module.exports = router;
+2 -2
View File
@@ -4,14 +4,14 @@
* take effect without a server restart.
* Falls back to environment variables when a key is not found in the DB.
*
* Encrypted keys (smtp_user, smtp_pass) are automatically decrypted on read.
* Encrypted keys (SMTP credentials, Yoco secrets) are automatically decrypted on read.
*/
const prisma = require('../config/db');
const { decrypt, isEncrypted } = require('./encryption');
// Keys stored encrypted in the DB — decrypted transparently on read.
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
let _cache = {};
let _lastFetch = 0;
+134
View File
@@ -0,0 +1,134 @@
/**
* Yoco configuration and API helpers.
*
* The secret key and webhook secret are stored AES-256-GCM encrypted in AppSetting
* (yoco_secret_key, yoco_webhook_secret) and managed from Admin → Site Settings → Yoco.
* YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in .env are only used as fallbacks when the
* database has no value, so older deployments keep working until an admin saves them.
*/
const axios = require('axios');
const prisma = require('../config/db');
const { getSetting } = require('./settingsCache');
const YOCO_API = 'https://payments.yoco.com/api';
const KEY_SECRET = 'yoco_secret_key';
const KEY_WEBHOOK = 'yoco_webhook_secret';
const KEY_WEBHOOK_ID = 'yoco_webhook_id';
const getYocoSecretKey = () => getSetting(KEY_SECRET, process.env.YOCO_SECRET_KEY);
const getYocoWebhookSecret = () => getSetting(KEY_WEBHOOK, process.env.YOCO_WEBHOOK_SECRET);
/** Where each value currently comes from: 'db' | 'env' | 'none'. */
async function getYocoConfigSource() {
const rows = await prisma.appSetting.findMany({ where: { key: { in: [KEY_SECRET, KEY_WEBHOOK] } } });
const inDb = new Set(rows.filter(r => r.value).map(r => r.key));
const source = (key, envVar) => (inDb.has(key) ? 'db' : process.env[envVar] ? 'env' : 'none');
return {
secretKey: source(KEY_SECRET, 'YOCO_SECRET_KEY'),
webhookSecret: source(KEY_WEBHOOK, 'YOCO_WEBHOOK_SECRET'),
};
}
const KNOWN_PREFIXES = ['sk_test_', 'sk_live_', 'whsec_'];
/**
* Mask a secret like a card number: keep the prefix and the last 3 characters.
* maskSecret('sk_test_abcdefghijklmnaba') → 'sk_test_**************aba'
*/
function maskSecret(value) {
if (!value) return '';
const prefix = KNOWN_PREFIXES.find(p => value.startsWith(p)) || value.slice(0, 4);
const rest = value.slice(prefix.length);
if (rest.length <= 6) return prefix + '*'.repeat(rest.length);
return prefix + '*'.repeat(rest.length - 3) + rest.slice(-3);
}
function yocoMode(key) {
if (!key) return 'unknown';
if (key.startsWith('sk_test_')) return 'test';
if (key.startsWith('sk_live_')) return 'live';
return 'unknown';
}
function notConfiguredError() {
const err = new Error('Online payments are not configured. An admin needs to add the Yoco secret key in Site Settings → Yoco.');
err.statusCode = 503;
return err;
}
/** `Bearer <key>` for Yoco API calls; throws a 503-style error when no key is set. */
async function yocoAuthHeader() {
const key = await getYocoSecretKey();
if (!key) throw notConfiguredError();
return `Bearer ${key}`;
}
/** Turn a Yoco API error into a message an admin can act on. */
function friendlyYocoError(e) {
const status = e.response?.status;
const detail = e.response?.data?.message || e.response?.data?.description || e.response?.data?.errorMessage;
let message;
if (status === 401) message = 'Yoco rejected the secret key. Check that it was copied correctly.';
else if (status === 403) message = 'This Yoco account is not allowed to manage webhooks.';
else if (status === 400) message = `Yoco rejected the request${detail ? `: ${detail}` : '.'}`;
else if (status) message = `Yoco returned an error (${status})${detail ? `: ${detail}` : '.'}`;
else message = e.message || 'Could not reach Yoco.';
const err = new Error(message);
err.statusCode = e.statusCode || (status === 401 || status === 403 || status === 400 ? 400 : 502);
return err;
}
/** Call the Yoco API. `key` overrides the stored key (used to verify a key before saving it). */
async function yocoRequest(method, path, body, key) {
const auth = key ? `Bearer ${key}` : await yocoAuthHeader();
try {
const res = await axios({
method,
url: `${YOCO_API}${path}`,
data: body,
headers: { 'Content-Type': 'application/json', Authorization: auth },
timeout: 15000,
});
return res.data;
} catch (e) {
throw friendlyYocoError(e);
}
}
async function listWebhooks(key) {
const data = await yocoRequest('get', '/webhooks', undefined, key);
return data?.subscriptions || [];
}
const registerWebhook = (name, url) => yocoRequest('post', '/webhooks', { name, url });
const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComponent(id)}`);
/**
* The URL Yoco should post events to: BACKEND_URL, or the public origin of the
* current request (correct behind a proxy when TRUST_PROXY is set).
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
function webhookUrlProblem(url) {
let u;
try { u = new URL(url); } catch { return 'Enter a full webhook URL, e.g. https://api.example.com/api/webhooks/yoco'; }
if (u.protocol !== 'https:') return 'The webhook URL must use https:// so Yoco can reach it.';
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
return 'The webhook URL must be a public address, not localhost.';
}
return null;
}
module.exports = {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, yocoAuthHeader,
yocoRequest, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
};
+99
View File
@@ -0,0 +1,99 @@
jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() },
}));
const prisma = require('../src/config/db');
const { encrypt } = require('../src/utils/encryption');
const settingsCache = require('../src/utils/settingsCache');
const {
maskSecret, yocoMode, getYocoSecretKey, getYocoWebhookSecret, yocoAuthHeader,
defaultWebhookUrl, webhookUrlProblem,
} = require('../src/utils/yoco');
const ENV = { ...process.env };
afterEach(() => { process.env = { ...ENV }; });
function dbHas(rows) {
prisma.appSetting.findMany.mockResolvedValue(rows);
settingsCache.invalidate();
}
describe('maskSecret', () => {
test('keeps the prefix and the last 3 characters', () => {
expect(maskSecret('sk_test_0123456789abcdefaba')).toBe('sk_test_****************aba');
expect(maskSecret('sk_live_0123456789xyz')).toBe('sk_live_**********xyz');
expect(maskSecret('whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw')).toBe(`whsec_${'*'.repeat(29)}aSw`);
});
test('masks the length exactly, like a card number', () => {
const v = 'sk_test_0123456789abcdefaba';
expect(maskSecret(v)).toHaveLength(v.length);
});
test('short values show nothing after the prefix', () => {
expect(maskSecret('sk_test_abc')).toBe('sk_test_***');
expect(maskSecret('whsec_')).toBe('whsec_');
});
test('unknown formats keep the first 4 characters', () => {
expect(maskSecret('abcd0123456789')).toBe('abcd*******789');
});
test('empty stays empty', () => {
expect(maskSecret('')).toBe('');
expect(maskSecret(undefined)).toBe('');
});
});
test('yocoMode reads the key prefix', () => {
expect(yocoMode('sk_test_x')).toBe('test');
expect(yocoMode('sk_live_x')).toBe('live');
expect(yocoMode('pk_test_x')).toBe('unknown');
expect(yocoMode('')).toBe('unknown');
});
describe('stored secrets', () => {
test('the database wins over .env, and is decrypted', async () => {
process.env.YOCO_SECRET_KEY = 'sk_test_fromenv';
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_test_fromdb') }]);
expect(await getYocoSecretKey()).toBe('sk_test_fromdb');
});
test('.env is the fallback when the database has no value', async () => {
process.env.YOCO_WEBHOOK_SECRET = 'whsec_ZW52';
dbHas([{ key: 'yoco_webhook_secret', value: '' }]);
expect(await getYocoWebhookSecret()).toBe('whsec_ZW52');
});
test('checkouts fail clearly when no key is configured', async () => {
delete process.env.YOCO_SECRET_KEY;
dbHas([]);
await expect(yocoAuthHeader()).rejects.toThrow(/not configured/);
});
test('the auth header uses the stored key', async () => {
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_live_abc') }]);
expect(await yocoAuthHeader()).toBe('Bearer sk_live_abc');
});
});
describe('webhook URL', () => {
const req = { protocol: 'https', get: () => 'events-demo-api.crosscode.co.za' };
test('built from BACKEND_URL', () => {
process.env.BACKEND_URL = 'https://api.example.com/';
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/webhooks/yoco');
});
test('falls back to the request origin', () => {
delete process.env.BACKEND_URL;
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/webhooks/yoco');
});
test('must be public https', () => {
expect(webhookUrlProblem('https://api.example.com/api/webhooks/yoco')).toBeNull();
expect(webhookUrlProblem('http://api.example.com/api/webhooks/yoco')).toMatch(/https/);
expect(webhookUrlProblem('https://localhost:5000/api/webhooks/yoco')).toMatch(/localhost/);
expect(webhookUrlProblem('not a url')).toMatch(/full webhook URL/);
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.24.0",
"version": "1.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.24.0",
"version": "1.25.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.24.0",
"version": "1.25.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -32,6 +32,9 @@ const ACTIONS = [
"ticket_transferred",
"impersonation_started",
"impersonation_ended",
"yoco_config_updated",
"yoco_webhook_registered",
"yoco_webhook_deleted",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -58,6 +61,9 @@ const ACTION_LABELS: Record<string, string> = {
ticket_transferred: "Ticket transferred",
impersonation_started: "Impersonation started",
impersonation_ended: "Impersonation ended",
yoco_config_updated: "Yoco settings changed",
yoco_webhook_registered: "Yoco webhook registered",
yoco_webhook_deleted: "Yoco webhook deleted",
};
interface AuditLogEntry {
@@ -6,14 +6,15 @@ import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, DatabaseBackup, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { YocoTab } from "@/components/admin/YocoTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "backups";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -22,6 +23,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "email", label: "Email", icon: Mail },
{ id: "legal", label: "Legal", icon: Scale },
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "yoco", label: "Yoco", icon: CreditCard },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
];
@@ -688,6 +690,9 @@ function SiteSettingsPageInner() {
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
{/* ── Yoco ──────────────────────────────────────────────────────────── */}
{activeTab === "yoco" && <YocoTab active={activeTab === "yoco"} />}
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
</div>
+354
View File
@@ -0,0 +1,354 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Copy, CreditCard, KeyRound, Trash2, Webhook } from "lucide-react";
type Source = "db" | "env" | "none";
interface YocoConfig {
secretKeyMasked: string;
secretKeySource: Source;
mode: "test" | "live" | "unknown";
webhookSecretMasked: string;
webhookSecretSource: Source;
webhookId: string;
webhookUrl: string;
configured: boolean;
}
interface YocoWebhook {
id: string;
name: string;
url: string;
mode: "test" | "live";
isActive: boolean;
pointsHere: boolean;
}
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const primaryBtn =
"px-3 py-1.5 text-sm rounded-lg bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white font-medium";
const secondaryBtn = "px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50";
function ModeBadge({ mode }: { mode: string }) {
if (mode !== "test" && mode !== "live") return null;
return (
<span
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${
mode === "live" ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"
}`}
>
{mode === "live" ? "Live" : "Test"}
</span>
);
}
function SourceNote({ source }: { source: Source }) {
if (source === "env") {
return <p className="text-xs text-amber-700 mt-1">From the server&apos;s .env file. Save it here to store it in the database instead.</p>;
}
if (source === "none") return <p className="text-xs text-gray-500 mt-1">Not set.</p>;
return null;
}
function Card({ icon: Icon, title, subtitle, children }: {
icon: React.ElementType; title: string; subtitle: string; children: React.ReactNode;
}) {
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-brand-600" />
<div>
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
<p className="text-xs text-gray-500">{subtitle}</p>
</div>
</div>
{children}
</div>
);
}
export function YocoTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [cfg, setCfg] = useState<YocoConfig | null>(null);
const [hooks, setHooks] = useState<YocoWebhook[]>([]);
const [hooksError, setHooksError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
// API key editor
const [editingKey, setEditingKey] = useState(false);
const [keyInput, setKeyInput] = useState("");
// Webhook secret: generate (register at Yoco) or enter by hand
const [secretMode, setSecretMode] = useState<"generate" | "enter">("generate");
const [hookName, setHookName] = useState("hope-events");
const [hookUrl, setHookUrl] = useState("");
const [secretInput, setSecretInput] = useState("");
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const loadHooks = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ webhooks: YocoWebhook[] }>("/api/yoco/webhooks", { authToken: token });
setHooks(res.webhooks || []);
setHooksError(null);
} catch (e: any) {
setHooks([]);
setHooksError(e?.message || "Could not load webhooks from Yoco");
}
}, [token]);
const load = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const c = await apiFetch<YocoConfig>("/api/yoco/config", { authToken: token });
setCfg(c);
setHookUrl((prev) => prev || c.webhookUrl);
if (c.secretKeySource !== "none") await loadHooks();
else { setHooks([]); setHooksError(null); }
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Failed to load Yoco settings" });
} finally {
setLoading(false);
}
}, [token, loadHooks, setMessage]);
useEffect(() => { if (active) load(); }, [active, load]);
const run = async (label: string, fn: () => Promise<string>) => {
setBusy(label);
setMessage(null);
try {
const text = await fn();
setMessage({ type: "ok", text });
await load();
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Something went wrong" });
} finally {
setBusy(null);
}
};
const saveKey = (value: string) => run("key", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { secretKey: value } });
setEditingKey(false);
setKeyInput("");
return value ? "Secret key saved." : "Secret key removed.";
});
const saveSecret = (value: string) => run("secret", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { webhookSecret: value } });
setSecretInput("");
return value ? "Webhook secret saved." : "Webhook secret removed.";
});
const generate = () => run("generate", async () => {
const res = await apiFetch<{ message: string }>("/api/yoco/webhooks", {
method: "POST", authToken: token!, body: { name: hookName.trim(), url: hookUrl.trim() },
});
return res.message;
});
const removeHook = (id: string) => run(`delete:${id}`, async () => {
const res = await apiFetch<{ message: string }>(`/api/yoco/webhooks/${encodeURIComponent(id)}`, {
method: "DELETE", authToken: token!,
});
setConfirmDelete(null);
return res.message;
});
const copy = (text: string) => {
navigator.clipboard?.writeText(text).then(
() => setMessage({ type: "ok", text: "Copied to clipboard." }),
() => setMessage({ type: "err", text: "Could not copy." }),
);
};
const hasKey = !!cfg && cfg.secretKeySource !== "none";
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<CreditCard className="w-4 h-4 text-brand-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Yoco payments</h2>
<p className="text-xs text-gray-500">Online card payments. Secrets are stored encrypted and never shown in full.</p>
</div>
</div>
{message && (
<div className={`text-sm p-2.5 rounded-lg ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
{message.text}
</div>
)}
{loading && !cfg ? (
<p className="text-sm text-gray-400">Loading…</p>
) : cfg && (
<>
{/* ── API key ─────────────────────────────────────────────────── */}
<Card icon={KeyRound} title="Secret key" subtitle="From the Yoco app: Selling Online → Payment Gateway → API keys.">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-sm bg-gray-50 border rounded px-2 py-1">{cfg.secretKeyMasked || "—"}</code>
<ModeBadge mode={cfg.mode} />
{!editingKey && (
<button type="button" className={`${secondaryBtn} ml-auto`} onClick={() => setEditingKey(true)}>
{hasKey ? "Change" : "Add key"}
</button>
)}
</div>
<SourceNote source={cfg.secretKeySource} />
{editingKey && (
<div className="space-y-2">
<input
type="password"
autoComplete="off"
className={inputCls}
placeholder="sk_test_… or sk_live_…"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
/>
<div className="flex gap-2">
<button type="button" className={primaryBtn} disabled={!keyInput.trim() || busy === "key"} onClick={() => saveKey(keyInput.trim())}>
{busy === "key" ? "Checking…" : "Save"}
</button>
<button type="button" className={secondaryBtn} onClick={() => { setEditingKey(false); setKeyInput(""); }}>
Cancel
</button>
{cfg.secretKeySource === "db" && (
<button type="button" className={`${secondaryBtn} ml-auto text-red-600`} disabled={busy === "key"} onClick={() => saveKey("")}>
Remove
</button>
)}
</div>
<p className="text-xs text-gray-500">The key is checked with Yoco before it is saved.</p>
</div>
)}
</Card>
{/* ── Webhook secret ──────────────────────────────────────────── */}
<Card icon={Webhook} title="Webhook secret" subtitle="Used to check that payment notifications really come from Yoco.">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-sm bg-gray-50 border rounded px-2 py-1">{cfg.webhookSecretMasked || "—"}</code>
{cfg.webhookSecretSource === "db" && (
<button type="button" className={`${secondaryBtn} ml-auto text-red-600`} disabled={busy === "secret"} onClick={() => saveSecret("")}>
Remove
</button>
)}
</div>
<SourceNote source={cfg.webhookSecretSource} />
<div>
<label className="block text-xs text-gray-600 mb-1">Webhook endpoint on this server</label>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-50 border rounded px-2 py-1.5 flex-1 break-all">{cfg.webhookUrl || "—"}</code>
{cfg.webhookUrl && (
<button type="button" className={secondaryBtn} onClick={() => copy(cfg.webhookUrl)} aria-label="Copy webhook URL">
<Copy className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<div className="flex gap-2 pt-1">
{(["generate", "enter"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setSecretMode(m)}
className={`px-3 py-1 text-xs rounded-lg border ${secretMode === m ? "bg-brand-50 border-brand-300 text-brand-700" : "bg-white border-gray-200 text-gray-600"}`}
>
{m === "generate" ? "Generate new" : "Enter manually"}
</button>
))}
</div>
{secretMode === "generate" ? (
hasKey ? (
<div className="space-y-2">
<p className="text-xs text-gray-500">Registers a webhook with Yoco and saves the secret it returns. Yoco only shows the secret once, so it is stored straight away.</p>
<div className="grid sm:grid-cols-3 gap-2">
<input className={inputCls} value={hookName} onChange={(e) => setHookName(e.target.value)} placeholder="Name" />
<input className={`${inputCls} sm:col-span-2`} value={hookUrl} onChange={(e) => setHookUrl(e.target.value)} placeholder="https://…/api/webhooks/yoco" />
</div>
<button type="button" className={primaryBtn} disabled={busy === "generate" || !hookUrl.trim()} onClick={generate}>
{busy === "generate" ? "Registering…" : "Register webhook"}
</button>
</div>
) : (
<p className="text-xs text-gray-500">Add the secret key first.</p>
)
) : (
<div className="space-y-2">
<p className="text-xs text-gray-500">Paste the secret of a webhook you registered yourself.</p>
<input
type="password"
autoComplete="off"
className={inputCls}
placeholder="whsec_…"
value={secretInput}
onChange={(e) => setSecretInput(e.target.value)}
/>
<button type="button" className={primaryBtn} disabled={!secretInput.trim() || busy === "secret"} onClick={() => saveSecret(secretInput.trim())}>
{busy === "secret" ? "Saving…" : "Save secret"}
</button>
</div>
)}
</Card>
{/* ── Registered webhooks ─────────────────────────────────────── */}
<Card icon={Webhook} title="Registered webhooks" subtitle="Webhooks on this Yoco account. Yoco allows up to 5 and recommends just one.">
{!hasKey ? (
<p className="text-xs text-gray-500">Add the secret key to see the webhooks.</p>
) : hooksError ? (
<p className="text-sm text-red-600">{hooksError}</p>
) : hooks.length === 0 ? (
<p className="text-sm text-gray-500">No webhooks registered.</p>
) : (
<ul className="divide-y border rounded-lg">
{hooks.map((h) => (
<li key={h.id} className="p-3 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-900">{h.name}</span>
<ModeBadge mode={h.mode} />
{h.isActive && <span className="text-[11px] font-medium px-1.5 py-0.5 rounded bg-brand-100 text-brand-700">Active</span>}
<div className="ml-auto flex gap-2">
{confirmDelete === h.id ? (
<>
<button type="button" className="px-3 py-1.5 text-sm rounded-lg bg-red-600 hover:bg-red-700 text-white disabled:opacity-50" disabled={busy === `delete:${h.id}`} onClick={() => removeHook(h.id)}>
{busy === `delete:${h.id}` ? "Deleting…" : "Delete"}
</button>
<button type="button" className={secondaryBtn} onClick={() => setConfirmDelete(null)}>Cancel</button>
</>
) : (
<button type="button" className={secondaryBtn} onClick={() => setConfirmDelete(h.id)} aria-label={`Delete ${h.name}`}>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<p className="text-xs text-gray-500 break-all">{h.url}</p>
{!h.pointsHere && <p className="text-xs text-amber-700">This URL isn&apos;t this server&apos;s webhook endpoint.</p>}
{confirmDelete === h.id && h.isActive && (
<p className="text-xs text-red-600">This is the active webhook. Deleting it also removes the stored secret, and payments won&apos;t be confirmed until a new one is set up.</p>
)}
</li>
))}
</ul>
)}
</Card>
</>
)}
</div>
);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.24.0",
"version": "1.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.24.0",
"version": "1.25.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.24.0",
"version": "1.25.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",