Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dea712b72 |
@@ -7,6 +7,34 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.48.1] - 2026-10-28
|
||||
|
||||
### Changed
|
||||
|
||||
- **Built-in privacy notice brought up to date** with volunteer rosters (what is kept, reminders,
|
||||
who sees the roster and cover requests), following an event series, season passes, webhooks
|
||||
(connected systems, what they receive, 30-day copies), how people agree to the notice, and the
|
||||
`cross_events_dismissed_notices` browser-storage item. Sites that haven't published their own
|
||||
version show the new text straight away.
|
||||
- **Sites that published their own version** are now told: Admin → Privacy → Privacy notice lists
|
||||
what changed in the built-in text since they last reviewed it, marks each section with new text
|
||||
(**show it** / **Use the new text**), offers **Use all the new text**, and **Add** for built-in
|
||||
sections the notice doesn't have. **Done — hide this** records the review
|
||||
(`privacy_notice_builtin_reviewed`). Their own text is never changed without them; publish (with
|
||||
"Significant change" if people should accept again) to make it live.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Agreeing to the notice when an account was made for you** — activating an account (made by
|
||||
staff, at the door or by whoever registered you) now asks you to agree to the Terms of Use and
|
||||
Privacy Policy, as sign-up does (consent log source "Account activation").
|
||||
- **Never accepted?** Anyone who has never accepted any version (accounts made for them, or from
|
||||
before acceptances were recorded) is asked once after login ("Our privacy notice"), even while
|
||||
the built-in notice is in use. Before, they were only asked after a version marked as a
|
||||
significant change was published.
|
||||
- The "Change my cookie choice" button on the privacy page also shows above a cookie section an
|
||||
admin wrote themselves (any section whose id mentions cookies), not only the built-in one.
|
||||
|
||||
## [1.48.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
+6
-2
@@ -729,8 +729,12 @@ and published, numbered versions (earlier ones stay readable with `?version=N`).
|
||||
Placeholders (`{org}`, `{ioName}`, `{ioEmail}`, `{website}`, `{orgEmail}`,
|
||||
`{medicalRetentionDays}`, `{operators}`) are filled in from Site settings and the
|
||||
operator register. A version marked as a significant change is accepted once by everyone
|
||||
after their next login; sign-up records the version accepted. The contact page shows the
|
||||
Information Officer.
|
||||
after their next login. People agree to the notice at sign-up and when they activate an account
|
||||
made for them; anyone who never has is asked once after login. When the built-in text changes
|
||||
in an update, a site with its own published version sees what changed in the editor, with each
|
||||
changed section marked (**Use the new text**, **Use all the new text**, **Add** missing sections,
|
||||
**Done — hide this**). The "Change my cookie choice" button shows above the cookie section. The
|
||||
contact page shows the Information Officer.
|
||||
|
||||
**Registers and logs** (same admin page):
|
||||
|
||||
|
||||
@@ -12,6 +12,15 @@ 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.48.1
|
||||
|
||||
- **Privacy notice brought up to date** with volunteer rosters, event series, season passes and
|
||||
webhooks. If you haven't published your own version, your site shows the new text straight away.
|
||||
- **Published your own version?** Admin → Privacy → Privacy notice now shows what changed in the
|
||||
built-in text, section by section, so you can take the new wording with one click and publish.
|
||||
- **Everyone agrees to the notice once.** People whose account was made for them agree when they
|
||||
activate it, and anyone who never has is asked once after they next log in.
|
||||
|
||||
## 1.48.0
|
||||
|
||||
- **Webhooks.** Connect Cross Events to other systems — ChurchSuite, Planning Center, a Google
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -65,12 +65,30 @@ const getNoticeDraft = async (req, res) => {
|
||||
placeholders: notice.PLACEHOLDERS,
|
||||
versions: await versionList(),
|
||||
builtIn: notice.defaultSections(),
|
||||
// Changes to the built-in text since this site last reviewed them (only once it publishes its own).
|
||||
builtInUpdates: await notice.builtInUpdates(),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The built-in text changes have been gone through (the editor stops listing them)
|
||||
// @route POST /api/privacy/notice/builtin-reviewed
|
||||
// @access Private/Admin
|
||||
const markBuiltInReviewed = async (req, res) => {
|
||||
try {
|
||||
await notice.markBuiltInReviewed();
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'settings_updated', targetType: 'privacy_notice',
|
||||
metadata: { builtInReviewed: notice.BUILT_IN_REVISION }, ip: getClientIp(req),
|
||||
});
|
||||
res.json({ builtInUpdates: [] });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save the draft (body.sections)
|
||||
// @route PUT /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
@@ -425,6 +443,7 @@ const updateBreach = async (req, res) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
markBuiltInReviewed,
|
||||
getReadiness,
|
||||
getRetention,
|
||||
saveRetention,
|
||||
|
||||
@@ -1114,6 +1114,13 @@ const activateAccount = async (req, res) => {
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true }
|
||||
});
|
||||
|
||||
// The activation form asks them to agree to the terms and privacy notice, as sign-up does.
|
||||
// Without it (an older page) they're asked once after login instead.
|
||||
if (req.body.acceptTerms === true) {
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'activation', ip: getClientIp(req) })
|
||||
.catch(e => console.warn('[activate] notice acceptance not saved:', e?.message || e));
|
||||
}
|
||||
|
||||
// Send welcome email (skip guest/placeholder accounts)
|
||||
if (updated.email && !updated.email.endsWith('@guest.local')) {
|
||||
sendWelcomeEmail(updated).catch(() => {});
|
||||
|
||||
@@ -21,6 +21,7 @@ const {
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
markBuiltInReviewed,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
@@ -64,6 +65,7 @@ router.get('/notice/draft', protect, admin, getNoticeDraft);
|
||||
router.put('/notice/draft', protect, admin, saveNoticeDraft);
|
||||
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
|
||||
router.post('/notice/publish', protect, admin, publishNotice);
|
||||
router.post('/notice/builtin-reviewed', protect, admin, markBuiltInReviewed);
|
||||
|
||||
// Admin: operator register, consent log, access log
|
||||
router.get('/operators', protect, admin, listOperators);
|
||||
|
||||
@@ -24,9 +24,9 @@ const DEFAULT_SECTIONS = [
|
||||
title: 'Plain English Summary',
|
||||
body: [
|
||||
"We respect your privacy — here's what you need to know:",
|
||||
'• We only collect info needed to run events: registrations, payments, tickets, check-in and, for children\'s ministry, kids check-in.',
|
||||
'• We only collect info needed to run events: registrations, payments, tickets, check-in, volunteer rosters and, for children\'s ministry, kids check-in.',
|
||||
"• Your information is kept in this site's own database, run by the hosting provider listed in section 9. It isn't shared with other organisations that use the same software.",
|
||||
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
|
||||
"• We don't sell your data, and only share it with the services listed in section 9 — they process payments and deliver messages for us, and any other system we connect (such as church-management software) gets only the updates we chose.",
|
||||
'• If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.',
|
||||
'• For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.',
|
||||
"• For kids check-in, parents add their children's details once. Allergies, medical and custody notes are encrypted, shown only to kids-ministry staff, and deleted when a child hasn't been checked in for a while (section 7). Children are collected with a pickup code.",
|
||||
@@ -53,6 +53,8 @@ const DEFAULT_SECTIONS = [
|
||||
'• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.',
|
||||
'• Event information: events you register for, ticket selections (including the sessions you chose on multi-session events), discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned, per session), room or group allocations, and completion certificates issued to you.',
|
||||
'• Your home campus, if the organisation has more than one campus and you choose one.',
|
||||
'• Serving (if you volunteer): the roles and shifts you sign up for or are given, requests to pass a shift on and who took it, when you checked in, and the hours you served.',
|
||||
'• Event series you follow, and season passes you buy (a season pass registers you for each event on it, so each shows in your registrations).',
|
||||
"• Kids check-in (if you use it): your family's name, your children's names, dates of birth, gender, allergies, medical notes and custody notes, the adults you allow to collect them (name, phone number, relationship), and each check-in — when, which room, the pickup code, who checked them in and out, and who collected them (see section 8).",
|
||||
'• Details about other people you register (see section 3), parental consent details for youth events, and children at kids check-in (see section 8).',
|
||||
'• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.',
|
||||
@@ -90,6 +92,8 @@ const DEFAULT_SECTIONS = [
|
||||
'• Send optional reminders before sessions, and issue completion certificates (each has a code anyone can use to check it is genuine: the check shows the name on the certificate and the event).',
|
||||
'• Check children in and out at kids check-in, send you their pickup code, and ask you to come to the room if a leader needs you.',
|
||||
'• Show you the events of your home campus first, if the organisation has more than one campus.',
|
||||
'• Run volunteer rosters: remind you the day before you serve, pass your shift on to someone who accepts it when you can\'t make it, and check you in when you arrive.',
|
||||
'• Tell you when a new event in a series you follow comes out. This counts as news, so it\'s only sent if you get news and announcements.',
|
||||
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
|
||||
'• Send you an optional feedback form after an event, with one reminder.',
|
||||
'• Manage user accounts and event attendance records, and give support when you ask for help.',
|
||||
@@ -106,7 +110,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• Payment receipts.',
|
||||
'• Your event tickets (as a PDF document).',
|
||||
'• Security alerts such as new login notifications, password changes, and account closure confirmations, and sign-in codes if you chose WhatsApp for two-factor authentication.',
|
||||
'• Session reminders, if the event sends them.',
|
||||
'• Session reminders, if the event sends them, and volunteer reminders the day before you serve.',
|
||||
'• Kids check-in: your children\'s pickup code, and a request to come to the room if a leader needs you. These never include medical or custody details. If you ask the desk to, the pickup code is also sent to one of your authorised adults.',
|
||||
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
|
||||
'WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages.',
|
||||
@@ -133,6 +137,7 @@ const DEFAULT_SECTIONS = [
|
||||
'We keep personal information only as long as we need it:',
|
||||
'{retention}',
|
||||
"• Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
|
||||
'• Copies of the updates sent to connected systems (webhooks, section 9) are kept for 30 days so problems can be checked, then deleted — and straight away when your account is deleted.',
|
||||
"• Family lists downloaded to a kids check-in desk for offline use stay on that device until the desk is synced and the list is refreshed or removed. Only kids-ministry staff can download them, and each download is recorded.",
|
||||
'You can ask for your account to be deleted at any time from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
].join('\n'),
|
||||
@@ -161,10 +166,12 @@ const DEFAULT_SECTIONS = [
|
||||
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
|
||||
'{operators}',
|
||||
'• Anyone we are required to share it with under South African law.',
|
||||
'If we connect another system — for example our church-management software — through a webhook, it is listed above with what it receives. It gets only the updates we chose (such as new registrations, payments and check-ins), and never children\'s details from kids check-in.',
|
||||
'Within our organisation, people see only what they need:',
|
||||
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
|
||||
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details from parental consent forms are shown to supervisors only.',
|
||||
"• Kids-ministry staff see the children checked in, with their allergies, medical and custody notes, the parents' names and phone numbers, and the adults allowed to collect them.",
|
||||
'• Staff running a volunteer roster see who is serving in each role, their contact details, check-ins and hours. If you ask for someone to take over your shift, your first name, the role and any note you add are shown to the event\x27s other volunteers and others who could take it over.',
|
||||
'• If the organisation has several campuses, a staff member may be limited to their own campuses\' events.',
|
||||
'• Supervisors and administrators manage registrations, payments and communication for events. Every export of personal information (reports, attendance registers, lists) is recorded.',
|
||||
'• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.',
|
||||
@@ -205,6 +212,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• hope_events_scan_device, hope_events_offline_lock and the "hope-events-offline-scan" database — on a staff member\'s scanner in offline mode: the device\'s id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.',
|
||||
'• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.',
|
||||
"• hope_events_kids_event and hope_events_kids_room — on a kids check-in device: the event and room last chosen.",
|
||||
'• cross_events_dismissed_notices — notices from the software provider (such as planned maintenance) that you have closed.',
|
||||
"• hope_events_kids_device and hope_events_kids_offline_… — on a kids check-in desk in offline mode: the device's id, the list of families and children (with allergies, medical and custody notes), pickup codes kept for the device, and check-ins waiting to sync.",
|
||||
'Optional — only if you click "Allow" on the cookie notice:',
|
||||
'• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose "No thanks", nothing is counted and no visit id is created.',
|
||||
@@ -230,6 +238,7 @@ const DEFAULT_SECTIONS = [
|
||||
title: '13. Changes to This Notice',
|
||||
body: [
|
||||
'We may update this notice from time to time. Each update is published as a new version with its date, and earlier versions stay available on this page.',
|
||||
'You are asked to agree to this notice when you create an account, or when you activate an account that was made for you (for example when someone registered you, or at the door). If you have never agreed to it — such as an account from before we asked — we ask once, the next time you log in.',
|
||||
'If a change is significant, we ask you to accept the new version the next time you log in.',
|
||||
].join('\n'),
|
||||
},
|
||||
@@ -253,6 +262,49 @@ const DEFAULT_SECTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Changes to the built-in text, newest last. A site that published its own version keeps its own
|
||||
* text, so the editor lists the changes made since the revision it last reviewed
|
||||
* (`privacy_notice_builtin_reviewed`) and offers the new text section by section.
|
||||
*/
|
||||
const BUILT_IN_UPDATES = [
|
||||
{
|
||||
revision: '1.48.1',
|
||||
summary: [
|
||||
'Volunteer rosters: what is kept about people who serve, reminders, and who sees the roster.',
|
||||
'Following an event series, and season passes.',
|
||||
'Webhooks: other systems the organisation connects, what they receive, and that copies are kept 30 days.',
|
||||
'How people agree to the notice: at sign-up, at account activation, or once after login if they never have.',
|
||||
'A browser-storage item for closed provider notices.',
|
||||
],
|
||||
sections: ['summary', 'information', 'use', 'whatsapp', 'retention', 'sharing', 'cookies', 'changes'],
|
||||
},
|
||||
];
|
||||
const BUILT_IN_REVISION = BUILT_IN_UPDATES[BUILT_IN_UPDATES.length - 1].revision;
|
||||
const REVIEWED_KEY = 'privacy_notice_builtin_reviewed';
|
||||
|
||||
function revisionLess(a, b) {
|
||||
const pa = String(a || '0').split('.').map(Number);
|
||||
const pb = String(b || '0').split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in text changes this site hasn't reviewed yet. None while nothing is published (the site
|
||||
* shows the built-in text, so it is always current).
|
||||
*/
|
||||
async function builtInUpdates(db = prisma) {
|
||||
if (!(await latestNotice(db))) return [];
|
||||
const row = await db.appSetting.findUnique({ where: { key: REVIEWED_KEY } });
|
||||
return BUILT_IN_UPDATES.filter(u => !row?.value || revisionLess(row.value, u.revision));
|
||||
}
|
||||
|
||||
/** The admin has gone through the built-in changes (taken what they want, or not). */
|
||||
async function markBuiltInReviewed(db = prisma) {
|
||||
await db.appSetting.upsert({ where: { key: REVIEWED_KEY }, update: { value: BUILT_IN_REVISION }, create: { key: REVIEWED_KEY, value: BUILT_IN_REVISION } });
|
||||
}
|
||||
|
||||
function defaultSections() {
|
||||
return DEFAULT_SECTIONS.map(s => ({ ...s }));
|
||||
}
|
||||
@@ -340,11 +392,17 @@ async function requiredVersion(db = prisma) {
|
||||
return row ? row.version : null;
|
||||
}
|
||||
|
||||
/** Whether someone must (re-)accept the notice before carrying on. */
|
||||
/**
|
||||
* Whether someone must accept the notice before carrying on, and why: 'first' — they never
|
||||
* accepted any version (an account made for them by staff, at the door or by someone who
|
||||
* registered them, or from before acceptances were recorded); 'updated' — a version marked as a
|
||||
* significant change came out after the one they accepted. false when nothing is needed.
|
||||
*/
|
||||
async function noticePromptPending(user, db = prisma) {
|
||||
if (user.noticeVersionAccepted == null) return 'first';
|
||||
const required = await requiredVersion(db);
|
||||
if (required == null) return false;
|
||||
return (user.noticeVersionAccepted ?? -1) < required;
|
||||
return user.noticeVersionAccepted < required ? 'updated' : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,6 +505,11 @@ async function publish({ sections, changeSummary, requiresAcceptance, publishedB
|
||||
|
||||
module.exports = {
|
||||
PLACEHOLDERS,
|
||||
BUILT_IN_UPDATES,
|
||||
BUILT_IN_REVISION,
|
||||
builtInUpdates,
|
||||
markBuiltInReviewed,
|
||||
revisionLess,
|
||||
defaultSections,
|
||||
cleanSections,
|
||||
noticeContext,
|
||||
|
||||
@@ -60,18 +60,23 @@ describe('cleanSections', () => {
|
||||
});
|
||||
|
||||
describe('noticePromptPending', () => {
|
||||
test('nobody is asked while no version needs re-acceptance', async () => {
|
||||
test('someone who never accepted any version is asked once (first)', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(false);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe('first');
|
||||
});
|
||||
|
||||
test('nobody else is asked while no version needs re-acceptance', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
test('asked when they accepted an older version than the newest one that needs it', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 3 });
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe('updated');
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe('updated');
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 3 })).toBe(false);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 5 })).toBe(false);
|
||||
expect(prisma.privacyNotice.findFirst.mock.calls[0][0].where).toEqual({ requiresAcceptance: true });
|
||||
expect(prisma.privacyNotice.findFirst.mock.calls.at(-1)[0].where).toEqual({ requiresAcceptance: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,3 +112,32 @@ describe('reportRowCount', () => {
|
||||
expect(reportRowCount(null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changes to the built-in text', () => {
|
||||
test('a site that never published shows the built-in text, so there is nothing to review', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.builtInUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
test('a site with its own version is shown the changes it has not reviewed', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 2 });
|
||||
prisma.appSetting.findUnique.mockResolvedValue(null);
|
||||
expect((await notice.builtInUpdates()).map(u => u.revision)).toContain('1.48.1');
|
||||
prisma.appSetting.findUnique.mockResolvedValue({ value: notice.BUILT_IN_REVISION });
|
||||
expect(await notice.builtInUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
test('every changed section exists in the built-in text', () => {
|
||||
const ids = notice.defaultSections().map(s => s.id);
|
||||
for (const u of notice.BUILT_IN_UPDATES) for (const id of u.sections) expect(ids).toContain(id);
|
||||
expect(notice.revisionLess('1.47.9', '1.48.1')).toBe(true);
|
||||
expect(notice.revisionLess('1.48.10', '1.48.9')).toBe(false);
|
||||
});
|
||||
|
||||
test('the built-in text covers the features since 1.46', () => {
|
||||
const all = notice.defaultSections().map(s => s.body).join('\n');
|
||||
for (const words of ['volunteer', 'series you follow', 'season pass', 'webhook', 'cross_events_dismissed_notices', 'activate an account']) {
|
||||
expect([words, all.toLowerCase().includes(words.toLowerCase())]).toEqual([words, true]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+11
-2
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.48.0** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-28, after **1.48.1** (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).
|
||||
|
||||
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
|
||||
| 1.48.0 | **Outbound webhooks** (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration `20261028160000_webhooks` |
|
||||
| 1.47.4 | Fix: an update request for a version that has since been overtaken shows as such, with **Ask for … instead** (and the banner again). Hub 1.5.0 moves the open request on (`changed`); with an older Hub `requestUpdate` withdraws and asks again |
|
||||
| 1.47.3 | **Season passes** (§6 part 4): passes on the Event series page, buy on the series page (a registration per event, one checkout), price split by normal prices, one Payment per event from the webhook. Migration `20261028150000_season_pass` |
|
||||
@@ -168,7 +169,15 @@ a test server (both done 2026-10-28).
|
||||
(renders whatever `/api/privacy/notice` returns), admin `app/dashboard/admin/privacy` +
|
||||
`components/admin/privacy/*`, prompt `components/shared/NoticePrompt.tsx` (via
|
||||
`PrivacyPrompts.tsx`: notice first, then the news question). **Changing the built-in text
|
||||
changes the live notice only for installs that haven't published a version yet.**
|
||||
changes the live notice only for installs that haven't published a version yet** — so every
|
||||
change to `DEFAULT_SECTIONS` must also add an entry to `BUILT_IN_UPDATES` (the release, a summary,
|
||||
the section ids changed). Sites with their own version then see it in the editor until they press
|
||||
"Done — hide this" (AppSetting `privacy_notice_builtin_reviewed`). A test checks the ids exist.
|
||||
- Accepting the notice: sign-up (`source: 'signup'`), account activation (`'activation'`, the form's
|
||||
terms box) and the after-login prompt (`'prompt'`). `noticePromptPending` is `'first'` for anyone
|
||||
who never accepted (`noticeVersionAccepted` null) and `'updated'` when a significant version came
|
||||
out later. The e2e seed sets `noticeVersionAccepted: 0` so the prompt doesn't cover every test.
|
||||
- The cookie button on the privacy page shows above any section whose id contains "cookie".
|
||||
- Access log: `utils/accessLog.js` (`logDataAccess(req, { action, what, eventId, records })`).
|
||||
**Any new bulk export or list of personal data should call it** and get a label in
|
||||
`ACCESS_ACTIONS`. Operators: `Operator` table, seeded in the 1.39.0 migration.
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Seed = {
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
unsubscribeToken: string;
|
||||
newsPromptUser: { id: string; token: string };
|
||||
noticeFirstUser: { id: string; token: string };
|
||||
retention: { toWarn: string; toAnonymise: string };
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ test.describe("privacy notice, operators and access log (POPIA)", () => {
|
||||
// A version that needs accepting is published below; accept it for every seeded account
|
||||
// afterwards so later test files don't meet the prompt.
|
||||
test.afterAll(async ({ request }) => {
|
||||
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token];
|
||||
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token, seed().noticeFirstUser.token];
|
||||
for (const token of tokens) {
|
||||
await request.post(`${API}/api/privacy/me/notice/accept`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
}
|
||||
@@ -55,6 +55,52 @@ test.describe("privacy notice, operators and access log (POPIA)", () => {
|
||||
await api(page, "admin", "DELETE", `/api/privacy/operators/${op.id}`);
|
||||
});
|
||||
|
||||
test("after publishing its own version, the editor points out new built-in text", async ({ page }) => {
|
||||
// The published version came from the built-in text; put an older wording back in one changed
|
||||
// section so it shows as having new text.
|
||||
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
|
||||
const sections = draft.sections.map(s => (s.id === "changes" ? { ...s, body: "We may update this notice from time to time." } : s));
|
||||
await api(page, "admin", "PUT", "/api/privacy/notice/draft", { sections });
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/privacy?tab=notice");
|
||||
const banner = page.getByTestId("builtin-updates");
|
||||
await expect(banner).toContainText("The built-in privacy notice has been updated");
|
||||
await expect(banner).toContainText("How people agree to the notice");
|
||||
const marked = page.getByTestId("section-has-new-text");
|
||||
await expect(marked).toHaveCount(1);
|
||||
await marked.getByText("New built-in text for this section — show it").click();
|
||||
await marked.getByRole("button", { name: "Use the new text" }).click();
|
||||
await expect(page.getByTestId("section-has-new-text")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Save draft" }).click();
|
||||
await expect(page.getByText("Draft saved.")).toBeVisible();
|
||||
|
||||
await banner.getByRole("button", { name: "Done — hide this" }).click();
|
||||
await expect(page.getByTestId("builtin-updates")).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("1. Introduction text")).toBeVisible();
|
||||
await expect(page.getByTestId("builtin-updates")).toHaveCount(0);
|
||||
await api(page, "admin", "DELETE", "/api/privacy/notice/draft");
|
||||
});
|
||||
|
||||
test("someone who never accepted the notice is asked once, after login", async ({ page }) => {
|
||||
await page.addInitScript(token => localStorage.setItem("hope_events_token", token), seed().noticeFirstUser.token);
|
||||
await page.goto("/dashboard/user");
|
||||
const dialog = page.getByRole("dialog", { name: "Our privacy notice" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("It explains what we keep about you");
|
||||
await dialog.getByRole("button", { name: "I accept" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.reload();
|
||||
await expect(page.getByText("My Events").first()).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Our privacy notice" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the cookie button stays on a published notice", async ({ page }) => {
|
||||
await page.goto("/legal/privacy");
|
||||
await expect(page.getByRole("button", { name: "Change my cookie choice" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a significant change is accepted once, after login", async ({ page }) => {
|
||||
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
|
||||
// Re-acceptance needs a summary of what changed.
|
||||
|
||||
@@ -48,10 +48,10 @@ async function seed(url) {
|
||||
});
|
||||
|
||||
const password = await hashPassword('E2e-password-123');
|
||||
// Opted in to news and already asked (newsAskedAt), so the one-time news question
|
||||
// doesn't cover the dashboards in every test.
|
||||
// Opted in to news and already asked (newsAskedAt), and accepted the built-in privacy notice
|
||||
// (version 0), so neither one-time question covers the dashboards in every test.
|
||||
const mk = (role, name, email, phoneNumber) => prisma.user.create({
|
||||
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date(), newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date() },
|
||||
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date(), newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date(), noticeVersionAccepted: 0 },
|
||||
});
|
||||
const users = {
|
||||
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
|
||||
@@ -195,7 +195,12 @@ async function seed(url) {
|
||||
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
|
||||
// An account that has never answered the news question (asked once after login).
|
||||
newsPromptUser: await (async () => {
|
||||
const u = await prisma.user.create({ data: { name: 'Nomsa New', email: 'prompt@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true } });
|
||||
const u = await prisma.user.create({ data: { name: 'Nomsa New', email: 'prompt@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true, noticeVersionAccepted: 0 } });
|
||||
return { id: u.id, token: generateToken(u.id, u.role, 0) };
|
||||
})(),
|
||||
// An account made for someone (never accepted the privacy notice): asked once after login.
|
||||
noticeFirstUser: await (async () => {
|
||||
const u = await prisma.user.create({ data: { name: 'Pieter Placed', email: 'placed@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true, newsAskedAt: new Date() } });
|
||||
return { id: u.id, token: generateToken(u.id, u.role, 0) };
|
||||
})(),
|
||||
// Retention: two accounts untouched for six years — one not yet warned, one warned 40 days
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import React, { Suspense, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -16,8 +17,11 @@ function ActivateAccountContent() {
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// An account made for them (by staff, at the door, or by whoever registered them): they agree
|
||||
// to the terms and privacy notice here, as at sign-up.
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(() => password.length >= 8 && password === confirm && !!token, [password, confirm, token]);
|
||||
const canSubmit = useMemo(() => password.length >= 8 && password === confirm && !!token && accepted, [password, confirm, token, accepted]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -25,7 +29,7 @@ function ActivateAccountContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password } });
|
||||
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password, acceptTerms: accepted } });
|
||||
// Auto-login: save the session token (under the key AuthContext reads) and load the
|
||||
// dashboard fresh so the new session is picked up.
|
||||
if (data?.token) saveToken(data.token);
|
||||
@@ -64,6 +68,14 @@ function ActivateAccountContent() {
|
||||
{password && confirm && password !== confirm && (
|
||||
<p className="text-xs text-red-600">Passwords do not match.</p>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
<input id="terms" type="checkbox" checked={accepted} onChange={e => setAccepted(e.target.checked)} className="mt-1 h-4 w-4 border-gray-300 rounded accent-brand-600" required />
|
||||
<label htmlFor="terms" className="text-sm text-gray-700 leading-snug">
|
||||
I confirm that I've read and agree to the{" "}
|
||||
<Link href="/legal/terms" target="_blank" className="text-brand-600 hover:underline">Terms of Use</Link> and{" "}
|
||||
<Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">Privacy Policy</Link>.
|
||||
</label>
|
||||
</div>
|
||||
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60 w-full py-2.5 font-medium">
|
||||
{loading ? "Activating…" : "Activate my account"}
|
||||
</button>
|
||||
|
||||
@@ -24,6 +24,7 @@ type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
signup: "when you signed up",
|
||||
activation: "when you activated your account",
|
||||
privacy_centre: "in your privacy settings",
|
||||
prompt: "when we asked after logging in",
|
||||
unsubscribe_link: "with an unsubscribe link",
|
||||
|
||||
@@ -28,7 +28,7 @@ type ParentalRow = { id: string; at: string | null; child: string; event: string
|
||||
type LogResponse<T> = { rows: T[]; total: number; pageSize: number };
|
||||
|
||||
const SOURCE: Record<string, string> = {
|
||||
signup: "Sign-up", privacy_centre: "Privacy page", prompt: "Asked after login", unsubscribe_link: "Unsubscribe link",
|
||||
signup: "Sign-up", activation: "Account activation", privacy_centre: "Privacy page", prompt: "Asked after login", unsubscribe_link: "Unsubscribe link",
|
||||
whatsapp_stop: "Replied STOP", existing_account: "Existing account (1.38.0)", admin: "Administrator",
|
||||
};
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine } from "lucide-react";
|
||||
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";
|
||||
|
||||
type Version = { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean; publishedBy: string | null; acceptances: number };
|
||||
type Draft = { sections: NoticeSection[]; rendered: NoticeSection[]; isDraft: boolean; savedAt: string | null; versions: Version[]; builtIn: NoticeSection[] };
|
||||
// Changes to the built-in text since this site last reviewed them (only once it has published its own).
|
||||
type BuiltInUpdate = { revision: string; summary: string[]; sections: string[] };
|
||||
type Draft = { sections: NoticeSection[]; rendered: NoticeSection[]; isDraft: boolean; savedAt: string | null; versions: Version[]; builtIn: NoticeSection[]; builtInUpdates?: BuiltInUpdate[] };
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
|
||||
const PLACEHOLDER_HELP: [string, string][] = [
|
||||
@@ -106,10 +108,45 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
// The built-in text changed since this site last looked: hide the list once the admin has been through it.
|
||||
const markReviewed = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch("/api/privacy/notice/builtin-reviewed", { method: "POST", authToken: token });
|
||||
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) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!draft) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
|
||||
const builtInById = new Map(draft.builtIn.map(s => [s.id, s]));
|
||||
const current = draft.versions[0];
|
||||
const updates = draft.builtInUpdates || [];
|
||||
const updatedIds = new Set(updates.flatMap(u => u.sections));
|
||||
const differs = (s: NoticeSection) => { const b = builtInById.get(s.id); return !!b && (b.body !== s.body || b.title !== s.title); };
|
||||
// Built-in sections this notice doesn't have (removed, or new in the built-in text).
|
||||
const missing = draft.builtIn.filter(b => !sections.some(s => s.id === b.id));
|
||||
const pendingIds = sections.filter(s => updatedIds.has(s.id) && differs(s)).map(s => s.id);
|
||||
/** Puts a built-in section back where it sits in the built-in text. */
|
||||
const addBuiltIn = (b: NoticeSection) => {
|
||||
const order = draft.builtIn.map(x => x.id);
|
||||
const after = order.slice(0, order.indexOf(b.id)).reverse().find(id => sections.some(s => s.id === id));
|
||||
const at = after ? sections.findIndex(s => s.id === after) + 1 : 0;
|
||||
change([...sections.slice(0, at), { ...b }, ...sections.slice(at)]);
|
||||
};
|
||||
const useAllNew = () => {
|
||||
if (!confirm("Replace every changed section with the new built-in text? Your own wording in those sections is replaced (other sections stay as they are). Nothing is public until you publish.")) return;
|
||||
const next = sections.map(s => {
|
||||
const b = updatedIds.has(s.id) ? builtInById.get(s.id) : undefined;
|
||||
return b ? { ...b } : s;
|
||||
});
|
||||
change(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -148,6 +185,24 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
{dirty && <span className="self-center text-xs text-amber-700">Unsaved changes</span>}
|
||||
</div>
|
||||
|
||||
{updates.length > 0 && (
|
||||
<div className="border border-amber-200 bg-amber-50 rounded-xl p-4 text-sm text-amber-900 space-y-2" data-testid="builtin-updates">
|
||||
<div className="flex items-center gap-2 font-medium"><Sparkles className="w-4 h-4" /> The built-in privacy notice has been updated</div>
|
||||
<p>Your published notice keeps your own text, so the new wording from Cross Events {updates.map(u => u.revision).join(", ")} isn't in it yet:</p>
|
||||
<ul className="list-disc pl-5">{updates.flatMap(u => u.summary).map((line, i) => <li key={i}>{line}</li>)}</ul>
|
||||
<p>
|
||||
{pendingIds.length > 0
|
||||
? <>Sections with new text are marked below. Use the new text where you want it (it replaces your wording in that section), then save the draft and publish.</>
|
||||
: <>Your draft already has the new text in every changed section. Save the draft and publish to make it live.</>}
|
||||
{" "}Tick "Significant change" when publishing if people should accept it again.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pendingIds.length > 0 && <button onClick={useAllNew} disabled={busy} className="px-3 py-1.5 rounded-lg bg-amber-600 text-white text-sm font-medium hover:bg-amber-700 disabled:opacity-50">Use all the new text</button>}
|
||||
<button onClick={markReviewed} disabled={busy} className="px-3 py-1.5 rounded-lg border border-amber-300 bg-white text-sm hover:bg-amber-100 disabled:opacity-50">Done — hide this</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
|
||||
|
||||
{publishing && (
|
||||
@@ -182,8 +237,9 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
<div className="space-y-3">
|
||||
{sections.map((s, i) => {
|
||||
const original = builtInById.get(s.id);
|
||||
const hasNew = updatedIds.has(s.id) && differs(s);
|
||||
return (
|
||||
<div key={`${s.id}-${i}`} className="border rounded-xl p-3 bg-white">
|
||||
<div key={`${s.id}-${i}`} className={`border rounded-xl p-3 bg-white ${hasNew ? "border-amber-300" : ""}`} data-testid={hasNew ? "section-has-new-text" : undefined}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input className={`${inputCls} font-medium`} value={s.title} onChange={e => update(i, { title: e.target.value })} aria-label="Section title" />
|
||||
<button onClick={() => move(i, -1)} disabled={i === 0} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move up"><ArrowUp className="w-4 h-4" /></button>
|
||||
@@ -194,9 +250,27 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
<button onClick={() => confirm(`Remove "${s.title}"?`) && change(sections.filter((_, j) => j !== i))} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label="Remove section"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<textarea className={`${inputCls} font-mono text-xs`} rows={Math.min(18, Math.max(3, s.body.split("\n").length + 1))} value={s.body} onChange={e => update(i, { body: e.target.value })} aria-label={`${s.title} text`} />
|
||||
{hasNew && original && (
|
||||
<details className="mt-2 text-xs">
|
||||
<summary className="cursor-pointer text-amber-800 font-medium">New built-in text for this section — show it</summary>
|
||||
<pre className="mt-2 whitespace-pre-wrap rounded-lg bg-amber-50 border border-amber-200 p-2 font-mono text-[11px] text-gray-700">{original.body}</pre>
|
||||
<button onClick={() => update(i, { title: original.title, body: original.body })} className="mt-2 px-3 py-1.5 rounded-lg bg-amber-600 text-white hover:bg-amber-700">Use the new text</button>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{missing.length > 0 && (
|
||||
<div className="border border-dashed rounded-xl p-3 text-sm text-gray-600 space-y-1">
|
||||
<div className="font-medium text-gray-800">Built-in sections not in your notice</div>
|
||||
{missing.map(b => (
|
||||
<div key={b.id} className="flex items-center justify-between gap-2">
|
||||
<span>{b.title}{updatedIds.has(b.id) && <span className="ml-1 text-xs text-amber-700">(new text)</span>}</span>
|
||||
<button onClick={() => addBuiltIn(b)} className="inline-flex items-center gap-1 px-2 py-1 rounded border bg-white text-xs hover:bg-gray-50"><Plus className="w-3.5 h-3.5" /> Add</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => change([...sections, { id: `section-${sections.length + 1}`, title: "New section", body: "" }])} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm hover:bg-gray-50">
|
||||
<Plus className="w-4 h-4" /> Add section
|
||||
</button>
|
||||
|
||||
@@ -9,11 +9,12 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { PublicNotice } from "@/components/shared/PrivacyNoticeSection";
|
||||
|
||||
/**
|
||||
* POPIA: a privacy notice version marked as a significant change has been published, and
|
||||
* this person accepted an older one. Asked once after login; it can't be waved away, but the
|
||||
* notice and the Privacy page (to delete the account instead) stay reachable.
|
||||
* POPIA: asked once after login when this person never accepted the privacy notice (an account
|
||||
* made for them, or from before acceptances were recorded) — reason "first" — or a version marked
|
||||
* as a significant change came out after the one they accepted ("updated"). It can't be waved
|
||||
* away, but the notice and the Privacy page (to delete the account instead) stay reachable.
|
||||
*/
|
||||
export function NoticePrompt({ onDone }: { onDone: () => void }) {
|
||||
export function NoticePrompt({ reason = "updated", onDone }: { reason?: "first" | "updated"; onDone: () => void }) {
|
||||
const { token } = useAuth();
|
||||
const [notice, setNotice] = useState<PublicNotice | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -40,7 +41,7 @@ export function NoticePrompt({ onDone }: { onDone: () => void }) {
|
||||
};
|
||||
|
||||
// The newest version that needed accepting, for "what changed".
|
||||
const changes = notice.versions.find(v => v.requiresAcceptance)?.changeSummary || notice.changeSummary;
|
||||
const changes = reason === "updated" ? notice.versions.find(v => v.requiresAcceptance)?.changeSummary || notice.changeSummary : null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-labelledby="notice-prompt-title">
|
||||
@@ -49,7 +50,7 @@ export function NoticePrompt({ onDone }: { onDone: () => void }) {
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-brand-50 text-brand-600 shrink-0">
|
||||
<ScrollText className="w-5 h-5" />
|
||||
</div>
|
||||
<h2 id="notice-prompt-title" className="text-lg font-semibold text-gray-900">We've updated our privacy notice</h2>
|
||||
<h2 id="notice-prompt-title" className="text-lg font-semibold text-gray-900">{reason === "first" ? "Our privacy notice" : <>We've updated our privacy notice</>}</h2>
|
||||
</div>
|
||||
{changes && (
|
||||
<div className="mb-3 p-3 rounded-lg bg-gray-50 border text-sm text-gray-700 whitespace-pre-line">
|
||||
@@ -57,6 +58,7 @@ export function NoticePrompt({ onDone }: { onDone: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mb-5">
|
||||
{reason === "first" && <>It explains what we keep about you and why, and your rights. </>}
|
||||
Please read the <Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">privacy notice</Link> and
|
||||
accept it to carry on using your account. If you don't agree, you can{" "}
|
||||
<Link href="/dashboard/user/privacy" className="text-brand-600 hover:underline">delete your account</Link> instead.
|
||||
|
||||
@@ -42,7 +42,8 @@ export function PrivacyNoticeSection({ section, first = false }: { section: Noti
|
||||
) : (
|
||||
<h3 className="text-xl font-semibold text-brand-600 mb-3">{section.title}</h3>
|
||||
)}
|
||||
{section.id === "cookies" && (
|
||||
{/* Above the cookie section: the built-in "cookies" one, or one an admin wrote about cookies. */}
|
||||
{/cookie/i.test(section.id) && (
|
||||
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
|
||||
Change my cookie choice
|
||||
</CookieSettingsButton>
|
||||
|
||||
@@ -18,6 +18,6 @@ export function PrivacyPrompts() {
|
||||
const finishNotice = useCallback(() => setNoticeDone(true), []);
|
||||
|
||||
if (pathname === "/dashboard/user/privacy") return null;
|
||||
if (user?.noticePromptPending && !noticeDone) return <NoticePrompt onDone={finishNotice} />;
|
||||
if (user?.noticePromptPending && !noticeDone) return <NoticePrompt reason={user.noticePromptPending} onDone={finishNotice} />;
|
||||
return <NewsPrompt />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye, Gauge, Timer, AlertTriangle } from "lucide-react";
|
||||
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye, Gauge, Timer, AlertTriangle, Sparkles, Users } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -79,6 +79,12 @@ export const adminPrivacyHelpContent: HelpContent = {
|
||||
<GuideItem icon={ScrollText} title="Editing" tone="brand">
|
||||
The notice starts from the built-in text. Change any section, add or remove sections, and save a draft — nothing is public until you publish. Placeholders like {"{org}"}, {"{ioName}"} and {"{operators}"} are filled in from Site settings and the operator register, so they stay up to date.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Sparkles} title="When the built-in text is updated" tone="violet">
|
||||
Until you publish your own version, the site shows the built-in text, so updates appear by themselves. Once you have published, your text stays as it is — after an update a yellow box lists what changed and marks the sections with new text. Show it, use it section by section (or all at once), save and publish. "Done — hide this" when you've been through it.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="How people agree to it" tone="blue">
|
||||
At sign-up, and when they activate an account that was made for them (by staff, at the door or by whoever registered them). Anyone who has never agreed is asked once after they log in. Every agreement is in the consent log.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Send} title="Publishing" tone="green">
|
||||
Each publish is a numbered version with its date, and earlier versions stay readable on the privacy page. Tick "Significant change" and say what changed when people should accept it again — they're asked once, after they next log in. Leave it unticked for small fixes.
|
||||
</GuideItem>
|
||||
|
||||
@@ -12,7 +12,8 @@ export type AuthUser = {
|
||||
// POPIA: hasn't answered the "news and announcements" opt-in question yet (asked once).
|
||||
newsPromptPending?: boolean;
|
||||
// POPIA: a privacy notice version they must accept has been published since they last did.
|
||||
noticePromptPending?: boolean;
|
||||
// Why the privacy notice must be accepted after login: never accepted, or a newer version needs it.
|
||||
noticePromptPending?: false | "first" | "updated";
|
||||
// Multi-campus: their home campus (the events page starts on it).
|
||||
homeCampusId?: string | null;
|
||||
// Campus-scoped staff: the campuses they're limited to (null = every campus).
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user