add email consent and update audience

This commit is contained in:
miloschwartz
2025-11-17 20:37:24 -05:00
parent 80a68507cd
commit 02fbc279b5
7 changed files with 104 additions and 60 deletions

View File

@@ -1430,6 +1430,9 @@
"and": "and",
"privacyPolicy": "privacy policy"
},
"signUpMarketing": {
"keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email."
},
"siteRequired": "Site is required.",
"olmTunnel": "Olm Tunnel",
"olmTunnelDescription": "Use Olm for client connectivity",

View File

@@ -16,7 +16,7 @@ import privateConfig from "#private/lib/config";
import logger from "@server/logger";
export enum AudienceIds {
SignUps = "5cfbf99b-c592-40a9-9b8a-577a4681c158",
SignUps = "6c4e77b2-0851-4bd6-bac8-f51f91360f1a",
Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20",
Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549",
Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0"

View File

@@ -40,7 +40,12 @@ export const queryAccessAuditLogsQuery = z.object({
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(new Date().toISOString()),
.prefault(new Date().toISOString())
.openapi({
type: "string",
format: "date-time",
description: "End time as ISO date string (defaults to current time)"
}),
action: z
.union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val))

View File

@@ -40,7 +40,12 @@ export const queryActionAuditLogsQuery = z.object({
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(new Date().toISOString()),
.prefault(new Date().toISOString())
.openapi({
type: "string",
format: "date-time",
description: "End time as ISO date string (defaults to current time)"
}),
action: z.string().optional(),
actorType: z.string().optional(),
actorId: z.string().optional(),

View File

@@ -27,7 +27,12 @@ export const queryAccessAuditLogsQuery = z.object({
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(new Date().toISOString()),
.prefault(new Date().toISOString())
.openapi({
type: "string",
format: "date-time",
description: "End time as ISO date string (defaults to current time)"
}),
action: z
.union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val))

View File

@@ -30,7 +30,8 @@ export const signupBodySchema = z.object({
password: passwordSchema,
inviteToken: z.string().optional(),
inviteId: z.string().optional(),
termsAcceptedTimestamp: z.string().nullable().optional()
termsAcceptedTimestamp: z.string().nullable().optional(),
marketingEmailConsent: z.boolean().optional()
});
export type SignUpBody = z.infer<typeof signupBodySchema>;
@@ -55,7 +56,7 @@ export async function signup(
);
}
const { email, password, inviteToken, inviteId, termsAcceptedTimestamp } =
const { email, password, inviteToken, inviteId, termsAcceptedTimestamp, marketingEmailConsent } =
parsedBody.data;
const passwordHash = await hashPassword(password);
@@ -220,8 +221,8 @@ export async function signup(
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
if (build == "saas") {
if (build == "saas" && marketingEmailConsent) {
logger.debug(`User ${email} opted in to marketing emails during signup.`);
moveEmailToAudience(email, AudienceIds.SignUps);
}

View File

@@ -92,7 +92,8 @@ const formSchema = z
message:
"You must agree to the terms of service and privacy policy"
}
)
),
marketingEmailConsent: z.boolean().optional()
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
@@ -123,7 +124,8 @@ export default function SignupForm({
email: emailParam || "",
password: "",
confirmPassword: "",
agreeToTerms: false
agreeToTerms: false,
marketingEmailConsent: false
},
mode: "onChange" // Enable real-time validation
});
@@ -135,7 +137,7 @@ export default function SignupForm({
passwordValue === confirmPasswordValue;
async function onSubmit(values: z.infer<typeof formSchema>) {
const { email, password } = values;
const { email, password, marketingEmailConsent } = values;
setLoading(true);
const res = await api
@@ -144,7 +146,8 @@ export default function SignupForm({
password,
inviteId,
inviteToken,
termsAcceptedTimestamp: termsAgreedAt
termsAcceptedTimestamp: termsAgreedAt,
marketingEmailConsent: build === "saas" ? marketingEmailConsent : undefined
})
.catch((e) => {
console.error(e);
@@ -489,56 +492,78 @@ export default function SignupForm({
)}
/>
{build === "saas" && (
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
handleTermsChange(
checked as boolean
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
<>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
handleTermsChange(
checked as boolean
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.termsOfService"
"signUpTerms.IAgreeToThe"
)}{" "}
</a>
{t("signUpTerms.and")}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t("signUpTerms.and")}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketingEmailConsent"
render={({ field }) => (
<FormItem className="flex flex-row items-start">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t("signUpMarketing.keepMeInTheLoop")}
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</>
)}
{error && (