Skip to content

Commit 0dff342

Browse files
authored
Merge pull request #4228 from ava-labs/consent_t1_events
Consent t1 events
2 parents a5d065f + ffa50c3 commit 0dff342

8 files changed

Lines changed: 165 additions & 136 deletions

File tree

app/api/register-form/route.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Session } from "next-auth";
44
import { withAuth } from "@/lib/protectedRoute";
55
import { prisma } from "@/prisma/prisma";
66
import { syncUserDataToHubSpot } from "@/server/services/hubspotUserData";
7+
import { isTeam1Event } from "@/lib/events/team1";
78

89
type UserConsentsInput = {
910
notifications?: unknown;
@@ -41,6 +42,40 @@ export const POST = withAuth(async (
4142
try {
4243
const body = await req.json();
4344
const { user_consents, ...registerData } = body ?? {};
45+
46+
// Team1-organized events require explicit sharing consent unless the user
47+
// has already granted it on their profile. Enforce here so a crafted
48+
// client request can't bypass the registration form's required check.
49+
const hackathonId = registerData?.hackathon_id;
50+
if (session.user?.email && typeof hackathonId === "string" && hackathonId) {
51+
const [hackathon, user] = await Promise.all([
52+
prisma.hackathon.findUnique({
53+
where: { id: hackathonId },
54+
select: { organizers: true, cohosts: true },
55+
}),
56+
prisma.user.findUnique({
57+
where: { email: session.user.email },
58+
select: { consent_sharing: true },
59+
}),
60+
]);
61+
const isTeam1 = hackathon ? isTeam1Event(hackathon) : false;
62+
const userHasConsent = user?.consent_sharing === true;
63+
const incomingConsent =
64+
(user_consents as UserConsentsInput | undefined)?.consent_sharing;
65+
if (isTeam1 && !userHasConsent && incomingConsent !== true) {
66+
return NextResponse.json(
67+
{
68+
error: {
69+
message:
70+
"Team1 sharing consent is required to register for this event.",
71+
field: "user_consent_sharing",
72+
},
73+
},
74+
{ status: 400 },
75+
);
76+
}
77+
}
78+
4479
if (user_consents && typeof user_consents === "object" && session.user?.email) {
4580
await persistUserConsents(session.user.email, user_consents as UserConsentsInput);
4681
}
Lines changed: 38 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"use client";
22

33
import * as React from "react";
4-
import { ChevronDown } from "lucide-react";
54

65
import { Checkbox } from "@/components/ui/checkbox";
76
import { cn } from "@/lib/utils";
@@ -19,134 +18,61 @@ export type GroupedUserConsentsProps = {
1918
groupLabel: string;
2019
groupHint?: string;
2120
items: GroupedConsentItem[];
22-
defaultExpanded?: boolean;
2321
className?: string;
2422
};
2523

2624
/**
27-
* Renders a group of User-level consent checkboxes with a parent toggle that
28-
* checks/unchecks all children at once and shows an indeterminate state when
29-
* only some children are checked. When only one item is provided, the parent
30-
* wrapper collapses into a single flat checkbox to avoid superfluous nesting.
25+
* Renders a section of User-level consent checkboxes under a static group
26+
* heading. Each item is an independent checkbox — there is no parent toggle
27+
* and no collapse behavior.
3128
*/
3229
export function GroupedUserConsents({
3330
groupLabel,
3431
groupHint,
3532
items,
36-
defaultExpanded = false,
3733
className,
3834
}: GroupedUserConsentsProps) {
39-
const parentInputId = React.useId();
40-
const [expanded, setExpanded] = React.useState(defaultExpanded);
35+
const reactId = React.useId();
4136

4237
if (items.length === 0) return null;
4338

44-
if (items.length === 1) {
45-
const item = items[0];
46-
const id = `${parentInputId}-${item.key}`;
47-
return (
48-
<div className={cn("flex items-start space-x-3", className)}>
49-
<Checkbox
50-
id={id}
51-
checked={item.checked}
52-
onCheckedChange={(value) => item.onCheckedChange(value === true)}
53-
/>
54-
<div className="flex-1">
55-
<label
56-
htmlFor={id}
57-
className="text-sm text-foreground cursor-pointer"
58-
>
59-
{item.label}
60-
</label>
61-
{item.hint ? (
62-
<p className="text-xs text-muted-foreground mt-1">{item.hint}</p>
63-
) : null}
64-
</div>
65-
</div>
66-
);
67-
}
68-
69-
const allChecked = items.every((i) => i.checked);
70-
const noneChecked = items.every((i) => !i.checked);
71-
const parentState: boolean | "indeterminate" = allChecked
72-
? true
73-
: noneChecked
74-
? false
75-
: "indeterminate";
76-
77-
const handleParentChange = () => {
78-
const next = !allChecked;
79-
items.forEach((i) => i.onCheckedChange(next));
80-
};
81-
8239
return (
83-
<div className={cn("space-y-2", className)}>
84-
<div className="flex items-start space-x-3">
85-
<Checkbox
86-
id={parentInputId}
87-
checked={parentState}
88-
onCheckedChange={handleParentChange}
89-
/>
90-
<div className="flex-1">
91-
<div className="flex items-center gap-2">
92-
<label
93-
htmlFor={parentInputId}
94-
className="text-sm text-foreground cursor-pointer"
95-
>
96-
{groupLabel}
97-
</label>
98-
<button
99-
type="button"
100-
onClick={() => setExpanded((prev) => !prev)}
101-
aria-expanded={expanded}
102-
aria-controls={`${parentInputId}-details`}
103-
aria-label={expanded ? "Collapse details" : "Expand details"}
104-
className="text-muted-foreground hover:text-foreground transition-colors"
105-
>
106-
<ChevronDown
107-
className={cn(
108-
"size-4 transition-transform",
109-
expanded && "rotate-180",
110-
)}
111-
/>
112-
</button>
113-
</div>
114-
{groupHint ? (
115-
<p className="text-xs text-muted-foreground mt-1">{groupHint}</p>
116-
) : null}
117-
</div>
40+
<div className={cn("space-y-3", className)}>
41+
<div>
42+
<p className="text-sm text-foreground">{groupLabel}</p>
43+
{groupHint ? (
44+
<p className="text-xs text-muted-foreground mt-1">{groupHint}</p>
45+
) : null}
11846
</div>
119-
{expanded ? (
120-
<div id={`${parentInputId}-details`} className="pl-7 space-y-3">
121-
{items.map((item) => {
122-
const id = `${parentInputId}-${item.key}`;
123-
return (
124-
<div key={item.key} className="flex items-start space-x-3">
125-
<Checkbox
126-
id={id}
127-
checked={item.checked}
128-
onCheckedChange={(value) =>
129-
item.onCheckedChange(value === true)
130-
}
131-
/>
132-
<div className="flex-1">
133-
<label
134-
htmlFor={id}
135-
className="text-sm text-foreground cursor-pointer"
136-
>
137-
{item.label}
138-
</label>
139-
{item.hint ? (
140-
<p className="text-xs text-muted-foreground mt-1">
141-
{item.hint}
142-
</p>
143-
) : null}
144-
</div>
47+
<div className="space-y-3">
48+
{items.map((item) => {
49+
const id = `${reactId}-${item.key}`;
50+
return (
51+
<div key={item.key} className="flex items-start space-x-3">
52+
<Checkbox
53+
id={id}
54+
checked={item.checked}
55+
onCheckedChange={(value) =>
56+
item.onCheckedChange(value === true)
57+
}
58+
/>
59+
<div className="flex-1">
60+
<label
61+
htmlFor={id}
62+
className="text-sm text-foreground cursor-pointer"
63+
>
64+
{item.label}
65+
</label>
66+
{item.hint ? (
67+
<p className="text-xs text-muted-foreground mt-1">
68+
{item.hint}
69+
</p>
70+
) : null}
14571
</div>
146-
);
147-
})}
148-
</div>
149-
) : null}
72+
</div>
73+
);
74+
})}
75+
</div>
15076
</div>
15177
);
15278
}

components/hackathons/registration-form/RegisterFormStep3.tsx

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,21 @@ interface RegisterFormStep3Props {
1818
lang?: EventsLang;
1919
showNotificationsConsent?: boolean;
2020
showSharingConsent?: boolean;
21+
/** Team1-organized event — sharing consent is mandatory to register. */
22+
requireSharingConsent?: boolean;
2123
}
2224

2325
export function RegisterFormStep3({
2426
isOnlineHackathon,
2527
lang = "en",
2628
showNotificationsConsent = false,
2729
showSharingConsent = false,
30+
requireSharingConsent = false,
2831
}: RegisterFormStep3Props) {
2932
const form = useFormContext<RegisterFormValues>();
33+
const sharingError = form.formState.errors.user_consent_sharing?.message as
34+
| string
35+
| undefined;
3036

3137
const consentItems: GroupedConsentItem[] = [];
3238
if (showNotificationsConsent) {
@@ -42,11 +48,17 @@ export function RegisterFormStep3({
4248
if (showSharingConsent) {
4349
consentItems.push({
4450
key: "user_consent_sharing",
45-
label: t(lang, "consents.consentSharing.label"),
51+
label:
52+
t(lang, "consents.consentSharing.label") +
53+
(requireSharingConsent ? " *" : ""),
4654
hint: t(lang, "consents.consentSharing.hint"),
4755
checked: form.watch("user_consent_sharing") ?? false,
48-
onCheckedChange: (next) =>
49-
form.setValue("user_consent_sharing", next, { shouldDirty: true }),
56+
onCheckedChange: (next) => {
57+
form.setValue("user_consent_sharing", next, { shouldDirty: true });
58+
if (next && sharingError) {
59+
form.clearErrors("user_consent_sharing");
60+
}
61+
},
5062
});
5163
}
5264

@@ -88,20 +100,25 @@ export function RegisterFormStep3({
88100
{t(lang, "reg.step3.privacyLink")}
89101
</a> *
90102
</FormLabel>
91-
<FormMessage className="text-zinc-400">
103+
<p className="text-zinc-400 text-sm">
92104
{t(lang, "reg.step3.terms.hint")}
93-
</FormMessage>
105+
</p>
106+
<FormMessage />
94107
</div>
95108
</FormItem>
96109
)}
97110
/>
98111

99112
{consentItems.length > 0 && (
100-
<GroupedUserConsents
101-
groupLabel={t(lang, "consents.group.label")}
102-
groupHint={t(lang, "consents.group.hint")}
103-
items={consentItems}
104-
/>
113+
<div className="space-y-2">
114+
<GroupedUserConsents
115+
groupLabel={t(lang, "consents.group.label")}
116+
items={consentItems}
117+
/>
118+
{sharingError ? (
119+
<p className="text-sm text-red-500">{sharingError}</p>
120+
) : null}
121+
</div>
105122
)}
106123

107124
{/* Only show prohibited items for in-person hackathons */}
@@ -120,9 +137,10 @@ export function RegisterFormStep3({
120137
</FormControl>
121138
<div className="space-y-1 leading-none">
122139
<FormLabel>{t(lang, "reg.step3.prohibited.label")}</FormLabel>
123-
<FormMessage className="text-zinc-400">
140+
<p className="text-zinc-400 text-sm">
124141
{t(lang, "reg.step3.prohibited.hint")}
125-
</FormMessage>
142+
</p>
143+
<FormMessage />
126144
</div>
127145
</FormItem>
128146
)}

components/hackathons/registration-form/RegistrationForm.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import Modal from "@/components/ui/Modal";
2828
import ProcessCompletedDialog from "./ProcessCompletedDialog";
2929
import { useUTMPreservation } from "@/hooks/use-utm-preservation";
3030
import { normalizeEventsLang, t } from "@/lib/events/i18n";
31+
import { isTeam1Event } from "@/lib/events/team1";
3132
import { clearStoredReferralAttribution } from "@/lib/referrals/client";
3233
import {
3334
ReferralFormSection,
@@ -139,6 +140,13 @@ export function RegisterForm({
139140

140141
// Determine if hackathon is online based on location
141142
const isOnlineHackathon = hackathon?.location?.toLowerCase().includes("online") || false;
143+
// Team1-organized / co-hosted events require the `consent_sharing` opt-in
144+
// unless the user has already granted it on their profile.
145+
const isTeam1 = hackathon
146+
? isTeam1Event({ organizers: hackathon.organizers, cohosts: hackathon.cohosts })
147+
: false;
148+
const requireSharingConsent =
149+
isTeam1 && consentsLoaded && userConsentState.consent_sharing !== true;
142150
const lang = normalizeEventsLang(hackathon?.content?.language);
143151

144152
const getDefaultValues = () => ({
@@ -481,11 +489,27 @@ export function RegisterForm({
481489
};
482490
}
483491

492+
if (requireSharingConsent && data.user_consent_sharing !== true) {
493+
errors.user_consent_sharing = {
494+
type: "custom",
495+
message: t(lang, "consents.consentSharing.required"),
496+
};
497+
}
498+
484499

485500
if (Object.keys(errors).length > 0) {
486501
Object.keys(errors).forEach(field => {
487502
form.setError(field as keyof RegisterFormValues, errors[field]);
488503
});
504+
// Bring the first invalid field into view so the user notices the
505+
// feedback even when scrolled to the submit button.
506+
const firstField = Object.keys(errors)[0];
507+
if (typeof window !== "undefined") {
508+
const el = document.querySelector<HTMLElement>(
509+
`[name="${firstField}"], #${CSS.escape(firstField)}`,
510+
);
511+
el?.scrollIntoView({ behavior: "smooth", block: "center" });
512+
}
489513
return;
490514
}
491515
setFormData((prevData) => ({ ...prevData, ...data }));
@@ -611,6 +635,13 @@ export function RegisterForm({
611635
};
612636
}
613637

638+
if (requireSharingConsent && formValues.user_consent_sharing !== true) {
639+
errors.user_consent_sharing = {
640+
type: "custom",
641+
message: t(lang, "consents.consentSharing.required"),
642+
};
643+
}
644+
614645
if (Object.keys(errors).length > 0) {
615646
(Object.keys(errors) as (keyof RegisterFormValues)[]).forEach(field => {
616647
form.setError(field, errors[field]!);
@@ -662,6 +693,7 @@ export function RegisterForm({
662693
lang={lang}
663694
showNotificationsConsent={showNotificationsConsent}
664695
showSharingConsent={showSharingConsent}
696+
requireSharingConsent={requireSharingConsent}
665697
/>
666698
)}
667699
<Separator className="border-red-300 dark:border-red-300 mt-4" />

0 commit comments

Comments
 (0)