From 865a16abe9fb1cbe21fd75c3ce616569296a66cd Mon Sep 17 00:00:00 2001 From: joshua Date: Tue, 20 Oct 2026 10:28:50 +0200 Subject: [PATCH] Store Yoco secrets in the database; add Yoco settings tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 16 + FEATURES.md | 4 +- README.md | 3 +- SETTINGS.md | 10 + backend/.env.example | 9 +- backend/API_DOCUMENTATION.md | 14 +- backend/README.md | 21 +- .../migration.sql | 12 + backend/prisma/schema.prisma | 3 + backend/scripts/migrate-env-to-db.js | 6 +- backend/src/controllers/paymentController.js | 9 +- backend/src/controllers/settingsController.js | 11 +- .../src/controllers/sponsorshipController.js | 3 +- backend/src/controllers/webhookController.js | 7 +- backend/src/controllers/yocoController.js | 149 ++++++++ backend/src/index.js | 17 + backend/src/routes/yocoRoutes.js | 12 + backend/src/utils/settingsCache.js | 4 +- backend/src/utils/yoco.js | 134 +++++++ backend/tests/yoco.test.js | 99 +++++ .../app/dashboard/admin/audit-log/page.tsx | 6 + .../src/app/dashboard/admin/settings/page.tsx | 9 +- frontend/src/components/admin/YocoTab.tsx | 354 ++++++++++++++++++ 23 files changed, 885 insertions(+), 27 deletions(-) create mode 100644 backend/prisma/migrations/20261020101550_add_yoco_audit_actions/migration.sql create mode 100644 backend/src/controllers/yocoController.js create mode 100644 backend/src/routes/yocoRoutes.js create mode 100644 backend/src/utils/yoco.js create mode 100644 backend/tests/yoco.test.js create mode 100644 frontend/src/components/admin/YocoTab.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d9d7e..7c01ba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/FEATURES.md b/FEATURES.md index 7060704..7d6a85b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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. diff --git a/README.md b/README.md index d76d35d..aedc595 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SETTINGS.md b/SETTINGS.md index 2f48ca1..3d954c7 100644 --- a/SETTINGS.md +++ b/SETTINGS.md @@ -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 | diff --git a/backend/.env.example b/backend/.env.example index 816fadf..2ea965f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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, diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index bb5ba07..2414f98 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -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 { diff --git a/backend/README.md b/backend/README.md index 23ec7e6..bc35cbb 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 \ No newline at end of file diff --git a/backend/prisma/migrations/20261020101550_add_yoco_audit_actions/migration.sql b/backend/prisma/migrations/20261020101550_add_yoco_audit_actions/migration.sql new file mode 100644 index 0000000..f006d6f --- /dev/null +++ b/backend/prisma/migrations/20261020101550_add_yoco_audit_actions/migration.sql @@ -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'; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 82dc032..f39f5d1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/scripts/migrate-env-to-db.js b/backend/scripts/migrate-env-to-db.js index 3af2623..b9db214 100644 --- a/backend/scripts/migrate-env-to-db.js +++ b/backend/scripts/migrate-env-to-db.js @@ -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'] }, ]; diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index fc47681..5c23619 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -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 }); } diff --git a/backend/src/controllers/settingsController.js b/backend/src/controllers/settingsController.js index de4225a..6855a74 100644 --- a/backend/src/controllers/settingsController.js +++ b/backend/src/controllers/settingsController.js @@ -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); diff --git a/backend/src/controllers/sponsorshipController.js b/backend/src/controllers/sponsorshipController.js index e206d93..e906ee0 100644 --- a/backend/src/controllers/sponsorshipController.js +++ b/backend/src/controllers/sponsorshipController.js @@ -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) { diff --git a/backend/src/controllers/webhookController.js b/backend/src/controllers/webhookController.js index bedc8b7..9703569 100644 --- a/backend/src/controllers/webhookController.js +++ b/backend/src/controllers/webhookController.js @@ -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', diff --git a/backend/src/controllers/yocoController.js b/backend/src/controllers/yocoController.js new file mode 100644 index 0000000..674a777 --- /dev/null +++ b/backend/src/controllers/yocoController.js @@ -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 }; diff --git a/backend/src/index.js b/backend/src/index.js index 23797a1..cf9c7d9 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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' }}]}, diff --git a/backend/src/routes/yocoRoutes.js b/backend/src/routes/yocoRoutes.js new file mode 100644 index 0000000..1f37619 --- /dev/null +++ b/backend/src/routes/yocoRoutes.js @@ -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; diff --git a/backend/src/utils/settingsCache.js b/backend/src/utils/settingsCache.js index c3758e4..b35f9a9 100644 --- a/backend/src/utils/settingsCache.js +++ b/backend/src/utils/settingsCache.js @@ -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; diff --git a/backend/src/utils/yoco.js b/backend/src/utils/yoco.js new file mode 100644 index 0000000..a5897c3 --- /dev/null +++ b/backend/src/utils/yoco.js @@ -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 ` 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, +}; diff --git a/backend/tests/yoco.test.js b/backend/tests/yoco.test.js new file mode 100644 index 0000000..fc379b8 --- /dev/null +++ b/backend/tests/yoco.test.js @@ -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/); + }); +}); diff --git a/frontend/src/app/dashboard/admin/audit-log/page.tsx b/frontend/src/app/dashboard/admin/audit-log/page.tsx index 2ba2e05..c682521 100644 --- a/frontend/src/app/dashboard/admin/audit-log/page.tsx +++ b/frontend/src/app/dashboard/admin/audit-log/page.tsx @@ -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 = { @@ -58,6 +61,9 @@ const ACTION_LABELS: Record = { 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 { diff --git a/frontend/src/app/dashboard/admin/settings/page.tsx b/frontend/src/app/dashboard/admin/settings/page.tsx index d75f054..b5241f8 100644 --- a/frontend/src/app/dashboard/admin/settings/page.tsx +++ b/frontend/src/app/dashboard/admin/settings/page.tsx @@ -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" && } + {/* ── Yoco ──────────────────────────────────────────────────────────── */} + {activeTab === "yoco" && } + {/* ── Backups ───────────────────────────────────────────────────────── */} {activeTab === "backups" && } diff --git a/frontend/src/components/admin/YocoTab.tsx b/frontend/src/components/admin/YocoTab.tsx new file mode 100644 index 0000000..d69ae0d --- /dev/null +++ b/frontend/src/components/admin/YocoTab.tsx @@ -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 ( + + {mode === "live" ? "Live" : "Test"} + + ); +} + +function SourceNote({ source }: { source: Source }) { + if (source === "env") { + return

From the server's .env file. Save it here to store it in the database instead.

; + } + if (source === "none") return

Not set.

; + return null; +} + +function Card({ icon: Icon, title, subtitle, children }: { + icon: React.ElementType; title: string; subtitle: string; children: React.ReactNode; +}) { + return ( +
+
+ +
+

{title}

+

{subtitle}

+
+
+ {children} +
+ ); +} + +export function YocoTab({ active }: { active: boolean }) { + const { token } = useAuth(); + const [cfg, setCfg] = useState(null); + const [hooks, setHooks] = useState([]); + const [hooksError, setHooksError] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(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(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("/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) => { + 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 ( +
+
+
+ +
+
+

Yoco payments

+

Online card payments. Secrets are stored encrypted and never shown in full.

+
+
+ + {message && ( +
+ {message.text} +
+ )} + + {loading && !cfg ? ( +

Loading…

+ ) : cfg && ( + <> + {/* ── API key ─────────────────────────────────────────────────── */} + +
+ {cfg.secretKeyMasked || "—"} + + {!editingKey && ( + + )} +
+ + {editingKey && ( +
+ setKeyInput(e.target.value)} + /> +
+ + + {cfg.secretKeySource === "db" && ( + + )} +
+

The key is checked with Yoco before it is saved.

+
+ )} +
+ + {/* ── Webhook secret ──────────────────────────────────────────── */} + +
+ {cfg.webhookSecretMasked || "—"} + {cfg.webhookSecretSource === "db" && ( + + )} +
+ + +
+ +
+ {cfg.webhookUrl || "—"} + {cfg.webhookUrl && ( + + )} +
+
+ +
+ {(["generate", "enter"] as const).map((m) => ( + + ))} +
+ + {secretMode === "generate" ? ( + hasKey ? ( +
+

Registers a webhook with Yoco and saves the secret it returns. Yoco only shows the secret once, so it is stored straight away.

+
+ setHookName(e.target.value)} placeholder="Name" /> + setHookUrl(e.target.value)} placeholder="https://…/api/webhooks/yoco" /> +
+ +
+ ) : ( +

Add the secret key first.

+ ) + ) : ( +
+

Paste the secret of a webhook you registered yourself.

+ setSecretInput(e.target.value)} + /> + +
+ )} +
+ + {/* ── Registered webhooks ─────────────────────────────────────── */} + + {!hasKey ? ( +

Add the secret key to see the webhooks.

+ ) : hooksError ? ( +

{hooksError}

+ ) : hooks.length === 0 ? ( +

No webhooks registered.

+ ) : ( +
    + {hooks.map((h) => ( +
  • +
    + {h.name} + + {h.isActive && Active} +
    + {confirmDelete === h.id ? ( + <> + + + + ) : ( + + )} +
    +
    +

    {h.url}

    + {!h.pointsHere &&

    This URL isn't this server's webhook endpoint.

    } + {confirmDelete === h.id && h.isActive && ( +

    This is the active webhook. Deleting it also removes the stored secret, and payments won't be confirmed until a new one is set up.

    + )} +
  • + ))} +
+ )} +
+ + )} +
+ ); +}