* 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
107 lines
2.2 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
}
|