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", "and": "and",
"privacyPolicy": "privacy policy" "privacyPolicy": "privacy policy"
}, },
"signUpMarketing": {
"keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email."
},
"siteRequired": "Site is required.", "siteRequired": "Site is required.",
"olmTunnel": "Olm Tunnel", "olmTunnel": "Olm Tunnel",
"olmTunnelDescription": "Use Olm for client connectivity", "olmTunnelDescription": "Use Olm for client connectivity",

View File

@@ -16,7 +16,7 @@ import privateConfig from "#private/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
export enum AudienceIds { export enum AudienceIds {
SignUps = "5cfbf99b-c592-40a9-9b8a-577a4681c158", SignUps = "6c4e77b2-0851-4bd6-bac8-f51f91360f1a",
Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20", Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20",
Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549", Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549",
Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0" 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)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional() .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 action: z
.union([z.boolean(), z.string()]) .union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val)) .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)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional() .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(), action: z.string().optional(),
actorType: z.string().optional(), actorType: z.string().optional(),
actorId: 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)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional() .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 action: z
.union([z.boolean(), z.string()]) .union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val)) .transform((val) => (typeof val === "string" ? val === "true" : val))

View File

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

View File

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