diff --git a/apps/admin-server/src/components/ui/sidenav-project.tsx b/apps/admin-server/src/components/ui/sidenav-project.tsx index 584dc8ad7..3be895885 100644 --- a/apps/admin-server/src/components/ui/sidenav-project.tsx +++ b/apps/admin-server/src/components/ui/sidenav-project.tsx @@ -31,11 +31,11 @@ export function SidenavProject({ className }: { className?: string }) { className )}>
- +
@@ -213,6 +213,20 @@ export function SidenavProject({ className }: { className?: string }) { )} + {HasAccess(sessionData) && ( + + + + )} ) : null} diff --git a/apps/admin-server/src/pages/projects/[project]/api-tokens.tsx b/apps/admin-server/src/pages/projects/[project]/settings/api-tokens.tsx similarity index 97% rename from apps/admin-server/src/pages/projects/[project]/api-tokens.tsx rename to apps/admin-server/src/pages/projects/[project]/settings/api-tokens.tsx index 5befaec0d..95ae27381 100644 --- a/apps/admin-server/src/pages/projects/[project]/api-tokens.tsx +++ b/apps/admin-server/src/pages/projects/[project]/settings/api-tokens.tsx @@ -1,12 +1,11 @@ import { ApiTokenStatusBadge } from '@/components/api-token-status-badge'; import { Button } from '@/components/ui/button'; +import { PageLayout } from '@/components/ui/page-layout'; import { ListHeading, Paragraph } from '@/components/ui/typography'; import { ApiToken, useProjectApiTokens } from '@/hooks/use-api-tokens'; import { useRouter } from 'next/router'; import toast from 'react-hot-toast'; -import { PageLayout } from '../../../components/ui/page-layout'; - function maskToken(token: ApiToken) { return `${token.tokenPrefix}...${token.lastFour}`; } @@ -51,7 +50,7 @@ export default function ProjectApiTokens() { }, { name: 'API-tokens', - url: `/projects/${project}/api-tokens`, + url: `/projects/${project}/settings/api-tokens`, }, ]}>
diff --git a/apps/admin-server/src/pages/projects/[project]/settings/data-scope-catalog.ts b/apps/admin-server/src/pages/projects/[project]/settings/data-scope-catalog.ts index 8b319a7f2..2fd9b5730 100644 --- a/apps/admin-server/src/pages/projects/[project]/settings/data-scope-catalog.ts +++ b/apps/admin-server/src/pages/projects/[project]/settings/data-scope-catalog.ts @@ -72,4 +72,14 @@ export const DATA_SCOPE_COMPONENTS = { label: 'Keuzewijzer vragen', personalFields: [], }, + // Dedicated opt-in for the anonymized/aggregated participant endpoints + // (/reports/users/anonymized, /reports/users/aggregates). Deliberately + // separate from every other component's toggle: those endpoints return a + // project-wide participant roster across ALL data sources, so enabling e.g. + // only 'votes' reporting must NOT also unlock this. No personal fields to + // opt into — the exposed shape is fixed (pseudonymized id, role, timestamps). + users: { + label: 'Deelnemers (geanonimiseerd)', + personalFields: [], + }, } as const; diff --git a/apps/admin-server/src/pages/projects/[project]/settings/data-scope.tsx b/apps/admin-server/src/pages/projects/[project]/settings/data-scope.tsx index faa6e7f2a..182036491 100644 --- a/apps/admin-server/src/pages/projects/[project]/settings/data-scope.tsx +++ b/apps/admin-server/src/pages/projects/[project]/settings/data-scope.tsx @@ -17,14 +17,60 @@ import { zodResolver } from '@hookform/resolvers/zod'; import * as Switch from '@radix-ui/react-switch'; import { AlertTriangle } from 'lucide-react'; import { useRouter } from 'next/router'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; import toast from 'react-hot-toast'; import * as z from 'zod'; import { useProject } from '../../../../hooks/use-project'; +import { Widget, useWidgetsHook } from '../../../../hooks/use-widgets'; import { DATA_SCOPE_COMPONENTS } from './data-scope-catalog'; +// Confirmation/meta config keys (packages/enquete's Confirmation type) — never +// treated as a form field, even if a stray config item happens to collide with +// one of these names. MUST stay in sync with CONTROL_FIELD_KEYS in +// apps/api-server/src/lib/reporting/flatten-submission.js (the backend source +// of truth that actually enforces it) — kept here only so the admin UI doesn't +// offer a toggle for a field the backend would silently ignore anyway. +const CONTROL_FIELD_KEYS = new Set([ + 'confirmationUser', + 'userEmailAddress', + 'confirmationAdmin', + 'overwriteEmailAddress', +]); + +type FormFieldOption = { key: string; label: string }; + +/** + * Unions the form-item field keys across every widget of the given type + * (e.g. 'enquete' for submissions, 'choiceguide' for choice guides), so the + * per-field opt-in list reflects every field the project's forms can produce + * — matching the union approach the reporting endpoints themselves use + * (see apps/api-server/src/routes/api/reports/submissions.js). + */ +function getWidgetFormFields( + widgets: Widget[] | undefined, + widgetType: string +): FormFieldOption[] { + const seen = new Set(); + const out: FormFieldOption[] = []; + + for (const widget of widgets || []) { + if (widget.type !== widgetType) continue; + const items = (widget.config as any)?.items; + if (!Array.isArray(items)) continue; + + for (const item of items) { + const key = item?.fieldKey || item?.key; + if (!key || CONTROL_FIELD_KEYS.has(key) || seen.has(key)) continue; + seen.add(key); + out.push({ key, label: item.title || key }); + } + } + + return out; +} + // Single source of truth for labels/fields lives in data-scope-catalog.ts; // the personalField keys are kept in sync with the backend catalog // (packages/lib/report-data-scope.js) by a parity test. @@ -35,6 +81,12 @@ type ComponentKey = keyof typeof COMPONENTS; const componentSchema = z.object({ enabled: z.boolean().default(false), personalFields: z.array(z.string()).default([]), + // Per-field opt-in for dynamic form content. Only meaningful for + // 'submissions' (formFields, backed by enquete widget items) and + // 'choiceguides' (answerFields, backed by choiceguide widget items) — an + // empty array on every other component is harmless. + formFields: z.array(z.string()).default([]), + answerFields: z.array(z.string()).default([]), }); const formSchema = z.object({ @@ -46,10 +98,79 @@ const formSchema = z.object({ projects: componentSchema, choiceguideguides: componentSchema, choiceguidequestions: componentSchema, + users: componentSchema, }); type FormValues = z.infer; +/** + * Reusable checkbox list bound to `${key}.${fieldName}` (a string[] form + * field). Used for the static personalFields catalog as well as the dynamic + * formFields/answerFields lists sourced from the project's widgets. + */ +function FieldCheckboxGroup({ + control, + keyName, + fieldName, + sectionLabel, + options, +}: { + control: any; + keyName: ComponentKey; + fieldName: 'personalFields' | 'formFields' | 'answerFields'; + sectionLabel: string; + options: readonly FormFieldOption[]; +}) { + if (options.length === 0) return null; + + return ( +
+

+ {sectionLabel} +

+ ( + + {options.map((opt) => ( + { + const currentValues: string[] = field.value || []; + return ( + + + { + const next = checked + ? [...currentValues, opt.key] + : currentValues.filter((v) => v !== opt.key); + field.onChange(next); + }} + /> + + + {opt.label} + + + ); + }} + /> + ))} + + + )} + /> +
+ ); +} + function buildDefaults(dataScopeConfig: any): FormValues { return (Object.keys(COMPONENTS) as ComponentKey[]).reduce( (acc, key) => ({ @@ -57,6 +178,8 @@ function buildDefaults(dataScopeConfig: any): FormValues { [key]: { enabled: dataScopeConfig?.[key]?.enabled ?? false, personalFields: dataScopeConfig?.[key]?.personalFields ?? [], + formFields: dataScopeConfig?.[key]?.formFields ?? [], + answerFields: dataScopeConfig?.[key]?.answerFields ?? [], }, }), {} as FormValues @@ -67,6 +190,16 @@ export default function ProjectSettingsDataScope() { const router = useRouter(); const { project } = router.query; const { data, error, isLoading, updateProject } = useProject(); + const { data: widgets } = useWidgetsHook(project as string); + + const enqueteFormFields = useMemo( + () => getWidgetFormFields(widgets, 'enquete'), + [widgets] + ); + const choiceguideAnswerFields = useMemo( + () => getWidgetFormFields(widgets, 'choiceguide'), + [widgets] + ); const defaults = useCallback( () => buildDefaults(data?.config?.dataScope), @@ -90,7 +223,12 @@ export default function ProjectSettingsDataScope() { ...acc, [key]: values[key].enabled ? values[key] - : { enabled: false, personalFields: [] }, + : { + enabled: false, + personalFields: [], + formFields: [], + answerFields: [], + }, }), {} as FormValues ); @@ -188,56 +326,34 @@ export default function ProjectSettingsDataScope() { )} /> - {enabled && def.personalFields.length > 0 && ( -
-

- Optionele persoonsvelden (gepseudonimiseerd) -

- ( - - {def.personalFields.map((pf) => ( - { - const currentValues: string[] = - field.value || []; - return ( - - - { - const next = checked - ? [...currentValues, pf.key] - : currentValues.filter( - (v) => v !== pf.key - ); - field.onChange(next); - }} - /> - - - {pf.label} - - - ); - }} - /> - ))} - - - )} - /> -
+ {enabled && ( + + )} + + {enabled && key === 'submissions' && ( + + )} + + {enabled && key === 'choiceguides' && ( + )}
); diff --git a/apps/api-server/src/lib/reporting/serialize.test.js b/apps/api-server/src/lib/reporting/serialize.test.js index 474ba1cd7..482f6c140 100644 --- a/apps/api-server/src/lib/reporting/serialize.test.js +++ b/apps/api-server/src/lib/reporting/serialize.test.js @@ -57,7 +57,6 @@ describe('serializeRecord', () => { budget: 'DECIMAL', tags: 'JSON', location: 'JSON', - status: 'STRING', widgetId: 'INTEGER', }; @@ -98,7 +97,6 @@ describe('serializeRecord', () => { { enabledPersonalFields: [] }, { fieldTypes } ); - expect(out).toHaveProperty('status', null); expect(out).toHaveProperty('budget', null); expect(out).toHaveProperty('location', null); }); diff --git a/apps/api-server/src/middleware/api-token-scope-guard.js b/apps/api-server/src/middleware/api-token-scope-guard.js index 73e5a6009..d184d0834 100644 --- a/apps/api-server/src/middleware/api-token-scope-guard.js +++ b/apps/api-server/src/middleware/api-token-scope-guard.js @@ -13,15 +13,23 @@ const MUTATING_GET_SEGMENTS = ['/toggle', '/confirm', '/like', '/dislike']; // Non-component path segments a reporting token may still reach. Everything // else is denied (fail-closed). Only aggregate/stats endpoints belong here. +const OVERVIEW_SEGMENT = 'overview'; + // ADDITIVE (#442): /reports/users/anonymized + /reports/users/aggregates are // deliberately NOT a normal report-data-scope component — there is no raw // user-id/PII exposure to gate per-field (rows are hand-built with a fixed, -// pseudonymized field set; see users-anonymized.js), so they belong here -// alongside 'overview' rather than in the COMPONENTS catalog. +// pseudonymized field set; see users-anonymized.js), so they don't live in the +// COMPONENTS catalog either. They expose a project-wide participant roster +// (role, createdAt, lastLogin) across EVERY data source, which is broader than +// any single component — so they require their OWN dedicated opt-in +// (dataScope.users.enabled), not "any component enabled" (that would let +// enabling e.g. just 'votes' reporting silently unlock the full participant +// list too). +const USER_DATA_SEGMENTS = new Set(['anonymized', 'aggregates']); + const ALLOWED_NON_COMPONENT_SEGMENTS = new Set([ - 'overview', - 'anonymized', - 'aggregates', + OVERVIEW_SEGMENT, + ...USER_DATA_SEGMENTS, ]); /** @@ -93,7 +101,11 @@ function getEnabledComponents(req) { * 3. For component-specific paths the component must be enabled in the * project's config.dataScope. Disabled or unconfigured → 403. * 4. Non-component paths are denied by default (fail-closed); only an explicit - * allowlist of aggregate endpoints (/overview) is permitted. Every blocked + * allowlist of aggregate endpoints (/overview, /users/anonymized, + * /users/aggregates) is permitted. /overview requires any component to be + * enabled; the /users/* paths require their own dedicated + * dataScope.users.enabled toggle, since they expose a project-wide + * participant roster broader than any single component. Every blocked * non-component path is logged so it can be added to the allowlist later. * 5. Resolved scope info is attached to req.reportingScope so the * downstream field-filter middleware can project responses correctly. @@ -166,6 +178,33 @@ function apiTokenScopeGuard(req, res, next) { .json({ error: 'Path not allowed for reporting tokens' }); } + if (USER_DATA_SEGMENTS.has(lastSegment)) { + // Dedicated gate: enabling any single component must NOT unlock the + // project-wide participant roster/aggregates — only its own toggle does. + const dataScope = + req.project && req.project.config && req.project.config.dataScope; + const usersEnabled = !!( + dataScope && + dataScope.users && + dataScope.users.enabled + ); + + if (!usersEnabled) { + logBlockedReportingPath(req).catch(() => {}); + return res.status(403).json({ + error: + "The 'users' reporting component is not enabled for this project's reporting scope", + }); + } + + req.reportingScope = { + componentKey: null, + enabledPersonalFields: [], + enabledComponents: getEnabledComponents(req), + }; + return next(); + } + // Aggregate endpoints (e.g. /overview) derive their numbers from the // component data. If the project enabled no components, the token must // reach nothing — fail-closed, consistent with the per-component gate. diff --git a/apps/api-server/src/middleware/api-token-scope-guard.test.js b/apps/api-server/src/middleware/api-token-scope-guard.test.js index fd04ee064..d0c98d851 100644 --- a/apps/api-server/src/middleware/api-token-scope-guard.test.js +++ b/apps/api-server/src/middleware/api-token-scope-guard.test.js @@ -237,7 +237,9 @@ describe('apiTokenScopeGuard', () => { expect(next).not.toHaveBeenCalled(); }); - it('allows /reports/users/anonymized (#442) when at least one component is enabled', () => { + it('blocks /reports/users/anonymized (#442) when only an unrelated component is enabled', () => { + // Enabling e.g. 'votes' must NOT unlock the project-wide participant + // roster — that requires its own dedicated dataScope.users.enabled. const req = makeReq({ apiTokenScope: 'reports', method: 'GET', @@ -249,16 +251,48 @@ describe('apiTokenScopeGuard', () => { apiTokenScopeGuard(req, res, next); + expect(res._status).toBe(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('blocks /reports/users/aggregates (#442) when only an unrelated component is enabled', () => { + const req = makeReq({ + apiTokenScope: 'reports', + method: 'GET', + path: '/project/1/reports/users/aggregates', + projectDataScope: { votes: { enabled: true, personalFields: [] } }, + }); + const res = makeRes(); + const next = vi.fn(); + + apiTokenScopeGuard(req, res, next); + + expect(res._status).toBe(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('allows /reports/users/anonymized when dataScope.users.enabled is true', () => { + const req = makeReq({ + apiTokenScope: 'reports', + method: 'GET', + path: '/project/1/reports/users/anonymized', + projectDataScope: { users: { enabled: true, personalFields: [] } }, + }); + const res = makeRes(); + const next = vi.fn(); + + apiTokenScopeGuard(req, res, next); + expect(next).toHaveBeenCalledOnce(); expect(req.reportingScope).toMatchObject({ componentKey: null }); }); - it('allows /reports/users/aggregates (#442) when at least one component is enabled', () => { + it('allows /reports/users/aggregates when dataScope.users.enabled is true', () => { const req = makeReq({ apiTokenScope: 'reports', method: 'GET', path: '/project/1/reports/users/aggregates', - projectDataScope: { votes: { enabled: true, personalFields: [] } }, + projectDataScope: { users: { enabled: true, personalFields: [] } }, }); const res = makeRes(); const next = vi.fn(); @@ -283,6 +317,37 @@ describe('apiTokenScopeGuard', () => { expect(res._status).toBe(403); expect(next).not.toHaveBeenCalled(); }); + + it('blocks /reports/users/anonymized when dataScope.users.enabled is false', () => { + const req = makeReq({ + apiTokenScope: 'reports', + method: 'GET', + path: '/project/1/reports/users/anonymized', + projectDataScope: { users: { enabled: false, personalFields: [] } }, + }); + const res = makeRes(); + const next = vi.fn(); + + apiTokenScopeGuard(req, res, next); + + expect(res._status).toBe(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('still allows /overview when any component is enabled (unaffected by the users gate)', () => { + const req = makeReq({ + apiTokenScope: 'reports', + method: 'GET', + path: '/project/1/overview', + projectDataScope: { votes: { enabled: true, personalFields: [] } }, + }); + const res = makeRes(); + const next = vi.fn(); + + apiTokenScopeGuard(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + }); }); describe('reporting token — stats aggregate paths are flagged', () => { diff --git a/docker-compose.yml b/docker-compose.yml index 9d79a3117..e0d58caf0 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -149,6 +149,7 @@ services: - RANDOM_SORT_ROTATION_MS=${RANDOM_SORT_ROTATION_MS:-0} - PDF_API_ENDPOINT=${PDF_API_ENDPOINT} - PDF_API_KEY=${PDF_API_KEY} + - OPENSTAD_REPORT_PSEUDONYM_SECRET=${OPENSTAD_REPORT_PSEUDONYM_SECRET} ports: - '${API_PORT}:${API_PORT}' volumes: diff --git a/packages/lib/report-data-scope.js b/packages/lib/report-data-scope.js index 3f29b6582..595003ad1 100644 --- a/packages/lib/report-data-scope.js +++ b/packages/lib/report-data-scope.js @@ -44,7 +44,6 @@ const COMPONENTS = { 'publishDate', 'startDate', 'endDate', - 'status', 'budget', 'tags', 'location', @@ -89,9 +88,6 @@ const COMPONENTS = { 'updatedAt', 'sentiment', 'label', - 'status', - 'modBreak', - 'modBreakDatetime', ], // description is free text authored by the commenter. personalFields: ['description', 'user.displayName', 'user.nickName'],