@@ -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 (
+