Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/admin-server/src/components/ui/sidenav-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ export function SidenavProject({ className }: { className?: string }) {
className
)}>
<div className="flex flex-col items-start justify-center h-24">
<Link href="javascript:history.back();">
<button onClick={() => router.back()}>
<div className="m-4 p-3 bg-secondary rounded">
<ArrowLeft size={20} />
</div>
</Link>
</button>
</div>
<div className="p-4 flex flex-col gap-2">
<Link href={`/projects/${project}/statistics`}>
Expand Down Expand Up @@ -213,6 +213,20 @@ export function SidenavProject({ className }: { className?: string }) {
</Button>
</Link>
)}
{HasAccess(sessionData) && (
<Link href={`/projects/${project}/settings/api-tokens`}>
<Button
variant={
location.includes('/settings/api-tokens')
? 'secondary'
: 'ghost'
}
size="default"
className="w-full flex justify-start pl-8">
<span className="truncate">API-tokens</span>
</Button>
</Link>
)}
</>
) : null}
<Link href={`/projects/${project}/authentication`}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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}`;
}
Expand Down Expand Up @@ -51,7 +50,7 @@ export default function ProjectApiTokens() {
},
{
name: 'API-tokens',
url: `/projects/${project}/api-tokens`,
url: `/projects/${project}/settings/api-tokens`,
},
]}>
<div className="container py-6">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
220 changes: 168 additions & 52 deletions apps/admin-server/src/pages/projects/[project]/settings/data-scope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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.
Expand All @@ -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({
Expand All @@ -46,17 +98,88 @@ const formSchema = z.object({
projects: componentSchema,
choiceguideguides: componentSchema,
choiceguidequestions: componentSchema,
users: componentSchema,
});

type FormValues = z.infer<typeof formSchema>;

/**
* 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 (
<div className="pl-2 border-l-2 border-yellow-300 space-y-2">
<p className="text-sm font-medium text-muted-foreground">
{sectionLabel}
</p>
<FormField
control={control}
name={`${keyName}.${fieldName}` as any}
render={() => (
<FormItem className="space-y-2">
{options.map((opt) => (
<FormField
key={opt.key}
control={control}
name={`${keyName}.${fieldName}` as any}
render={({ field }) => {
const currentValues: string[] = field.value || [];
return (
<FormItem
key={opt.key}
className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={currentValues.includes(opt.key)}
onCheckedChange={(checked) => {
const next = checked
? [...currentValues, opt.key]
: currentValues.filter((v) => v !== opt.key);
field.onChange(next);
}}
/>
</FormControl>
<FormLabel className="font-normal cursor-pointer">
{opt.label}
</FormLabel>
</FormItem>
);
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
</div>
);
}

function buildDefaults(dataScopeConfig: any): FormValues {
return (Object.keys(COMPONENTS) as ComponentKey[]).reduce(
(acc, key) => ({
...acc,
[key]: {
enabled: dataScopeConfig?.[key]?.enabled ?? false,
personalFields: dataScopeConfig?.[key]?.personalFields ?? [],
formFields: dataScopeConfig?.[key]?.formFields ?? [],
answerFields: dataScopeConfig?.[key]?.answerFields ?? [],
},
}),
{} as FormValues
Expand All @@ -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),
Expand All @@ -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
);
Expand Down Expand Up @@ -188,56 +326,34 @@ export default function ProjectSettingsDataScope() {
)}
/>

{enabled && def.personalFields.length > 0 && (
<div className="pl-2 border-l-2 border-yellow-300 space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Optionele persoonsvelden (gepseudonimiseerd)
</p>
<FormField
control={form.control}
name={`${key}.personalFields` as any}
render={() => (
<FormItem className="space-y-2">
{def.personalFields.map((pf) => (
<FormField
key={pf.key}
control={form.control}
name={`${key}.personalFields` as any}
render={({ field }) => {
const currentValues: string[] =
field.value || [];
return (
<FormItem
key={pf.key}
className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={currentValues.includes(
pf.key
)}
onCheckedChange={(checked) => {
const next = checked
? [...currentValues, pf.key]
: currentValues.filter(
(v) => v !== pf.key
);
field.onChange(next);
}}
/>
</FormControl>
<FormLabel className="font-normal cursor-pointer">
{pf.label}
</FormLabel>
</FormItem>
);
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
</div>
{enabled && (
<FieldCheckboxGroup
control={form.control}
keyName={key}
fieldName="personalFields"
sectionLabel="Optionele persoonsvelden (gepseudonimiseerd)"
options={def.personalFields}
/>
)}

{enabled && key === 'submissions' && (
<FieldCheckboxGroup
control={form.control}
keyName={key}
fieldName="formFields"
sectionLabel="Formuliervelden (per veld opt-in)"
options={enqueteFormFields}
/>
)}

{enabled && key === 'choiceguides' && (
<FieldCheckboxGroup
control={form.control}
keyName={key}
fieldName="answerFields"
sectionLabel="Antwoordvelden (per veld opt-in)"
options={choiceguideAnswerFields}
/>
)}
</div>
);
Expand Down
2 changes: 0 additions & 2 deletions apps/api-server/src/lib/reporting/serialize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ describe('serializeRecord', () => {
budget: 'DECIMAL',
tags: 'JSON',
location: 'JSON',
status: 'STRING',
widgetId: 'INTEGER',
};

Expand Down Expand Up @@ -98,7 +97,6 @@ describe('serializeRecord', () => {
{ enabledPersonalFields: [] },
{ fieldTypes }
);
expect(out).toHaveProperty('status', null);
expect(out).toHaveProperty('budget', null);
expect(out).toHaveProperty('location', null);
});
Expand Down
Loading