Ben Senescu aae759ff1e feat: email verification and password reset for hosted auth (#47)
* feat: add email verification and password reset for hosted auth

Add email-based sign-up verification and password reset flows using
Better Auth and Loops transactional emails. New routes for
/verify-email, /reset-password, and /forgot-password. Sign-up now
redirects to verify-email page instead of showing inline state.

* refactor: use TanStack Form standard schema validation for auth forms

Pass Zod schemas directly to `validators.onSubmit` instead of manually
calling safeParse and reducing over issues. TanStack Form v1.25+ with
Zod v4 handles field-level error extraction automatically.

* refactor: use form.isSubmitSuccessful instead of manual state

Replace `submittedEmail` state in forgot-password and `isComplete`
state in reset-password with TanStack Form's built-in
`isSubmitSuccessful` flag, removing the need for useState in both.

* fix: formatting and lint fixes for ci:check

Fix prettier formatting, replace unsafe type assertions with
Reflect.get for Cloudflare env access.

* fix auth copy and verification redirect

* refactor: derive auth route page copy from state
2026-03-26 20:42:19 -04:00

107 lines
2.2 KiB
TypeScript

import { env } from "cloudflare:workers";
const LOOPS_TRANSACTIONAL_URL = "https://app.loops.so/api/v1/transactional";
function getRequiredEnv(name: string) {
const value: unknown = Reflect.get(env, name);
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
throw new Error(`${name} is required in hosted mode`);
}
return trimmed;
}
function getHostedAuthEmailConfig() {
return {
apiKey: getRequiredEnv("LOOPS_API_KEY"),
verificationTemplateId: getRequiredEnv(
"LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID",
),
passwordResetTemplateId: getRequiredEnv(
"LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID",
),
};
}
async function sendLoopsTransactionalEmail({
apiKey,
email,
transactionalId,
dataVariables,
}: {
apiKey: string;
email: string;
transactionalId: string;
dataVariables: Record<string, string>;
}) {
const response = await fetch(LOOPS_TRANSACTIONAL_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
transactionalId,
email,
addToAudience: false,
dataVariables,
}),
});
if (response.ok) {
return;
}
const errorPayload = await response.json().catch(() => null);
console.error("Loops transactional email error:", {
status: response.status,
email,
transactionalId,
errorPayload,
});
throw new Error(
`Failed to send Loops transactional email (${response.status})`,
);
}
export async function sendHostedVerificationEmail({
email,
confirmationUrl,
}: {
email: string;
confirmationUrl: string;
}) {
const config = getHostedAuthEmailConfig();
await sendLoopsTransactionalEmail({
apiKey: config.apiKey,
email,
transactionalId: config.verificationTemplateId,
dataVariables: {
appName: "OpenSEO",
confirmationUrl,
},
});
}
export async function sendHostedPasswordResetEmail({
email,
resetUrl,
}: {
email: string;
resetUrl: string;
}) {
const config = getHostedAuthEmailConfig();
await sendLoopsTransactionalEmail({
apiKey: config.apiKey,
email,
transactionalId: config.passwordResetTemplateId,
dataVariables: {
appName: "OpenSEO",
resetUrl,
},
});
}