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>
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'] },
|
||||
];
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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 };
|
||||
@@ -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' }}]},
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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't this server'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't be confirmed until a new one is set up.</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user