Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4fff7c835 | ||
|
|
e310743b92 | ||
|
|
b6c13de5dc |
@@ -7,6 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.51.15] - 2026-10-30
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — privacy and admin:** my privacy (`/me/privacy`, news, my data as JSON or PDF, requests,
|
||||
accepting the notice), `/privacy/notice`, unsubscribe links (`/unsubscribe/{token}`), the privacy
|
||||
admin (requests, notice drafts and publishing, operators, consent and access logs, readiness,
|
||||
retention, breaches); `/admin/audit-log`, jobs, impersonation (`/admin/impersonate/{userId}`,
|
||||
`/me/impersonation` and its end and log); site settings and first-run set-up (`/settings`,
|
||||
`/setup`); the Hub; outbound webhooks; backups (including the download); Yoco and PayFast settings
|
||||
(`/payments/yoco/…`, `/payments/payfast/…`).
|
||||
- `v1Route` handles `res.download` and `res.sendFile`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Every API call the app makes now goes to `/api/v2`.** Only the incoming webhooks (Yoco,
|
||||
PayFast, WhatsApp) and the links in sent messages are still v1 — those are 1.51.16.
|
||||
- Ending a viewing-as session works on v2 while the session is read-only.
|
||||
|
||||
## [1.51.14] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -12,6 +12,12 @@ Write for church admins, not developers: no environment variables, endpoints or
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.51.15
|
||||
|
||||
- **The app now talks to the server only through its new version.** Privacy settings, the admin
|
||||
pages, site settings, the Hub, webhooks and backups were the last to move. Everything works just
|
||||
as before.
|
||||
|
||||
## 1.51.14
|
||||
|
||||
- **More groundwork for 2.0.** Accounts, messaging and broadcasts, WhatsApp set-up, the dashboards,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.14",
|
||||
"version": "1.51.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.14",
|
||||
"version": "1.51.15",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.14",
|
||||
"version": "1.51.15",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -17,7 +17,8 @@ const prisma = require('../config/db');
|
||||
|
||||
const MAX_MINUTES = 30;
|
||||
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
||||
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
|
||||
// v1 and v2 paths for ending a viewing-as session and logging what was viewed.
|
||||
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log', '/api/v2/me/impersonation/end', '/api/v2/me/impersonation/log'];
|
||||
|
||||
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
|
||||
function impersonationClaim(req) {
|
||||
|
||||
@@ -46,6 +46,61 @@ const SUCCESSORS: Successor[] = [
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
// 1.51.15: privacy and admin
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/me$/, v2: () => '/api/v2/me/privacy' },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/me\/news$/, v2: () => '/api/v2/me/privacy/news' },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/me\/export$/, v2: () => '/api/v2/me/privacy/export' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/me\/requests$/, v2: () => '/api/v2/me/privacy/requests' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/me\/notice\/accept$/, v2: () => '/api/v2/me/privacy/notice/accept' },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/notice$/, v2: () => '/api/v2/privacy/notice' },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/requests$/, v2: () => '/api/v2/privacy/requests' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/requests\/([^/]+)\/(complete|reject)$/, v2: m => `/api/v2/privacy/requests/${m[1]}/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
|
||||
{ method: 'DELETE', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/notice\/(publish|builtin-reviewed)$/, v2: m => `/api/v2/privacy/notice/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/(consent-log|access-log|readiness|retention|breaches)$/, v2: m => `/api/v2/privacy/${m[1]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/retention$/, v2: () => '/api/v2/privacy/retention' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/retention\/run$/, v2: () => '/api/v2/privacy/retention/run' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/breaches$/, v2: () => '/api/v2/privacy/breaches' },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/breaches\/([^/]+)$/, v2: m => `/api/v2/privacy/breaches/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/admin\/(audit-log|jobs|impersonation-sessions)$/, v2: m => `/api/v2/admin/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/admin\/jobs\/([^/]+)\/run$/, v2: m => `/api/v2/admin/jobs/${m[1]}/run` },
|
||||
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/(end|log)$/, v2: m => `/api/v2/me/impersonation/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/admin\/impersonate\/current$/, v2: () => '/api/v2/me/impersonation' },
|
||||
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/([^/]+)$/, v2: m => `/api/v2/admin/impersonate/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/settings\/needs-setup$/, v2: () => '/api/v2/setup/needed' },
|
||||
{ method: 'GET', v1: /^\/api\/settings\/all$/, v2: () => '/api/v2/settings/all' },
|
||||
{ method: 'GET', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
|
||||
{ method: 'PUT', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
|
||||
{ method: 'POST', v1: /^\/api\/settings\/test-smtp$/, v2: () => '/api/v2/settings/test-smtp' },
|
||||
{ method: 'POST', v1: /^\/api\/setup\/register$/, v2: () => '/api/v2/setup/register' },
|
||||
{ method: 'POST', v1: /^\/api\/setup\/?$/, v2: () => '/api/v2/setup' },
|
||||
{ method: 'GET', v1: /^\/api\/hub\/(status|notices)$/, v2: m => `/api/v2/hub/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/hub\/(connect|heartbeat|update-request|update-snooze)$/, v2: m => `/api/v2/hub/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/hub\/update-request\/cancel$/, v2: () => '/api/v2/hub/update-request/cancel' },
|
||||
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
|
||||
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
|
||||
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/deliveries\/([^/]+)\/resend$/, v2: m => `/api/v2/outbound-webhook-deliveries/${m[1]}/resend` },
|
||||
{ method: 'PUT', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/([^/]+)\/(rotate-secret|test)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/([^/]+)\/deliveries$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/deliveries` },
|
||||
{ method: 'GET', v1: /^\/api\/backups\/?$/, v2: () => '/api/v2/backups' },
|
||||
{ method: 'POST', v1: /^\/api\/backups\/run$/, v2: () => '/api/v2/backups/run' },
|
||||
{ method: 'GET', v1: /^\/api\/backups\/([^/]+)\/download$/, v2: m => `/api/v2/backups/${m[1]}/download` },
|
||||
{ method: 'GET', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
|
||||
{ method: 'PUT', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
|
||||
{ method: 'GET', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
|
||||
{ method: 'POST', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
|
||||
{ method: 'DELETE', v1: /^\/api\/yoco\/webhooks\/([^/]+)$/, v2: m => `/api/v2/payments/yoco/webhooks/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/payfast\/test$/, v2: () => '/api/v2/payments/payfast/test' },
|
||||
// 1.51.14: people and messaging
|
||||
{ method: 'POST', v1: /^\/api\/users\/login\/2fa\/resend$/, v2: () => '/api/v2/auth/login/code/resend' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/forgot$/, v2: () => '/api/v2/auth/password/forgot' },
|
||||
|
||||
@@ -18,6 +18,7 @@ import './resources/money';
|
||||
import './resources/attendance';
|
||||
import './resources/kidsVolunteers';
|
||||
import './resources/peopleMessaging';
|
||||
import './resources/adminPrivacy';
|
||||
import './resources/checkin';
|
||||
import './resources/auth';
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Privacy and admin on v2 (1.51.15): my privacy (news consent, my data, requests, the notice),
|
||||
* unsubscribe links, the privacy admin (requests, notice drafts, operators, logs, retention,
|
||||
* breaches), the audit log, jobs, impersonation, site settings and first-run set-up, the Hub,
|
||||
* outbound webhooks and backups. Each route runs the v1 handler (viaV1.ts) behind the same
|
||||
* access checks as v1.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { idParams } from '../schemas';
|
||||
import { v1Route } from '../viaV1';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const rateLimit = require('express-rate-limit') as (opts: object) => any;
|
||||
const { campusIndependent } = require('../../utils/campusScope') as { campusIndependent(reason: string): any };
|
||||
const privacy = { ...require('../../controllers/privacyController'), ...require('../../controllers/privacyAdminController') };
|
||||
const audit = require('../../controllers/adminAuditController');
|
||||
const imp = require('../../controllers/impersonationController');
|
||||
const jobs = require('../../controllers/jobsController');
|
||||
const settings = require('../../controllers/settingsController');
|
||||
const hub = require('../../controllers/hubController');
|
||||
const hooks = require('../../controllers/outboundWebhookController');
|
||||
const backups = require('../../controllers/backupController');
|
||||
|
||||
const loose = z.looseObject({});
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
const tokenParams = z.object({ token: z.string().min(1).max(500) });
|
||||
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
// ─── My privacy ──────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/me/privacy', tag: 'Privacy', summary: 'My privacy settings', description: 'News consent, open requests, and whether there\'s a privacy notice to accept.', access: 'user', v1: privacy.getMyPrivacy });
|
||||
v1Route({ method: 'put', path: '/me/privacy/news', tag: 'Privacy', summary: 'Choose whether to get news', access: 'user', body: loose, v1: privacy.updateMyNews });
|
||||
v1Route({ method: 'get', path: '/me/privacy/export', tag: 'Privacy', summary: 'Download everything held about me', description: '`format=pdf` or JSON (the default).', access: 'user', middleware: [exportLimiter], query: filters('format'), file: { contentType: 'application/json' }, v1: privacy.exportMyData });
|
||||
v1Route({ method: 'post', path: '/me/privacy/requests', tag: 'Privacy', summary: 'Ask for my data to be corrected or deleted', access: 'user', status: 201, body: loose, v1: privacy.createMyRequest });
|
||||
v1Route({ method: 'post', path: '/me/privacy/notice/accept', tag: 'Privacy', summary: 'Accept the privacy notice', access: 'user', body: loose, v1: privacy.acceptMyNotice });
|
||||
v1Route({ method: 'get', path: '/privacy/notice', tag: 'Privacy', summary: 'The published privacy notice', access: 'public', query: filters('version'), v1: privacy.getNotice });
|
||||
v1Route({ method: 'get', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'What an unsubscribe link is for', description: 'The token in the link is the credential.', access: 'public', params: tokenParams, v1: privacy.getUnsubscribe });
|
||||
v1Route({ method: 'post', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'Unsubscribe with a link', access: 'public', params: tokenParams, body: loose, v1: privacy.postUnsubscribe });
|
||||
|
||||
// ─── Privacy admin ───────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/privacy/requests', tag: 'Privacy', summary: 'Data requests', description: '`status=open` (default), `closed` or `all`.', access: 'admin', query: filters('status', 'search', 'page', 'limit'), v1: privacy.listRequests });
|
||||
v1Route({ method: 'post', path: '/privacy/requests/:id/complete', tag: 'Privacy', summary: 'Mark a data request done', access: 'admin', params: idParams, body: loose, v1: privacy.completeRequest });
|
||||
v1Route({ method: 'post', path: '/privacy/requests/:id/reject', tag: 'Privacy', summary: 'Turn down a data request', access: 'admin', params: idParams, body: loose, v1: privacy.rejectRequest });
|
||||
v1Route({ method: 'get', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'The privacy notice being drafted', access: 'admin', v1: privacy.getNoticeDraft });
|
||||
v1Route({ method: 'put', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Save the draft notice', access: 'admin', body: loose, v1: privacy.saveNoticeDraft });
|
||||
v1Route({ method: 'delete', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Throw the draft away', access: 'admin', v1: privacy.discardNoticeDraft });
|
||||
v1Route({ method: 'post', path: '/privacy/notice/publish', tag: 'Privacy', summary: 'Publish the draft notice', description: 'Everyone is asked to accept it at their next sign-in.', access: 'admin', body: loose, v1: privacy.publishNotice });
|
||||
v1Route({ method: 'post', path: '/privacy/notice/builtin-reviewed', tag: 'Privacy', summary: 'Mark the built-in notice as reviewed', access: 'admin', body: loose, v1: privacy.markBuiltInReviewed });
|
||||
v1Route({ method: 'get', path: '/privacy/operators', tag: 'Privacy', summary: 'Who processes personal information for the church', access: 'admin', v1: privacy.listOperators });
|
||||
v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: 'Add an operator', access: 'admin', status: 201, body: loose, v1: privacy.createOperator });
|
||||
v1Route({ method: 'put', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Change an operator', access: 'admin', params: idParams, body: loose, v1: privacy.updateOperator });
|
||||
v1Route({ method: 'delete', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Remove an operator', access: 'admin', params: idParams, v1: privacy.deleteOperator });
|
||||
v1Route({ method: 'get', path: '/privacy/consent-log', tag: 'Privacy', summary: 'Consent given and withdrawn', access: 'admin', query: filters('type', 'page', 'q'), v1: privacy.consentLog });
|
||||
v1Route({ method: 'get', path: '/privacy/access-log', tag: 'Privacy', summary: 'Who looked at sensitive information', access: 'admin', query: filters('page', 'action', 'sensitive', 'actorId', 'from', 'to'), v1: privacy.accessLog });
|
||||
v1Route({ method: 'get', path: '/privacy/readiness', tag: 'Privacy', summary: 'POPIA checklist', access: 'admin', v1: privacy.getReadiness });
|
||||
v1Route({ method: 'get', path: '/privacy/retention', tag: 'Privacy', summary: 'How long information is kept', access: 'admin', v1: privacy.getRetention });
|
||||
v1Route({ method: 'put', path: '/privacy/retention', tag: 'Privacy', summary: 'Change how long information is kept', access: 'admin', body: loose, v1: privacy.saveRetention });
|
||||
v1Route({ method: 'post', path: '/privacy/retention/run', tag: 'Privacy', summary: 'Apply the retention rules now', access: 'admin', body: loose, v1: privacy.runRetentionNow });
|
||||
v1Route({ method: 'get', path: '/privacy/breaches', tag: 'Privacy', summary: 'Recorded data breaches', access: 'admin', v1: privacy.listBreaches });
|
||||
v1Route({ method: 'post', path: '/privacy/breaches', tag: 'Privacy', summary: 'Record a data breach', access: 'admin', status: 201, body: loose, v1: privacy.createBreach });
|
||||
v1Route({ method: 'put', path: '/privacy/breaches/:id', tag: 'Privacy', summary: 'Update a data breach', access: 'admin', params: idParams, body: loose, v1: privacy.updateBreach });
|
||||
|
||||
// ─── Admin ───────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/admin/audit-log', tag: 'Admin', summary: 'The audit log', access: 'admin', query: filters('page', 'limit', 'actorId', 'action', 'role', 'from', 'to', 'search'), v1: audit.listAuditLog });
|
||||
v1Route({ method: 'get', path: '/admin/jobs', tag: 'Admin', summary: 'Background jobs', access: 'admin', v1: jobs.listJobs });
|
||||
v1Route({ method: 'post', path: '/admin/jobs/:name/run', tag: 'Admin', summary: 'Run a background job now', access: 'admin', params: z.object({ name: z.string().min(1).max(100) }), body: loose, v1: jobs.runJobNow });
|
||||
v1Route({ method: 'get', path: '/admin/impersonation-sessions', tag: 'Admin', summary: 'Times admins viewed the site as someone else', access: 'admin', query: filters('limit'), v1: imp.listSessions });
|
||||
v1Route({ method: 'post', path: '/admin/impersonate/:userId', tag: 'Admin', summary: 'View the site as someone (read-only)', description: 'Answers a token for a read-only session, logged.', access: 'admin', params: z.object({ userId: z.string().min(1).max(64) }), body: loose, v1: imp.startImpersonation });
|
||||
v1Route({ method: 'get', path: '/me/impersonation', tag: 'Admin', summary: 'The viewing-as session I\'m in, if any', access: 'user', v1: imp.currentImpersonation });
|
||||
v1Route({ method: 'post', path: '/me/impersonation/end', tag: 'Admin', summary: 'Stop viewing as someone', access: 'user', body: loose, v1: imp.endImpersonation });
|
||||
v1Route({ method: 'post', path: '/me/impersonation/log', tag: 'Admin', summary: 'Record a page viewed while viewing as someone', access: 'user', body: loose, v1: imp.logPageView });
|
||||
|
||||
// ─── Settings and first-run set-up ───────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/settings', tag: 'Site', summary: 'Public site settings', description: 'Name, branding, contact details and what\'s switched on.', access: 'public', v1: settings.getSettings });
|
||||
v1Route({ method: 'get', path: '/settings/all', tag: 'Site', summary: 'Every setting', description: 'Admins. Secrets are masked.', access: 'admin', v1: settings.getAllSettings });
|
||||
v1Route({ method: 'put', path: '/settings', tag: 'Site', summary: 'Change settings', access: 'admin', body: loose, v1: settings.updateSettings });
|
||||
v1Route({ method: 'post', path: '/settings/test-smtp', tag: 'Site', summary: 'Send a test email', access: 'admin', body: loose, v1: settings.testSmtp });
|
||||
v1Route({ method: 'get', path: '/setup/needed', tag: 'Site', summary: 'Does the site still need setting up?', access: 'public', v1: settings.needsSetup });
|
||||
v1Route({ method: 'post', path: '/setup/register', tag: 'Site', summary: 'Create the first admin', description: 'Only while no account exists.', access: 'public', body: loose, v1: settings.setupRegister });
|
||||
v1Route({ method: 'post', path: '/setup', tag: 'Site', summary: 'Finish first-run set-up', access: 'admin', body: loose, v1: settings.runSetup });
|
||||
|
||||
// ─── Hub ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/hub/status', tag: 'Site', summary: 'The connection to the provider\'s Hub', access: 'admin', v1: hub.getStatus });
|
||||
v1Route({ method: 'post', path: '/hub/connect', tag: 'Site', summary: 'Connect to the Hub', access: 'admin', body: loose, v1: hub.connect });
|
||||
v1Route({ method: 'post', path: '/hub/heartbeat', tag: 'Site', summary: 'Check in with the Hub now', access: 'admin', body: loose, v1: hub.heartbeatNow });
|
||||
v1Route({ method: 'post', path: '/hub/update-request', tag: 'Site', summary: 'Ask for the update to be installed', access: 'admin', body: loose, v1: hub.requestUpdate });
|
||||
v1Route({ method: 'post', path: '/hub/update-request/cancel', tag: 'Site', summary: 'Withdraw the update request', access: 'admin', body: loose, v1: hub.cancelUpdateRequest });
|
||||
v1Route({ method: 'post', path: '/hub/update-snooze', tag: 'Site', summary: 'Remind me about the update later', access: 'admin', body: loose, v1: hub.snoozeUpdate });
|
||||
v1Route({
|
||||
method: 'get', path: '/hub/notices', tag: 'Site', summary: 'Notices from the provider',
|
||||
access: 'supervisor', campusScope: 'campusIndependent (notices from the provider are for the whole site)', middleware: [campusIndependent('notices from the provider are for the whole site')], v1: hub.getNotices,
|
||||
});
|
||||
|
||||
// ─── Outbound webhooks ───────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Webhooks the site sends', access: 'admin', v1: hooks.list });
|
||||
v1Route({ method: 'post', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Add a webhook', description: 'Answers its signing secret once.', access: 'admin', status: 201, body: loose, v1: hooks.create });
|
||||
v1Route({ method: 'post', path: '/outbound-webhook-deliveries/:id/resend', tag: 'Webhooks', summary: 'Send a delivery again', access: 'admin', params: idParams, body: loose, v1: hooks.resend });
|
||||
v1Route({ method: 'patch', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Change a webhook', access: 'admin', params: idParams, body: loose, v1: hooks.update });
|
||||
v1Route({ method: 'delete', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Remove a webhook', access: 'admin', params: idParams, v1: hooks.remove });
|
||||
v1Route({ method: 'post', path: '/outbound-webhooks/:id/rotate-secret', tag: 'Webhooks', summary: 'Give a webhook a new signing secret', access: 'admin', params: idParams, body: loose, v1: hooks.rotateSecret });
|
||||
v1Route({ method: 'post', path: '/outbound-webhooks/:id/test', tag: 'Webhooks', summary: 'Send a test delivery', access: 'admin', params: idParams, body: loose, v1: hooks.test });
|
||||
v1Route({ method: 'get', path: '/outbound-webhooks/:id/deliveries', tag: 'Webhooks', summary: 'A webhook\'s recent deliveries', access: 'admin', params: idParams, query: filters('page', 'limit'), v1: hooks.deliveries });
|
||||
|
||||
// ─── Backups ─────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/backups', tag: 'Admin', summary: 'Database backups', access: 'admin', v1: backups.getBackups });
|
||||
v1Route({ method: 'post', path: '/backups/run', tag: 'Admin', summary: 'Back up the database now', access: 'admin', body: loose, v1: backups.triggerBackup });
|
||||
v1Route({ method: 'get', path: '/backups/:filename/download', tag: 'Admin', summary: 'Download a backup', access: 'admin', params: z.object({ filename: z.string().min(1).max(200) }), file: { contentType: 'application/octet-stream' }, v1: backups.downloadBackup });
|
||||
|
||||
// ─── Yoco and PayFast settings (admins; their incoming webhooks are separate) ─
|
||||
|
||||
const yoco = require('../../controllers/yocoController');
|
||||
const payfast = require('../../controllers/payfastController');
|
||||
v1Route({ method: 'get', path: '/payments/yoco/config', tag: 'Payments', summary: 'Yoco settings', description: 'Keys are masked.', access: 'admin', v1: yoco.getConfig });
|
||||
v1Route({ method: 'put', path: '/payments/yoco/config', tag: 'Payments', summary: 'Change Yoco settings', access: 'admin', body: loose, v1: yoco.updateConfig });
|
||||
v1Route({ method: 'get', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Webhooks registered with Yoco', access: 'admin', v1: yoco.getWebhooks });
|
||||
v1Route({ method: 'post', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Register a webhook with Yoco', access: 'admin', body: loose, v1: yoco.createWebhook });
|
||||
v1Route({ method: 'delete', path: '/payments/yoco/webhooks/:id', tag: 'Payments', summary: 'Remove a webhook from Yoco', access: 'admin', params: z.object({ id: z.string().min(1).max(200) }), v1: yoco.removeWebhook });
|
||||
v1Route({ method: 'get', path: '/payments/payfast/config', tag: 'Payments', summary: 'PayFast settings', description: 'The passphrase is masked.', access: 'admin', v1: payfast.getConfig });
|
||||
v1Route({ method: 'put', path: '/payments/payfast/config', tag: 'Payments', summary: 'Change PayFast settings', access: 'admin', body: loose, v1: payfast.updateConfig });
|
||||
v1Route({ method: 'post', path: '/payments/payfast/test', tag: 'Payments', summary: 'Check the PayFast details work', access: 'admin', body: loose, v1: payfast.testConnection });
|
||||
@@ -9,6 +9,8 @@
|
||||
* The handlers are still JavaScript; moving each area into a TypeScript service (as 1.51.1 –
|
||||
* 1.51.10 did) can follow later without changing the v2 API.
|
||||
*/
|
||||
import { readFile } from 'fs/promises';
|
||||
import { basename } from 'path';
|
||||
import { Writable } from 'stream';
|
||||
import { z } from 'zod';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
@@ -73,6 +75,21 @@ class RecordingResponse extends Writable {
|
||||
this.finishWith({});
|
||||
return this;
|
||||
}
|
||||
/** `res.download` / `res.sendFile`: the file on disk becomes the answer. */
|
||||
download(filePath: string, filename?: string | ((err?: unknown) => void)) {
|
||||
const name = typeof filename === 'string' ? filename : basename(filePath);
|
||||
readFile(filePath).then(buf => {
|
||||
if (!this.headers['content-type']) this.setHeader('content-type', 'application/octet-stream');
|
||||
this.setHeader('content-disposition', `attachment; filename="${name}"`);
|
||||
this.chunks.push(buf);
|
||||
this.finishWith({});
|
||||
}, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
|
||||
return this;
|
||||
}
|
||||
sendFile(filePath: string) {
|
||||
readFile(filePath).then(buf => { this.chunks.push(buf); this.finishWith({}); }, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
|
||||
return this;
|
||||
}
|
||||
redirect(a: number | string, b?: string) {
|
||||
const url = typeof a === 'string' ? a : b!;
|
||||
this.statusCode = typeof a === 'number' ? a : 302;
|
||||
|
||||
@@ -3,6 +3,10 @@ const { allowedWhileImpersonating, impersonationBlocker } = require('../src/util
|
||||
describe('allowedWhileImpersonating', () => {
|
||||
test('reads are allowed', () => {
|
||||
expect(allowedWhileImpersonating('GET', '/api/registrations/myregistrations')).toBe(true);
|
||||
// Ending (and logging) a viewing-as session works on v2 too; other v2 writes stay blocked.
|
||||
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/end')).toBe(true);
|
||||
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/log')).toBe(true);
|
||||
expect(allowedWhileImpersonating('POST', '/api/v2/payments')).toBe(false);
|
||||
expect(allowedWhileImpersonating('HEAD', '/api/events')).toBe(true);
|
||||
expect(allowedWhileImpersonating('OPTIONS', '/api/payments')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -85,4 +85,16 @@ describe('v2 routes answered by v1 handlers', () => {
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error.code).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
test("a file v1 sends with res.download is a download", async () => {
|
||||
const os = require("os"); const path = require("path");
|
||||
const file = path.join(os.tmpdir(), "viaV1-test-backup.sql");
|
||||
require("fs").writeFileSync(file, "-- backup");
|
||||
const handler: RequestHandler = (_req, res) => { res.download(file, "backup.sql"); };
|
||||
const app = appWith(v1Route({ method: "get", path: "/backup", tag: "T", summary: "s", access: "public", file: { contentType: "application/octet-stream" }, v1: handler }));
|
||||
const res = await request(app).get("/api/v2/backup").buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on("data", (d: Buffer) => c.push(d)); r.on("end", () => cb(null, Buffer.concat(c))); });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-disposition"]).toContain("backup.sql");
|
||||
expect((res.body as Buffer).toString()).toBe("-- backup");
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-29, after **1.51.14** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-30, after **1.51.15** (and Hub 1.5.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -17,6 +17,7 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
|
||||
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.15 | **Privacy and admin on v2** — `resources/adminPrivacy.ts`; every frontend call on v2; impersonation end/log allowed on v2 while read-only |
|
||||
| 1.51.14 | **People and messaging on v2** — `resources/peopleMessaging.ts`; `/event-listing` for the public pages; `defineRoute` refuses duplicates |
|
||||
| 1.51.13 | **Kids check-in and volunteers on v2** — `resources/kidsVolunteers.ts` |
|
||||
| 1.51.12 | **Tickets and attendance on v2** — `resources/attendance.ts`; frontend `apiV2Raw()` for string-path helpers |
|
||||
@@ -74,10 +75,9 @@ encrypted settings unreadable or break existing installs.
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.51.15 – 1.51.16: every endpoint onto v2**, one area per release (roadmap build order, row
|
||||
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.16 gives incoming
|
||||
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
|
||||
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
|
||||
1. **1.51.16: incoming webhooks and links in sent messages** get v2 URLs (every call the app makes
|
||||
is on v2 since 1.51.15), with the v1 paths still answering (links redirect, webhooks handled by
|
||||
the same code). Then **2.0.0** — AI helpers and v1 removed.
|
||||
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
|
||||
move from ExcelTS — deprecated in favour of `documonster` by the same author (pre-1.0 in Oct
|
||||
2026) — once documonster is stable. After 1.51.7 has run everywhere, the old `pgboss` schema
|
||||
|
||||
+4
-3
@@ -43,8 +43,9 @@ foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts an
|
||||
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 1.51.5 **Next.js 16** and 1.51.6 **the complete command palette and
|
||||
ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
|
||||
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2**, 1.51.11 **money on v2** and 1.51.12 **tickets and
|
||||
attendance on v2** 1.51.13 **kids and volunteers on v2** and 1.51.14 **people and messaging on v2** shipped.
|
||||
**Next: 1.51.15 – 1.51.16 (every endpoint onto v2), then 2.0.0.**
|
||||
attendance on v2** 1.51.13 **kids and volunteers on v2**, 1.51.14 **people and messaging on v2** and 1.51.15
|
||||
**privacy and admin on v2** shipped — every call the app makes is on v2. **Next: 1.51.16 (incoming
|
||||
webhooks and links in sent messages), then 2.0.0.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -1013,7 +1014,7 @@ income justify the hardware:
|
||||
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
|
||||
| 11b | 1.51.4 ✅ – 1.51.14 ✅, 1.51.15 – 1.51.16 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
|
||||
| 11b | 1.51.4 ✅ – 1.51.15 ✅, 1.51.16 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
|
||||
+23
-2
@@ -90,8 +90,9 @@ test.describe("API v2", () => {
|
||||
expect(v1.headers()["link"]).toBe(`</api/v2/events>; rel="successor-version"`);
|
||||
const all = await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
|
||||
expect(all.headers()["link"]).toBe(`</api/v2/events/manage>; rel="successor-version"`);
|
||||
// Endpoints with no v2 version yet say nothing.
|
||||
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["deprecation"]).toBeUndefined();
|
||||
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["link"]).toBe(`</api/v2/setup/needed>; rel="successor-version"`);
|
||||
// Endpoints with no v2 version yet (incoming webhooks, until 1.51.16) say nothing.
|
||||
expect((await request.post(`${API}/api/webhooks/whatsapp`, { data: {} })).headers()["deprecation"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
|
||||
@@ -302,6 +303,26 @@ test.describe("API v2", () => {
|
||||
expect((await forgot.json()).error.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
test("privacy and admin: settings, my data, the audit log, webhooks, backups", async ({ request }) => {
|
||||
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await (await request.get(`${API}/api/v2/settings`)).json()).data).toBeTruthy();
|
||||
expect((await request.get(`${API}/api/v2/setup/needed`)).status()).toBe(200);
|
||||
expect((await request.get(`${API}/api/v2/me/privacy`, { headers: user })).status()).toBe(200);
|
||||
|
||||
const exported = await request.get(`${API}/api/v2/me/privacy/export`, { headers: user });
|
||||
expect(exported.status()).toBe(200);
|
||||
expect(exported.headers()["content-disposition"]).toContain("my-data-");
|
||||
expect(JSON.parse((await exported.body()).toString())).toBeTruthy();
|
||||
|
||||
for (const path of ["/api/v2/admin/audit-log?page=1&limit=5", "/api/v2/outbound-webhooks", "/api/v2/backups", "/api/v2/privacy/requests"]) {
|
||||
const res = await request.get(`${API}${path}`, { headers: admin });
|
||||
expect(res.status(), path).toBe(200);
|
||||
}
|
||||
// Admin areas aren't for everyone.
|
||||
expect((await request.get(`${API}/api/v2/admin/audit-log`, { headers: user })).status()).toBe(403);
|
||||
});
|
||||
|
||||
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.14",
|
||||
"version": "1.51.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.14",
|
||||
"version": "1.51.15",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.14",
|
||||
"version": "1.51.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { History } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
refund_created: "Refund created",
|
||||
@@ -131,7 +131,7 @@ function ImpersonationSessions({ token }: { token: string }) {
|
||||
const [sessions, setSessions] = useState<ImpSession[]>([]);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
apiFetch<ImpSession[]>("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([]));
|
||||
(apiV2Data("get", "/admin/impersonation-sessions", { query: { limit: "20" }, authToken: token }) as Promise<ImpSession[]>).then(setSessions).catch(() => setSessions([]));
|
||||
}, [token]);
|
||||
if (sessions.length === 0) return null;
|
||||
return (
|
||||
@@ -196,7 +196,7 @@ export default function AdminAuditLogPage() {
|
||||
if (search) qs.set("search", search);
|
||||
if (fromFilter) qs.set("from", fromFilter);
|
||||
if (toFilter) qs.set("to", toFilter);
|
||||
return `/api/admin/audit-log?${qs.toString()}`;
|
||||
return Object.fromEntries(qs);
|
||||
}, [actionFilter, roleFilter, search, fromFilter, toFilter]);
|
||||
|
||||
// Search as you type, once typing pauses.
|
||||
@@ -215,7 +215,7 @@ export default function AdminAuditLogPage() {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await apiFetch<{ rows: AuditLogEntry[]; total: number; page: number }>(buildQuery(p), { authToken: token });
|
||||
const res = await (apiV2Data("get", "/admin/audit-log", { query: buildQuery(p), authToken: token }) as Promise<{ rows: AuditLogEntry[]; total: number; page: number }>);
|
||||
setEntries(res?.rows || []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setPage(p);
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { Pager } from "@/components/shared/Pager";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Inbox, AlertTriangle } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type RequestType = "access" | "correction" | "deletion";
|
||||
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
|
||||
@@ -44,8 +44,8 @@ function OpenRequest({ request, token, onDone }: { request: DataRequest; token:
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/privacy/requests/${request.id}/${mode}`, {
|
||||
method: "POST",
|
||||
await apiV2Data("post", mode === "reject" ? "/privacy/requests/{id}/reject" : "/privacy/requests/{id}/complete", {
|
||||
params: { id: request.id },
|
||||
authToken: token,
|
||||
body: { response: response || undefined, cancelled: mode === "reject" ? cancelled : undefined },
|
||||
});
|
||||
@@ -155,7 +155,7 @@ export default function DataRequestsPage() {
|
||||
try {
|
||||
const qs = new URLSearchParams({ status: view, page: String(page), limit: String(pageSize) });
|
||||
if (search) qs.set("search", search);
|
||||
const res = await apiFetch<{ requests: DataRequest[]; openCount: number; total: number }>(`/api/privacy/requests?${qs.toString()}`, { authToken: token });
|
||||
const res = await (apiV2Data("get", "/privacy/requests", { query: Object.fromEntries(qs), authToken: token }) as Promise<{ requests: DataRequest[]; openCount: number; total: number }>);
|
||||
setRequests(res.requests);
|
||||
setOpenCount(res.openCount);
|
||||
setTotal(res.total ?? res.requests.length);
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from "react
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
import { ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
import { apiV2Raw, apiV2Data } from "@/lib/apiV2";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
@@ -165,7 +165,7 @@ function SiteSettingsPageInner() {
|
||||
// ── Load all settings once ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token })
|
||||
(apiV2Data("get", "/settings/all", { authToken: token }) as Promise<Record<string, string>>)
|
||||
.then((s) => {
|
||||
setOrgName(s.org_name || "");
|
||||
setOrgTagline(s.org_tagline || "");
|
||||
@@ -213,7 +213,7 @@ function SiteSettingsPageInner() {
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
|
||||
await apiV2Data("put", "/settings", { authToken: token, body: updates });
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e) {
|
||||
@@ -252,8 +252,8 @@ function SiteSettingsPageInner() {
|
||||
let finalFaviconUrl = faviconUrl;
|
||||
if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
|
||||
|
||||
await apiFetch("/api/settings", {
|
||||
method: "PUT", authToken: token,
|
||||
await apiV2Data("put", "/settings", {
|
||||
authToken: token,
|
||||
body: {
|
||||
primary_color: primaryColor,
|
||||
secondary_color: secondaryColor,
|
||||
@@ -284,8 +284,8 @@ function SiteSettingsPageInner() {
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await apiFetch("/api/settings", {
|
||||
method: "PUT", authToken: token,
|
||||
await apiV2Data("put", "/settings", {
|
||||
authToken: token,
|
||||
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
|
||||
});
|
||||
setPrimaryColor("#4F46E5");
|
||||
@@ -326,7 +326,7 @@ function SiteSettingsPageInner() {
|
||||
smtp_user: smtpUser.trim(),
|
||||
};
|
||||
if (smtpPass.trim() !== "") updates.smtp_pass = smtpPass.trim();
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
|
||||
await apiV2Data("put", "/settings", { authToken: token, body: updates });
|
||||
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
@@ -352,8 +352,7 @@ function SiteSettingsPageInner() {
|
||||
setSmtpTesting(true);
|
||||
setSmtpTestResult(null);
|
||||
try {
|
||||
await apiFetch("/api/settings/test-smtp", {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/settings/test-smtp", {
|
||||
authToken: token,
|
||||
body: {
|
||||
host: smtpHost.trim(),
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { beginImpersonation, type ImpersonationStart } from "@/lib/impersonation";
|
||||
import { Users as UsersIcon } from "lucide-react";
|
||||
@@ -217,7 +216,7 @@ export default function AdminUsersPage() {
|
||||
setImpBusy(true);
|
||||
setImpError(null);
|
||||
try {
|
||||
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
const res = await (apiV2Data("post", "/admin/impersonate/{userId}", { params: { userId: impTarget.id }, authToken: token, body: { reason: impReason } }) as Promise<ImpersonationStart>);
|
||||
beginImpersonation(res);
|
||||
} catch (e) {
|
||||
setImpError(errorMessage(e) || "Couldn't start impersonation");
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Megaphone, Download, PencilLine, Trash2, ListChecks, type LucideIcon } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type RequestType = "access" | "correction" | "deletion";
|
||||
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
|
||||
@@ -81,7 +82,7 @@ export default function PrivacyCentrePage() {
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setInfo(await apiFetch<PrivacyInfo>("/api/privacy/me", { authToken: token }));
|
||||
setInfo(await (apiV2Data("get", "/me/privacy", { authToken: token }) as Promise<PrivacyInfo>));
|
||||
setLoadError(null);
|
||||
} catch (e) {
|
||||
setLoadError(errorMessage(e) || "Could not load your privacy settings.");
|
||||
@@ -99,7 +100,7 @@ export default function PrivacyCentrePage() {
|
||||
// Show the new state straight away; load() puts back the saved one if it fails.
|
||||
setInfo({ ...info, news: { ...info.news, [channel]: value } });
|
||||
try {
|
||||
await apiFetch("/api/privacy/me/news", { method: "PUT", authToken: token, body: { [channel]: value } });
|
||||
await apiV2Data("put", "/me/privacy/news", { authToken: token, body: { [channel]: value } });
|
||||
await load();
|
||||
setNewsMsg({ type: "ok", text: value ? "You're subscribed." : "You're unsubscribed." });
|
||||
} catch (e) {
|
||||
@@ -118,10 +119,10 @@ export default function PrivacyCentrePage() {
|
||||
setDownloading(format);
|
||||
setDownloadMsg(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/privacy/me/export${format === "pdf" ? "?format=pdf" : ""}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const res = await fetch(`${API_BASE}/api/v2/me/privacy/export${format === "pdf" ? "?format=pdf" : ""}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.message || `Download failed (${res.status})`);
|
||||
throw new Error(body?.error?.message || body?.message || `Download failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -147,7 +148,7 @@ export default function PrivacyCentrePage() {
|
||||
setSendingCorrection(true);
|
||||
setCorrectionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", { method: "POST", authToken: token, body: { type: "correction", details: correction } });
|
||||
const res = await (apiV2Data("post", "/me/privacy/requests", { authToken: token, body: { type: "correction", details: correction } }) as Promise<{ message?: string }>);
|
||||
setCorrection("");
|
||||
setCorrectionMsg({ type: "ok", text: res?.message || "Request sent." });
|
||||
load();
|
||||
@@ -170,11 +171,10 @@ export default function PrivacyCentrePage() {
|
||||
setDeleting(true);
|
||||
setDeleteMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/me/privacy/requests", {
|
||||
authToken: token,
|
||||
body: { type: "deletion", password: deletePassword, details: deleteReason || undefined },
|
||||
});
|
||||
}) as Promise<{ message?: string }>);
|
||||
setDeleteMsg({ type: "ok", text: res?.message || "Your account is closed." });
|
||||
setTimeout(() => logout(), 3000);
|
||||
} catch (e) {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useScrollSpy } from "@/hooks/useScrollSpy";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
import { PrivacyNoticeSection, type PublicNotice } from "@/components/shared/PrivacyNoticeSection";
|
||||
|
||||
const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
|
||||
@@ -28,7 +29,7 @@ function PrivacyNotice() {
|
||||
useEffect(() => {
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
apiFetch<PublicNotice>(`/api/privacy/notice${versionParam ? `?version=${encodeURIComponent(versionParam)}` : ""}`)
|
||||
(apiV2Data("get", "/privacy/notice", { query: { version: versionParam || undefined } }) as Promise<PublicNotice>)
|
||||
.then(setNotice)
|
||||
.catch(e => setError(e instanceof ApiError ? e.message : "The privacy notice couldn't be loaded. Please try again."));
|
||||
}, [versionParam]);
|
||||
|
||||
@@ -38,9 +38,9 @@ const FEATURES = [
|
||||
async function getOrgName(): Promise<string> {
|
||||
if (!API_BASE) return appName;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
const res = await fetch(`${API_BASE}/api/v2/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return appName;
|
||||
const settings = await res.json();
|
||||
const settings = (await res.json())?.data;
|
||||
return settings?.org_name || appName;
|
||||
} catch {
|
||||
return appName;
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { ConnectForm } from "@/components/admin/LicenceTab";
|
||||
import { uploadImage } from "@/lib/eventSetup";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function SetupPage() {
|
||||
|
||||
// Check setup isn't already done
|
||||
useEffect(() => {
|
||||
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
|
||||
(apiV2Data("get", "/setup/needed") as Promise<{ needsSetup: boolean }>)
|
||||
.then((d) => { if (!d?.needsSetup) router.replace("/login"); })
|
||||
.catch(() => {});
|
||||
}, [router]);
|
||||
@@ -144,10 +144,9 @@ export default function SetupPage() {
|
||||
if (adminPassword !== adminConfirm) { setError("Passwords do not match."); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ token: string }>("/api/setup/register", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/setup/register", {
|
||||
body: { adminName, adminEmail, adminPassword },
|
||||
});
|
||||
}) as Promise<{ token: string }>);
|
||||
setSetupToken(res.token);
|
||||
next();
|
||||
} catch (e) {
|
||||
@@ -166,8 +165,7 @@ export default function SetupPage() {
|
||||
setSmtpTesting(true);
|
||||
setSmtpTestResult(null);
|
||||
try {
|
||||
const res = await apiFetch<{ ok: boolean; message?: string }>("/api/settings/test-smtp", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/settings/test-smtp", {
|
||||
authToken: setupToken,
|
||||
body: {
|
||||
host: smtpHost.trim(),
|
||||
@@ -177,7 +175,7 @@ export default function SetupPage() {
|
||||
user: smtpUser.trim() || undefined,
|
||||
pass: smtpPass || undefined,
|
||||
},
|
||||
});
|
||||
}) as Promise<{ ok: boolean; message?: string }>);
|
||||
setSmtpTestResult({ ok: true, message: res.message || "Connection successful." });
|
||||
} catch (e) {
|
||||
setSmtpTestResult({ ok: false, message: errorMessage(e) || "Connection failed." });
|
||||
@@ -235,8 +233,7 @@ export default function SetupPage() {
|
||||
if (smtpPass.trim()) settings.smtp_pass = smtpPass.trim();
|
||||
}
|
||||
|
||||
await apiFetch("/api/setup", {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/setup", {
|
||||
authToken: setupToken,
|
||||
body: { settings },
|
||||
});
|
||||
|
||||
@@ -4,8 +4,9 @@ import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { MailX } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Info =
|
||||
| { kind: "account"; email: string; hasPhone: boolean; news: { email: boolean; whatsapp: boolean } }
|
||||
@@ -21,7 +22,7 @@ function Unsubscribe() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<Info>(`/api/privacy/unsubscribe/${encodeURIComponent(token)}`)
|
||||
(apiV2Data("get", "/unsubscribe/{token}", { params: { token: token } }) as Promise<Info>)
|
||||
.then(setInfo)
|
||||
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this link."));
|
||||
}, [token]);
|
||||
@@ -29,7 +30,7 @@ function Unsubscribe() {
|
||||
const unsubscribe = async (channels?: ("email" | "whatsapp")[]) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch(`/api/privacy/unsubscribe/${encodeURIComponent(token)}`, { method: "POST", body: channels ? { channels } : {} });
|
||||
await apiV2Data("post", "/unsubscribe/{token}", { params: { token: token }, body: channels ? { channels } : {} });
|
||||
setDone(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Couldn't unsubscribe. Please try again.");
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { DatabaseBackup, Download } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
interface BackupEntry {
|
||||
filename: string;
|
||||
@@ -34,8 +35,8 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [list, allSettings] = await Promise.all([
|
||||
apiFetch<BackupEntry[]>("/api/backups", { authToken: token }),
|
||||
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token }),
|
||||
(apiV2Data("get", "/backups", { authToken: token }) as Promise<BackupEntry[]>),
|
||||
(apiV2Data("get", "/settings/all", { authToken: token }) as Promise<Record<string, string>>),
|
||||
]);
|
||||
setBackups(list || []);
|
||||
setRetainCount(allSettings?.backup_retain_count || "14");
|
||||
@@ -50,7 +51,7 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
if (!token) return;
|
||||
setSavingRetain(true);
|
||||
try {
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: { backup_retain_count: retainCount } });
|
||||
await apiV2Data("put", "/settings", { authToken: token, body: { backup_retain_count: retainCount } });
|
||||
setMessage({ type: "ok", text: "Retention setting saved." });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to save retention setting" });
|
||||
@@ -66,7 +67,7 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
setRunning(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch<{ filename: string }>("/api/backups/run", { method: "POST", authToken: token });
|
||||
const res = await (apiV2Data("post", "/backups/run", { authToken: token, body: {} }) as Promise<{ filename: string }>);
|
||||
setMessage({ type: "ok", text: `Backup created: ${res.filename}` });
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -141,14 +142,14 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
<td className="p-2.5 text-gray-500">{formatSize(b.size)}</td>
|
||||
<td className="p-2.5">
|
||||
<a
|
||||
href={`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`}
|
||||
href={`${API_BASE}/api/v2/backups/${encodeURIComponent(b.filename)}/download`}
|
||||
className="inline-flex items-center gap-1.5 text-brand-600 hover:underline"
|
||||
onClick={(e) => {
|
||||
// authenticated download: fetch as blob rather than a bare link,
|
||||
// since this route requires an admin bearer token
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
fetch(`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`, {
|
||||
fetch(`${API_BASE}/api/v2/backups/${encodeURIComponent(b.filename)}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => res.blob())
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Timer, Play, RefreshCw } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type JobRun = { state: string; createdOn?: string; startedOn: string | null; completedOn: string | null; output: unknown };
|
||||
type JobInfo = { name: string; label: string; cron: string; enabled: boolean; envFlag: string | null; nextRun: string | null; recent: JobRun[] };
|
||||
@@ -50,7 +50,7 @@ export function JobsTab({ active }: { active: boolean }) {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await apiFetch<JobsResponse>("/api/admin/jobs", { authToken: token }));
|
||||
setData(await (apiV2Data("get", "/admin/jobs", { authToken: token }) as Promise<JobsResponse>));
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to load background jobs" });
|
||||
} finally {
|
||||
@@ -64,7 +64,7 @@ export function JobsTab({ active }: { active: boolean }) {
|
||||
if (!token) return;
|
||||
setRunning(job.name);
|
||||
try {
|
||||
const r = await apiFetch<{ queued: boolean }>(`/api/admin/jobs/${job.name}/run`, { method: "POST", authToken: token });
|
||||
const r = await (apiV2Data("post", "/admin/jobs/{name}/run", { params: { name: job.name }, authToken: token, body: {} }) as Promise<{ queued: boolean }>);
|
||||
setMessage({ type: "ok", text: r.queued ? `"${job.label}" queued — it starts within a few seconds.` : `"${job.label}" ran.` });
|
||||
setTimeout(load, r.queued ? 4000 : 0);
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle, Undo2, BellOff, BellRing, Megaphone } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data, apiV2Raw } from "@/lib/apiV2";
|
||||
|
||||
export type HubStatus = {
|
||||
state: "active" | "grace" | "suspended" | "revoked" | "unlicensed";
|
||||
@@ -76,7 +76,7 @@ export function ConnectForm({ token, onConnected, compact = false }: { token: st
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onConnected(await apiFetch<HubStatus>("/api/hub/connect", { method: "POST", authToken: token, body: { licenceKey: key } }));
|
||||
onConnected(await (apiV2Data("post", "/hub/connect", { authToken: token, body: { licenceKey: key } }) as Promise<HubStatus>));
|
||||
setKey("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Could not connect.");
|
||||
@@ -107,7 +107,7 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try { setStatus(await apiFetch<HubStatus>("/api/hub/status", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load the licence." }); }
|
||||
try { setStatus(await (apiV2Data("get", "/hub/status", { authToken: token }) as Promise<HubStatus>)); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load the licence." }); }
|
||||
}, [token]);
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
@@ -115,7 +115,7 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
setStatus(await apiFetch<HubStatus>("/api/hub/heartbeat", { method: "POST", authToken: token }));
|
||||
setStatus(await (apiV2Data("post", "/hub/heartbeat", { authToken: token, body: {} }) as Promise<HubStatus>));
|
||||
setMsg({ type: "ok", text: "Checked with the Hub." });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
|
||||
@@ -130,7 +130,7 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
setStatus(await apiFetch<HubStatus>(path, { method: "POST", authToken: token, body }));
|
||||
setStatus(await apiV2Raw<HubStatus>("post", path, { authToken: token, body: body ?? {} }));
|
||||
setMsg({ type: "ok", text: ok });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
|
||||
@@ -138,14 +138,14 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const requestUpdate = () => updateAction("/api/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
|
||||
const requestUpdate = () => updateAction("/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
|
||||
// Same request again: the Hub moves the open request on to the latest version.
|
||||
const changeRequest = () => updateAction("/api/hub/update-request", undefined, `Request changed to version ${status?.latestVersion}. Your provider has been told.`);
|
||||
const changeRequest = () => updateAction("/hub/update-request", undefined, `Request changed to version ${status?.latestVersion}. Your provider has been told.`);
|
||||
const withdrawRequest = () => {
|
||||
if (!confirm("Withdraw the update request? Your provider is told not to update the site for now.")) return;
|
||||
updateAction("/api/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
|
||||
updateAction("/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
|
||||
};
|
||||
const snooze = (on: boolean) => updateAction("/api/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
|
||||
const snooze = (on: boolean) => updateAction("/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
|
||||
|
||||
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
const st = STATE[status.state];
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Wallet } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
interface PayFastConfig {
|
||||
merchantId: string;
|
||||
@@ -39,7 +39,7 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const c = await apiFetch<PayFastConfig>("/api/payfast/config", { authToken: token });
|
||||
const c = await (apiV2Data("get", "/payments/payfast/config", { authToken: token }) as Promise<PayFastConfig>);
|
||||
setCfg(c);
|
||||
setMerchantId(c.merchantId);
|
||||
setMerchantKey(c.merchantKey);
|
||||
@@ -57,8 +57,8 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiFetch("/api/payfast/config", {
|
||||
method: "PUT", authToken: token,
|
||||
await apiV2Data("put", "/payments/payfast/config", {
|
||||
authToken: token,
|
||||
// An empty passphrase box keeps the saved one.
|
||||
body: { merchantId, merchantKey, mode, ...(passphrase ? { passphrase } : {}) },
|
||||
});
|
||||
@@ -78,7 +78,7 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
|
||||
if (!token) return;
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>("/api/payfast/test", { method: "POST", authToken: token });
|
||||
const res = await (apiV2Data("post", "/payments/payfast/test", { authToken: token, body: {} }) as Promise<{ message: string }>);
|
||||
setMessage({ type: "ok", text: res.message });
|
||||
} catch (err) {
|
||||
setMessage({ type: "err", text: errorMessage(err) || "PayFast didn't accept the settings" });
|
||||
@@ -90,7 +90,7 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
|
||||
const removePassphrase = async () => {
|
||||
if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return;
|
||||
try {
|
||||
await apiFetch("/api/payfast/config", { method: "PUT", authToken: token, body: { passphrase: "" } });
|
||||
await apiV2Data("put", "/payments/payfast/config", { authToken: token, body: { passphrase: "" } });
|
||||
setMessage({ type: "ok", text: "Passphrase removed." });
|
||||
await load();
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Webhook, Send, RefreshCw, KeyRound, Trash2, Pencil, Copy, ChevronDown, ChevronRight, RotateCcw } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { WebhookGuide } from "./WebhookGuide";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type EventType = { type: string; label: string; description: string; example?: unknown };
|
||||
type Endpoint = {
|
||||
@@ -60,7 +60,7 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
|
||||
const qs = new URLSearchParams({ limit: String(PAGE) });
|
||||
if (status) qs.set("status", status);
|
||||
if (before) qs.set("before", before);
|
||||
return apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries?${qs.toString()}`, { authToken: token }).catch(() => [] as Delivery[]);
|
||||
return (apiV2Data("get", "/outbound-webhooks/{id}/deliveries", { params: { id: endpointId }, query: Object.fromEntries(qs), authToken: token }) as Promise<Delivery[]>).catch(() => [] as Delivery[]);
|
||||
}, [endpointId, token, status]);
|
||||
const load = useCallback(async () => {
|
||||
const page = await fetchPage();
|
||||
@@ -76,7 +76,7 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
|
||||
};
|
||||
const resend = async (id: string) => {
|
||||
setBusy(id);
|
||||
await apiFetch(`/api/outbound-webhooks/deliveries/${id}/resend`, { method: "POST", authToken: token }).catch(() => {});
|
||||
await apiV2Data("post", "/outbound-webhook-deliveries/{id}/resend", { params: { id: id }, authToken: token, body: {} }).catch(() => {});
|
||||
setBusy(null);
|
||||
load();
|
||||
};
|
||||
@@ -139,7 +139,7 @@ export function WebhooksTab({ active }: { active: boolean }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try { setData(await apiFetch<ListResponse>("/api/outbound-webhooks", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Couldn't load webhooks" }); }
|
||||
try { setData(await (apiV2Data("get", "/outbound-webhooks", { authToken: token }) as Promise<ListResponse>)); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Couldn't load webhooks" }); }
|
||||
}, [token]);
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
@@ -152,10 +152,10 @@ export function WebhooksTab({ active }: { active: boolean }) {
|
||||
if (!draft) return;
|
||||
const body = { name: draft.name, url: draft.url, events: draft.events };
|
||||
if (draft.id) {
|
||||
await apiFetch(`/api/outbound-webhooks/${draft.id}`, { method: "PUT", authToken: token || undefined, body });
|
||||
await apiV2Data("patch", "/outbound-webhooks/{id}", { params: { id: draft.id }, authToken: token, body });
|
||||
setMsg({ type: "ok", text: "Webhook saved." });
|
||||
} else {
|
||||
const made = await apiFetch<Endpoint & { secret: string }>("/api/outbound-webhooks", { method: "POST", authToken: token || undefined, body });
|
||||
const made = await (apiV2Data("post", "/outbound-webhooks", { authToken: token, body }) as Promise<Endpoint & { secret: string }>);
|
||||
setSecret({ endpointId: made.id, value: made.secret });
|
||||
setMsg({ type: "ok", text: `"${made.name}" added, and listed in the operator register (Admin → Privacy).` });
|
||||
}
|
||||
@@ -164,11 +164,11 @@ export function WebhooksTab({ active }: { active: boolean }) {
|
||||
});
|
||||
|
||||
const toggle = (e: Endpoint) => run(async () => {
|
||||
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "PUT", authToken: token || undefined, body: { isActive: !e.isActive } });
|
||||
await apiV2Data("patch", "/outbound-webhooks/{id}", { params: { id: e.id }, authToken: token, body: { isActive: !e.isActive } });
|
||||
await load();
|
||||
});
|
||||
const test = (e: Endpoint) => run(async () => {
|
||||
const r = await apiFetch<{ status: string; statusCode: number | null; error: string | null }>(`/api/outbound-webhooks/${e.id}/test`, { method: "POST", authToken: token || undefined });
|
||||
const r = await (apiV2Data("post", "/outbound-webhooks/{id}/test", { params: { id: e.id }, authToken: token, body: {} }) as Promise<{ status: string; statusCode: number | null; error: string | null }>);
|
||||
setMsg(r.status === "succeeded"
|
||||
? { type: "ok", text: `Test delivered (answered ${r.statusCode}).` }
|
||||
: { type: "err", text: `Test not delivered: ${r.error || "no answer"}. It will be retried; see the delivery log.` });
|
||||
@@ -177,12 +177,12 @@ export function WebhooksTab({ active }: { active: boolean }) {
|
||||
});
|
||||
const rotate = (e: Endpoint) => run(async () => {
|
||||
if (!confirm(`Make a new secret for "${e.name}"? The old one stops working at once, so update the receiving side straight away.`)) return;
|
||||
const r = await apiFetch<{ secret: string }>(`/api/outbound-webhooks/${e.id}/rotate-secret`, { method: "POST", authToken: token || undefined });
|
||||
const r = await (apiV2Data("post", "/outbound-webhooks/{id}/rotate-secret", { params: { id: e.id }, authToken: token, body: {} }) as Promise<{ secret: string }>);
|
||||
setSecret({ endpointId: e.id, value: r.secret });
|
||||
});
|
||||
const remove = (e: Endpoint) => run(async () => {
|
||||
if (!confirm(`Delete "${e.name}" and its delivery log?`)) return;
|
||||
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
await apiV2Data("delete", "/outbound-webhooks/{id}", { params: { id: e.id }, authToken: token });
|
||||
setMsg({ type: "ok", text: "Webhook deleted. Its operator register entry is marked inactive." });
|
||||
await load();
|
||||
});
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
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";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Source = "db" | "env" | "none";
|
||||
|
||||
@@ -98,7 +98,7 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
|
||||
const loadHooks = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ webhooks: YocoWebhook[] }>("/api/yoco/webhooks", { authToken: token });
|
||||
const res = await (apiV2Data("get", "/payments/yoco/webhooks", { authToken: token }) as Promise<{ webhooks: YocoWebhook[] }>);
|
||||
setHooks(res.webhooks || []);
|
||||
setHooksError(null);
|
||||
} catch (e) {
|
||||
@@ -111,7 +111,7 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const c = await apiFetch<YocoConfig>("/api/yoco/config", { authToken: token });
|
||||
const c = await (apiV2Data("get", "/payments/yoco/config", { authToken: token }) as Promise<YocoConfig>);
|
||||
setCfg(c);
|
||||
setHookUrl((prev) => prev || c.webhookUrl);
|
||||
if (c.secretKeySource !== "none") await loadHooks();
|
||||
@@ -141,29 +141,31 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
|
||||
};
|
||||
|
||||
const saveKey = (value: string) => run("key", async () => {
|
||||
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { secretKey: value } });
|
||||
await apiV2Data("put", "/payments/yoco/config", { 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 } });
|
||||
await apiV2Data("put", "/payments/yoco/config", { 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() },
|
||||
});
|
||||
const res = await (apiV2Data("post", "/payments/yoco/webhooks", {
|
||||
authToken: token,
|
||||
body: { name: hookName.trim(), url: hookUrl.trim() },
|
||||
}) as Promise<{ message: string }>);
|
||||
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!,
|
||||
});
|
||||
const res = await (apiV2Data("delete", "/payments/yoco/webhooks/{id}", {
|
||||
params: { id: id },
|
||||
authToken: token,
|
||||
}) as Promise<{ message: string }>);
|
||||
setConfirmDelete(null);
|
||||
return res.message;
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Plus, Copy, Pencil } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Status = "open" | "contained" | "closed";
|
||||
type Breach = {
|
||||
@@ -90,7 +90,7 @@ export function BreachesPanel({ token }: { token: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setList(await apiFetch<Breach[]>("/api/privacy/breaches", { authToken: token }));
|
||||
setList(await (apiV2Data("get", "/privacy/breaches", { authToken: token }) as Promise<Breach[]>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not load the register.");
|
||||
}
|
||||
@@ -101,8 +101,8 @@ export function BreachesPanel({ token }: { token: string }) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (id === "new") await apiFetch("/api/privacy/breaches", { method: "POST", authToken: token, body });
|
||||
else await apiFetch(`/api/privacy/breaches/${id}`, { method: "PUT", authToken: token, body });
|
||||
if (id === "new") await apiV2Data("post", "/privacy/breaches", { authToken: token, body });
|
||||
else await apiV2Data("put", "/privacy/breaches/{id}", { params: { id: id }, authToken: token, body });
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
const selectCls = "border rounded-lg px-3 py-2 text-sm bg-white";
|
||||
@@ -44,7 +44,7 @@ export function ConsentLogPanel({ token }: { token: string }) {
|
||||
const load = useCallback(async () => {
|
||||
setData(null);
|
||||
try {
|
||||
setData(await apiFetch(`/api/privacy/consent-log?type=${type}&page=${page}${query ? `&q=${encodeURIComponent(query)}` : ""}`, { authToken: token }));
|
||||
setData(await (apiV2Data("get", "/privacy/consent-log", { query: { type, page: String(page), q: query || undefined }, authToken: token }) as Promise<NonNullable<typeof data>>));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not load the consent log.");
|
||||
@@ -137,7 +137,7 @@ export function AccessLogPanel({ token }: { token: string }) {
|
||||
if (action) params.set("action", action);
|
||||
if (actorId) params.set("actorId", actorId);
|
||||
if (sensitiveOnly) params.set("sensitive", "true");
|
||||
setData(await apiFetch<AccessResponse>(`/api/privacy/access-log?${params}`, { authToken: token }));
|
||||
setData(await (apiV2Data("get", "/privacy/access-log", { query: Object.fromEntries(new URLSearchParams(params)), authToken: token }) as Promise<AccessResponse>));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not load the access log.");
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine, Sparkles } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { PrivacyNoticeSection, type NoticeSection } from "@/components/shared/PrivacyNoticeSection";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Version = { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean; publishedBy: string | null; acceptances: number };
|
||||
// Changes to the built-in text since this site last reviewed them (only once it has published its own).
|
||||
@@ -41,7 +41,7 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const d = await apiFetch<Draft>("/api/privacy/notice/draft", { authToken: token });
|
||||
const d = await (apiV2Data("get", "/privacy/notice/draft", { authToken: token }) as Promise<Draft>);
|
||||
setDraft(d);
|
||||
setSections(d.sections);
|
||||
setDirty(false);
|
||||
@@ -64,7 +64,7 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<{ sections: NoticeSection[]; rendered: NoticeSection[]; savedAt: string }>("/api/privacy/notice/draft", { method: "PUT", authToken: token, body: { sections } });
|
||||
const res = await (apiV2Data("put", "/privacy/notice/draft", { authToken: token, body: { sections } }) as Promise<{ sections: NoticeSection[]; rendered: NoticeSection[]; savedAt: string }>);
|
||||
setDraft(d => (d ? { ...d, sections: res.sections, rendered: res.rendered, isDraft: true, savedAt: res.savedAt } : d));
|
||||
setSections(res.sections);
|
||||
setDirty(false);
|
||||
@@ -80,7 +80,7 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
if (!confirm("Throw away the draft and go back to the published text?")) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch("/api/privacy/notice/draft", { method: "DELETE", authToken: token });
|
||||
await apiV2Data("delete", "/privacy/notice/draft", { authToken: token });
|
||||
await load();
|
||||
setMsg({ type: "ok", text: "Draft discarded." });
|
||||
} catch (e) {
|
||||
@@ -95,7 +95,7 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<{ version: number }>("/api/privacy/notice/publish", { method: "POST", authToken: token, body: { sections, changeSummary, requiresAcceptance } });
|
||||
const res = await (apiV2Data("post", "/privacy/notice/publish", { authToken: token, body: { sections, changeSummary, requiresAcceptance } }) as Promise<{ version: number }>);
|
||||
setPublishing(false);
|
||||
setChangeSummary("");
|
||||
setRequiresAcceptance(false);
|
||||
@@ -112,7 +112,7 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
const markReviewed = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch("/api/privacy/notice/builtin-reviewed", { method: "POST", authToken: token });
|
||||
await apiV2Data("post", "/privacy/notice/builtin-reviewed", { authToken: token, body: {} });
|
||||
setDraft(d => (d ? { ...d, builtInUpdates: [] } : d));
|
||||
setMsg({ type: "ok", text: "Done. Remember to save the draft and publish if you took any new text." });
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Operator = { id: string; name: string; purpose: string; dataShared: string; location: string | null; website: string | null; active: boolean; order: number };
|
||||
type Form = { name: string; purpose: string; dataShared: string; location: string; website: string; active: boolean };
|
||||
@@ -41,7 +41,7 @@ export function OperatorsPanel({ token }: { token: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setOps(await apiFetch<Operator[]>("/api/privacy/operators", { authToken: token }));
|
||||
setOps(await (apiV2Data("get", "/privacy/operators", { authToken: token }) as Promise<Operator[]>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not load operators.");
|
||||
}
|
||||
@@ -53,8 +53,8 @@ export function OperatorsPanel({ token }: { token: string }) {
|
||||
setError(null);
|
||||
try {
|
||||
const body = { ...f, location: f.location || null, website: f.website || null };
|
||||
if (editing === "new") await apiFetch("/api/privacy/operators", { method: "POST", authToken: token, body });
|
||||
else await apiFetch(`/api/privacy/operators/${editing}`, { method: "PUT", authToken: token, body });
|
||||
if (editing === "new") await apiV2Data("post", "/privacy/operators", { authToken: token, body });
|
||||
else await apiV2Data("put", "/privacy/operators/{id}", { params: { id: editing! }, authToken: token, body });
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -67,7 +67,7 @@ export function OperatorsPanel({ token }: { token: string }) {
|
||||
const remove = async (op: Operator) => {
|
||||
if (!confirm(`Remove ${op.name} from the register? (Untick "In use" instead if you might use them again.)`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/privacy/operators/${op.id}`, { method: "DELETE", authToken: token });
|
||||
await apiV2Data("delete", "/privacy/operators/{id}", { params: { id: op.id }, authToken: token });
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not remove.");
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2, Circle, ArrowRight } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Check = { key: string; label: string; ok: boolean; detail: string; href: string | null; applies: boolean };
|
||||
type Readiness = { score: number; passed: number; total: number; checks: Check[] };
|
||||
@@ -16,7 +16,7 @@ export function ReadinessPanel({ token }: { token: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await apiFetch<Readiness>("/api/privacy/readiness", { authToken: token }));
|
||||
setData(await (apiV2Data("get", "/privacy/readiness", { authToken: token }) as Promise<Readiness>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not load the checklist.");
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Policy = { enabled: boolean; months: number };
|
||||
type Definition = { label: string; description: string; minMonths: number; defaults: Policy };
|
||||
@@ -39,7 +39,7 @@ export function RetentionPanel({ token }: { token: string }) {
|
||||
const apply = (s: State) => { setState(s); setPolicies(s.policies); };
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
apply(await apiFetch<State>("/api/privacy/retention", { authToken: token }));
|
||||
apply(await (apiV2Data("get", "/privacy/retention", { authToken: token }) as Promise<State>));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load retention settings." });
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function RetentionPanel({ token }: { token: string }) {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
apply(await apiFetch<State>("/api/privacy/retention", { method: "PUT", authToken: token, body: { policies } }));
|
||||
apply(await (apiV2Data("put", "/privacy/retention", { authToken: token, body: { policies } }) as Promise<State>));
|
||||
setMsg({ type: "ok", text: "Saved. The privacy notice's retention section is updated to match." });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
|
||||
@@ -66,7 +66,7 @@ export function RetentionPanel({ token }: { token: string }) {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<{ state: State }>("/api/privacy/retention/run", { method: "POST", authToken: token });
|
||||
const res = await (apiV2Data("post", "/privacy/retention/run", { authToken: token, body: {} }) as Promise<{ state: State }>);
|
||||
apply(res.state);
|
||||
setMsg({ type: "ok", text: "Done — see \"Last run\" below." });
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ShieldAlert, KeyRound, ArrowUpCircle, BellOff } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import type { HubStatus } from "@/components/admin/LicenceTab";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
/**
|
||||
* Licence and update notices for **site admins only** (never supervisors, staff, users or the
|
||||
@@ -20,7 +20,7 @@ export function LicenceBanner() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !token) return;
|
||||
apiFetch<HubStatus>("/api/hub/status", { authToken: token }).then(setStatus).catch(() => {});
|
||||
(apiV2Data("get", "/hub/status", { authToken: token }) as Promise<HubStatus>).then(setStatus).catch(() => {});
|
||||
}, [isAdmin, token]);
|
||||
|
||||
if (!isAdmin || !status) return null;
|
||||
@@ -69,7 +69,7 @@ export function LicenceBanner() {
|
||||
if (status.updateAvailable && !awaiting && !status.updateSnoozed) {
|
||||
const notNow = async () => {
|
||||
setBusy(true);
|
||||
try { setStatus(await apiFetch<HubStatus>("/api/hub/update-snooze", { method: "POST", authToken: token, body: { snooze: true } })); } catch { /* the Licence page shows why */ }
|
||||
try { setStatus(await (apiV2Data("post", "/hub/update-snooze", { authToken: token, body: { snooze: true } }) as Promise<HubStatus>)); } catch { /* the Licence page shows why */ }
|
||||
setBusy(false);
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -4,8 +4,8 @@ import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type News = { email: boolean; whatsapp: boolean };
|
||||
|
||||
@@ -26,7 +26,7 @@ export function NewsPrompt() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending || !token || news) return;
|
||||
apiFetch<{ news: News; canWhatsApp: boolean }>("/api/privacy/me", { authToken: token })
|
||||
(apiV2Data("get", "/me/privacy", { authToken: token }) as Promise<{ news: News; canWhatsApp: boolean }>)
|
||||
.then(res => { setNews(res.news); setCanWhatsApp(res.canWhatsApp); })
|
||||
.catch(() => setDone(true)); // never block the dashboard over this
|
||||
}, [pending, token, news]);
|
||||
@@ -38,7 +38,7 @@ export function NewsPrompt() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch("/api/privacy/me/news", { method: "PUT", authToken: token, body: { ...choice, source: "prompt" } });
|
||||
await apiV2Data("put", "/me/privacy/news", { authToken: token, body: { ...choice, source: "prompt" } });
|
||||
setDone(true);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not save your choice.");
|
||||
|
||||
@@ -4,9 +4,9 @@ import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { PublicNotice } from "@/components/shared/PrivacyNoticeSection";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
/**
|
||||
* POPIA: asked once after login when this person never accepted the privacy notice (an account
|
||||
@@ -21,7 +21,7 @@ export function NoticePrompt({ reason = "updated", onDone }: { reason?: "first"
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<PublicNotice>("/api/privacy/notice").then(setNotice).catch(() => onDone());
|
||||
(apiV2Data("get", "/privacy/notice") as Promise<PublicNotice>).then(setNotice).catch(() => onDone());
|
||||
}, [onDone]);
|
||||
|
||||
if (!notice) return null;
|
||||
@@ -31,7 +31,7 @@ export function NoticePrompt({ reason = "updated", onDone }: { reason?: "first"
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch("/api/privacy/me/notice/accept", { method: "POST", authToken: token });
|
||||
await apiV2Data("post", "/me/privacy/notice/accept", { authToken: token, body: {} });
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not save. Please try again.");
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Info, Wrench, AlertTriangle, X } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import type { HubNotice } from "@/components/admin/LicenceTab";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const DISMISSED_KEY = "cross_events_dismissed_notices";
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ProviderNotices() {
|
||||
useEffect(() => {
|
||||
if (!canSee || !token) return;
|
||||
setDismissed(readDismissed());
|
||||
apiFetch<HubNotice[]>("/api/hub/notices", { authToken: token }).then(setNotices).catch(() => {});
|
||||
(apiV2Data("get", "/hub/notices", { authToken: token }) as Promise<HubNotice[]>).then(setNotices).catch(() => {});
|
||||
}, [canSee, token]);
|
||||
|
||||
if (!canSee) return null;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// Checks once per browser session whether first-time setup is needed.
|
||||
// If the backend says yes and we're not already on /setup, redirect there.
|
||||
@@ -16,7 +16,7 @@ export function SetupGuard() {
|
||||
if (pathname?.startsWith("/setup")) return;
|
||||
checked.current = true;
|
||||
|
||||
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
|
||||
(apiV2Data("get", "/setup/needed") as Promise<{ needsSetup: boolean }>)
|
||||
.then((data) => {
|
||||
if (data?.needsSetup) {
|
||||
router.replace("/setup");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
export type SiteSettings = {
|
||||
org_name?: string;
|
||||
@@ -51,7 +51,7 @@ export function SiteSettingsProvider({
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await apiFetch<SiteSettings>("/api/settings");
|
||||
const data = await (apiV2Data("get", "/settings") as Promise<SiteSettings>);
|
||||
setSettings(data || {});
|
||||
} catch {
|
||||
// Non-fatal — use defaults
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+4751
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,7 @@ export function restoreAdmin(redirect = true) {
|
||||
export async function endImpersonation() {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token) await fetch(`${API_BASE}/api/admin/impersonate/end`, { method: "POST", headers: { Authorization: `Bearer ${token}` } });
|
||||
if (token) await fetch(`${API_BASE}/api/v2/me/impersonation/end`, { method: "POST", headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch { /* the session expires by itself anyway */ }
|
||||
restoreAdmin();
|
||||
}
|
||||
@@ -69,6 +69,6 @@ export function logImpersonatedPage(path: string) {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (!token) return;
|
||||
fetch(`${API_BASE}/api/admin/impersonate/log`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ path }), keepalive: true }).catch(() => {});
|
||||
fetch(`${API_BASE}/api/v2/me/impersonation/log`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ path }), keepalive: true }).catch(() => {});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ export async function getServerSettings(): Promise<SiteSettings> {
|
||||
// would time out every page, so skip straight to the defaults.
|
||||
if (!API_BASE) return {};
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
const res = await fetch(`${API_BASE}/api/v2/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return {};
|
||||
return await res.json();
|
||||
return (await res.json())?.data || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.14",
|
||||
"version": "1.51.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.14",
|
||||
"version": "1.51.15",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.14",
|
||||
"version": "1.51.15",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user