From aae759ff1eb003c4a2e48595bd0491b168550248 Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Thu, 26 Mar 2026 20:25:45 -0400
Subject: [PATCH] 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
---
.env.example | 3 +
src/client/features/auth/AuthPage.tsx | 24 ++-
src/lib/auth.ts | 50 ++++-
src/routeTree.gen.ts | 63 ++++++
src/routes/_auth.sign-in.tsx | 244 +++++++++++++--------
src/routes/_auth.sign-up.tsx | 94 ++++----
src/routes/_auth.tsx | 13 +-
src/routes/forgot-password.tsx | 165 ++++++++++++++
src/routes/reset-password.tsx | 296 ++++++++++++++++++++++++++
src/routes/verify-email.tsx | 216 +++++++++++++++++++
src/server/email/loops.ts | 106 +++++++++
11 files changed, 1130 insertions(+), 144 deletions(-)
create mode 100644 src/routes/forgot-password.tsx
create mode 100644 src/routes/reset-password.tsx
create mode 100644 src/routes/verify-email.tsx
create mode 100644 src/server/email/loops.ts
diff --git a/.env.example b/.env.example
index c7a328c..133eb77 100644
--- a/.env.example
+++ b/.env.example
@@ -30,3 +30,6 @@
# Required when AUTH_MODE=hosted
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters
# BETTER_AUTH_URL=http://localhost:3001
+# LOOPS_API_KEY=replace-with-your-loops-api-key
+# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id
+# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id
diff --git a/src/client/features/auth/AuthPage.tsx b/src/client/features/auth/AuthPage.tsx
index c5ba7c6..e609627 100644
--- a/src/client/features/auth/AuthPage.tsx
+++ b/src/client/features/auth/AuthPage.tsx
@@ -20,7 +20,19 @@ export function useAuthPageState(redirect: string | undefined) {
}
export function getFieldError(errors: unknown[]) {
- return typeof errors[0] === "string" ? errors[0] : null;
+ const first = errors[0];
+ if (typeof first === "string") return first;
+ if (first && typeof first === "object" && "message" in first)
+ return String((first as { message: unknown }).message);
+ return null;
+}
+
+export function getFormError(error: unknown): string | null {
+ if (!error) return null;
+ if (typeof error === "string") return error;
+ if (typeof error === "object" && "form" in error)
+ return String((error as { form: unknown }).form);
+ return null;
}
export function AuthPageCard({
@@ -49,3 +61,13 @@ export function AuthPageCard({
);
}
+
+export function AuthPageShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index d8ec04a..88f8cac 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,4 +1,4 @@
-import { env } from "cloudflare:workers";
+import { env, waitUntil } from "cloudflare:workers";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";
@@ -6,6 +6,10 @@ import { db } from "@/db";
import { z } from "zod";
import { baseAuthConfig } from "@/lib/auth-config";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
+import {
+ sendHostedPasswordResetEmail,
+ sendHostedVerificationEmail,
+} from "@/server/email/loops";
const hostedBaseUrlSchema = z
.string()
@@ -25,6 +29,35 @@ function createAuth() {
baseURL: baseUrl,
secret: getHostedSecret(),
...baseAuthConfig,
+ advanced: {
+ backgroundTasks: {
+ handler: (promise) => {
+ waitUntil(promise);
+ },
+ },
+ },
+ emailAndPassword: {
+ ...baseAuthConfig.emailAndPassword,
+ requireEmailVerification: true,
+ resetPasswordTokenExpiresIn: 60 * 60,
+ revokeSessionsOnPasswordReset: true,
+ sendResetPassword: async ({ user, url }) => {
+ await sendHostedPasswordResetEmail({
+ email: user.email,
+ resetUrl: url,
+ });
+ },
+ },
+ emailVerification: {
+ sendOnSignUp: true,
+ autoSignInAfterVerification: true,
+ sendVerificationEmail: async ({ user, url }) => {
+ await sendHostedVerificationEmail({
+ email: user.email,
+ confirmationUrl: url,
+ });
+ },
+ },
trustedOrigins: getTrustedOrigins(baseUrl),
database: drizzleAdapter(db, {
provider: "sqlite",
@@ -95,11 +128,24 @@ function getHostedSecret() {
return secret;
}
+function hasHostedAuthEmailConfig() {
+ const loopsVars = [
+ "LOOPS_API_KEY",
+ "LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID",
+ "LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID",
+ ];
+
+ return loopsVars.every((name) => {
+ const value: unknown = Reflect.get(env, name);
+ return typeof value === "string" && value.trim() !== "";
+ });
+}
+
export function hasHostedAuthConfig() {
try {
getHostedBaseUrl();
getHostedSecret();
- return true;
+ return hasHostedAuthEmailConfig();
} catch {
return false;
}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index cb32af5..b322eb9 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -9,6 +9,9 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
+import { Route as VerifyEmailRouteImport } from './routes/verify-email'
+import { Route as ResetPasswordRouteImport } from './routes/reset-password'
+import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as ProjectRouteRouteImport } from './routes/_project/route'
import { Route as AppRouteRouteImport } from './routes/_app/route'
@@ -29,6 +32,21 @@ import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$pr
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
import { Route as ProjectPProjectIdAuditIssuesResultIdRouteImport } from './routes/_project/p/$projectId/audit/issues/$resultId'
+const VerifyEmailRoute = VerifyEmailRouteImport.update({
+ id: '/verify-email',
+ path: '/verify-email',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ResetPasswordRoute = ResetPasswordRouteImport.update({
+ id: '/reset-password',
+ path: '/reset-password',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
+ id: '/forgot-password',
+ path: '/forgot-password',
+ getParentRoute: () => rootRouteImport,
+} as any)
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
@@ -128,6 +146,9 @@ const ProjectPProjectIdAuditIssuesResultIdRoute =
export interface FileRoutesByFullPath {
'/': typeof AppIndexRoute
+ '/forgot-password': typeof ForgotPasswordRoute
+ '/reset-password': typeof ResetPasswordRoute
+ '/verify-email': typeof VerifyEmailRoute
'/billing': typeof AppBillingRoute
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
@@ -146,6 +167,9 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof AppIndexRoute
+ '/forgot-password': typeof ForgotPasswordRoute
+ '/reset-password': typeof ResetPasswordRoute
+ '/verify-email': typeof VerifyEmailRoute
'/billing': typeof AppBillingRoute
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
@@ -165,6 +189,9 @@ export interface FileRoutesById {
'/_app': typeof AppRouteRouteWithChildren
'/_project': typeof ProjectRouteRouteWithChildren
'/_auth': typeof AuthRouteWithChildren
+ '/forgot-password': typeof ForgotPasswordRoute
+ '/reset-password': typeof ResetPasswordRoute
+ '/verify-email': typeof VerifyEmailRoute
'/_app/billing': typeof AppBillingRoute
'/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute
@@ -186,6 +213,9 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/forgot-password'
+ | '/reset-password'
+ | '/verify-email'
| '/billing'
| '/sign-in'
| '/sign-up'
@@ -204,6 +234,9 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/forgot-password'
+ | '/reset-password'
+ | '/verify-email'
| '/billing'
| '/sign-in'
| '/sign-up'
@@ -222,6 +255,9 @@ export interface FileRouteTypes {
| '/_app'
| '/_project'
| '/_auth'
+ | '/forgot-password'
+ | '/reset-password'
+ | '/verify-email'
| '/_app/billing'
| '/_auth/sign-in'
| '/_auth/sign-up'
@@ -244,11 +280,35 @@ export interface RootRouteChildren {
AppRouteRoute: typeof AppRouteRouteWithChildren
ProjectRouteRoute: typeof ProjectRouteRouteWithChildren
AuthRoute: typeof AuthRouteWithChildren
+ ForgotPasswordRoute: typeof ForgotPasswordRoute
+ ResetPasswordRoute: typeof ResetPasswordRoute
+ VerifyEmailRoute: typeof VerifyEmailRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/verify-email': {
+ id: '/verify-email'
+ path: '/verify-email'
+ fullPath: '/verify-email'
+ preLoaderRoute: typeof VerifyEmailRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/reset-password': {
+ id: '/reset-password'
+ path: '/reset-password'
+ fullPath: '/reset-password'
+ preLoaderRoute: typeof ResetPasswordRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/forgot-password': {
+ id: '/forgot-password'
+ path: '/forgot-password'
+ fullPath: '/forgot-password'
+ preLoaderRoute: typeof ForgotPasswordRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/_auth': {
id: '/_auth'
path: ''
@@ -472,6 +532,9 @@ const rootRouteChildren: RootRouteChildren = {
AppRouteRoute: AppRouteRouteWithChildren,
ProjectRouteRoute: ProjectRouteRouteWithChildren,
AuthRoute: AuthRouteWithChildren,
+ ForgotPasswordRoute: ForgotPasswordRoute,
+ ResetPasswordRoute: ResetPasswordRoute,
+ VerifyEmailRoute: VerifyEmailRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
}
export const routeTree = rootRouteImport
diff --git a/src/routes/_auth.sign-in.tsx b/src/routes/_auth.sign-in.tsx
index 8ed511c..d0352bb 100644
--- a/src/routes/_auth.sign-in.tsx
+++ b/src/routes/_auth.sign-in.tsx
@@ -1,8 +1,12 @@
import { useForm } from "@tanstack/react-form";
import { Link, createFileRoute } from "@tanstack/react-router";
+import { useState } from "react";
+import { toast } from "sonner";
import {
AuthPageCard,
authRedirectSearchSchema,
+ getFieldError,
+ getFormError,
useAuthPageState,
} from "@/client/features/auth/AuthPage";
import { authClient } from "@/lib/auth-client";
@@ -21,71 +25,105 @@ export const Route = createFileRoute("/_auth/sign-in")({
function getHelperText(isHostedMode: boolean) {
if (!isHostedMode) {
- return "Sign-in is only available when AUTH_MODE=hosted.";
+ return "Sign-in isn't available right now.";
}
return "Sign in to your OpenSEO account.";
}
-function getSignInValidationErrors(value: { email: string; password: string }) {
- const parsed = signInSchema.safeParse(value);
-
- if (parsed.success) {
- return null;
- }
-
- return {
- form: parsed.error.issues[0]?.message || "Unable to sign in.",
- fields: parsed.error.issues.reduce>(
- (errors, issue) => {
- const path = issue.path.join(".");
-
- if (path && !errors[path]) {
- errors[path] = issue.message;
- }
-
- return errors;
- },
- {},
- ),
- };
-}
-
function SignInPage() {
const search = Route.useSearch();
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
search.redirect,
);
const helperText = getHelperText(isHostedMode);
+ const [verificationEmail, setVerificationEmail] = useState(
+ null,
+ );
+ const [isSendingVerification, setIsSendingVerification] = useState(false);
+
const form = useForm({
defaultValues: {
email: "",
password: "",
},
validators: {
- onSubmit: ({ value }) => getSignInValidationErrors(value),
+ onSubmit: signInSchema,
},
onSubmit: async ({ formApi, value }) => {
try {
+ const email = value.email.trim();
+ setVerificationEmail(null);
+
const result = await authClient.signIn.email({
- email: value.email.trim(),
+ email,
password: value.password,
callbackURL: redirectTo,
});
- if (result.error) {
- formApi.setErrorMap({
- onSubmit: result.error.message || "Unable to sign in.",
- });
+ if (!result.error) {
+ return;
}
+
+ if (result.error.status === 403) {
+ setVerificationEmail(email);
+ formApi.setErrorMap({
+ onSubmit: {
+ form: "Please confirm your email before signing in.",
+ fields: {},
+ },
+ });
+ return;
+ }
+
+ formApi.setErrorMap({
+ onSubmit: {
+ form: result.error.message || "We couldn't sign you in.",
+ fields: {},
+ },
+ });
} catch {
formApi.setErrorMap({
- onSubmit: "Unable to sign in right now. Please try again.",
+ onSubmit: {
+ form: "We couldn't sign you in right now. Please try again.",
+ fields: {},
+ },
});
}
},
});
+ async function handleResendVerification() {
+ if (!verificationEmail) {
+ return;
+ }
+
+ setIsSendingVerification(true);
+
+ try {
+ const callbackURL = new URL("/verify-email", window.location.origin);
+ if (redirectTo !== "/")
+ callbackURL.searchParams.set("redirect", redirectTo);
+ const result = await authClient.sendVerificationEmail({
+ email: verificationEmail,
+ callbackURL: callbackURL.toString(),
+ });
+
+ if (result.error) {
+ toast.error(result.error.message || "We couldn't send another email.");
+ return;
+ }
+
+ toast.success("A new email is on the way.");
+ } catch {
+ toast.error(
+ "We couldn't send another email right now. Please try again.",
+ );
+ } finally {
+ setIsSendingVerification(false);
+ }
+ }
+
return (
Email
- {(field) => (
- <>
- field.handleChange(event.target.value)}
- autoComplete="email"
- disabled={!isHostedMode || isSessionPending}
- required
- />
- {field.state.meta.errors[0] ? (
-
- {field.state.meta.errors[0]}
-
- ) : null}
- >
- )}
+ {(field) => {
+ const error = getFieldError(field.state.meta.errors);
+
+ return (
+ <>
+ field.handleChange(event.target.value)}
+ autoComplete="email"
+ disabled={!isHostedMode || isSessionPending}
+ required
+ />
+ {error ? (
+ {error}
+ ) : null}
+ >
+ );
+ }}
+
+
+ Forgot password?
+
+
+
+ {verificationEmail ? (
+
+
+
+ Please check {verificationEmail} for a link to confirm your
+ email.
+
+
+
+
+ ) : null}
+
({
submitError: state.errorMap.onSubmit,
isSubmitting: state.isSubmitting,
})}
>
- {({ submitError, isSubmitting }) => (
- <>
- {submitError ? (
- {submitError}
- ) : null}
-
- >
- )}
+ {({ submitError, isSubmitting }) => {
+ const errorMessage = getFormError(submitError);
+ return (
+ <>
+ {errorMessage ? (
+ {errorMessage}
+ ) : null}
+
+ >
+ );
+ }}
diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx
index 4144b63..c93e722 100644
--- a/src/routes/_auth.sign-up.tsx
+++ b/src/routes/_auth.sign-up.tsx
@@ -1,9 +1,10 @@
import { useForm } from "@tanstack/react-form";
-import { Link, createFileRoute } from "@tanstack/react-router";
+import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import {
AuthPageCard,
authRedirectSearchSchema,
getFieldError,
+ getFormError,
useAuthPageState,
} from "@/client/features/auth/AuthPage";
import { authClient } from "@/lib/auth-client";
@@ -16,7 +17,7 @@ import { z } from "zod";
const signUpSchema = z
.object({
- name: z.string().trim().optional(),
+ name: z.string().trim(),
email: z.string().trim().email("Enter a valid email address."),
password: z
.string()
@@ -35,13 +36,6 @@ const signUpSchema = z
path: ["confirmPassword"],
});
-type SignUpValues = {
- name: string;
- email: string;
- password: string;
- confirmPassword: string;
-};
-
export const Route = createFileRoute("/_auth/sign-up")({
validateSearch: authRedirectSearchSchema,
component: SignUpPage,
@@ -50,36 +44,12 @@ export const Route = createFileRoute("/_auth/sign-up")({
function getHelperText(isHostedMode: boolean) {
return isHostedMode
? "Create your OpenSEO account."
- : "Account creation is only available when AUTH_MODE=hosted.";
-}
-
-function getSignUpValidationErrors(value: SignUpValues) {
- const parsed = signUpSchema.safeParse(value);
-
- if (parsed.success) {
- return null;
- }
-
- return {
- form:
- parsed.error.issues[0]?.message || "Please check your account details.",
- fields: parsed.error.issues.reduce>(
- (errors, issue) => {
- const path = issue.path.join(".");
-
- if (path && !errors[path]) {
- errors[path] = issue.message;
- }
-
- return errors;
- },
- {},
- ),
- };
+ : "Account creation isn't available right now.";
}
function SignUpPage() {
const search = Route.useSearch();
+ const navigate = useNavigate();
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
search.redirect,
);
@@ -93,7 +63,7 @@ function SignUpPage() {
confirmPassword: "",
},
validators: {
- onSubmit: ({ value }) => getSignUpValidationErrors(value),
+ onSubmit: signUpSchema,
},
onSubmit: async ({ formApi, value }) => {
try {
@@ -104,17 +74,34 @@ function SignUpPage() {
name: resolvedName,
email,
password: value.password,
- callbackURL: redirectTo,
+ callbackURL: (() => {
+ const url = new URL("/verify-email", window.location.origin);
+ if (redirectTo !== "/")
+ url.searchParams.set("redirect", redirectTo);
+ return url.toString();
+ })(),
});
if (result.error) {
formApi.setErrorMap({
- onSubmit: result.error.message || "Unable to create account.",
+ onSubmit: {
+ form: result.error.message || "We couldn't create your account.",
+ fields: {},
+ },
});
+ return;
}
+
+ void navigate({
+ to: "/verify-email",
+ search: { email, ...getSignInSearch(redirectTo) },
+ });
} catch {
formApi.setErrorMap({
- onSubmit: "Unable to create account right now. Please try again.",
+ onSubmit: {
+ form: "We couldn't create your account right now. Please try again.",
+ fields: {},
+ },
});
}
},
@@ -265,19 +252,22 @@ function SignUpPage() {
isSubmitting: state.isSubmitting,
})}
>
- {({ submitError, isSubmitting }) => (
- <>
- {submitError ? (
- {submitError}
- ) : null}
-
- >
- )}
+ {({ submitError, isSubmitting }) => {
+ const errorMessage = getFormError(submitError);
+ return (
+ <>
+ {errorMessage ? (
+ {errorMessage}
+ ) : null}
+
+ >
+ );
+ }}
diff --git a/src/routes/_auth.tsx b/src/routes/_auth.tsx
index c29f8a2..fcf12e7 100644
--- a/src/routes/_auth.tsx
+++ b/src/routes/_auth.tsx
@@ -1,6 +1,9 @@
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
-import { authRedirectSearchSchema } from "@/client/features/auth/AuthPage";
+import {
+ AuthPageShell,
+ authRedirectSearchSchema,
+} from "@/client/features/auth/AuthPage";
import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
@@ -30,10 +33,8 @@ function AuthPageLayout() {
}
return (
-
+
+
+
);
}
diff --git a/src/routes/forgot-password.tsx b/src/routes/forgot-password.tsx
new file mode 100644
index 0000000..4898de6
--- /dev/null
+++ b/src/routes/forgot-password.tsx
@@ -0,0 +1,165 @@
+import { useForm } from "@tanstack/react-form";
+import { Link, createFileRoute } from "@tanstack/react-router";
+import {
+ AuthPageCard,
+ AuthPageShell,
+ authRedirectSearchSchema,
+ getFieldError,
+ getFormError,
+} from "@/client/features/auth/AuthPage";
+import { authClient } from "@/lib/auth-client";
+import { isHostedClientAuthMode } from "@/lib/auth-mode";
+import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
+import { z } from "zod";
+
+const forgotPasswordSchema = z.object({
+ email: z.string().trim().email("Enter a valid email address."),
+});
+
+export const Route = createFileRoute("/forgot-password")({
+ validateSearch: authRedirectSearchSchema,
+ component: ForgotPasswordPage,
+});
+
+function ForgotPasswordPage() {
+ const search = Route.useSearch();
+ const redirectTo = normalizeAuthRedirect(search.redirect);
+ const isHostedMode = isHostedClientAuthMode();
+
+ const form = useForm({
+ defaultValues: {
+ email: "",
+ },
+ validators: {
+ onSubmit: forgotPasswordSchema,
+ },
+ onSubmit: async ({ formApi, value }) => {
+ try {
+ const redirectUrl = new URL("/reset-password", window.location.origin);
+ if (redirectTo !== "/")
+ redirectUrl.searchParams.set("redirect", redirectTo);
+ const result = await authClient.requestPasswordReset({
+ email: value.email.trim(),
+ redirectTo: redirectUrl.toString(),
+ });
+
+ if (result.error) {
+ formApi.setErrorMap({
+ onSubmit: {
+ form: result.error.message || "We couldn't send the reset email.",
+ fields: {},
+ },
+ });
+ return;
+ }
+ } catch {
+ formApi.setErrorMap({
+ onSubmit: {
+ form: "We couldn't send the reset email right now. Please try again.",
+ fields: {},
+ },
+ });
+ }
+ },
+ });
+
+ return (
+
+ ({
+ isSuccess: state.isSubmitSuccessful && !state.errorMap.onSubmit,
+ submittedEmail: state.values.email,
+ submitError: state.errorMap.onSubmit,
+ isSubmitting: state.isSubmitting,
+ })}
+ >
+ {({ isSuccess, submittedEmail, submitError, isSubmitting }) => {
+ const errorMessage = getFormError(submitError);
+
+ return (
+
+ Remembered it?{" "}
+
+ Back to sign in
+
+
+ }
+ >
+ {isSuccess ? (
+
+
+ If an account exists for that email, you'll receive password
+ reset instructions shortly.
+
+
+ ) : (
+
+ )}
+
+ );
+ }}
+
+
+ );
+}
diff --git a/src/routes/reset-password.tsx b/src/routes/reset-password.tsx
new file mode 100644
index 0000000..adf8286
--- /dev/null
+++ b/src/routes/reset-password.tsx
@@ -0,0 +1,296 @@
+import { useForm } from "@tanstack/react-form";
+import { Link, createFileRoute } from "@tanstack/react-router";
+import {
+ AuthPageCard,
+ AuthPageShell,
+ authRedirectSearchSchema,
+ getFieldError,
+ getFormError,
+} from "@/client/features/auth/AuthPage";
+import { authClient } from "@/lib/auth-client";
+import { isHostedClientAuthMode } from "@/lib/auth-mode";
+import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
+import {
+ HOSTED_PASSWORD_MAX_LENGTH,
+ HOSTED_PASSWORD_MIN_LENGTH,
+} from "@/lib/auth-options";
+import { z } from "zod";
+
+const resetPasswordSchema = z
+ .object({
+ password: z
+ .string()
+ .min(
+ HOSTED_PASSWORD_MIN_LENGTH,
+ `Password must be at least ${HOSTED_PASSWORD_MIN_LENGTH} characters.`,
+ )
+ .max(
+ HOSTED_PASSWORD_MAX_LENGTH,
+ `Password must be at most ${HOSTED_PASSWORD_MAX_LENGTH} characters.`,
+ ),
+ confirmPassword: z.string(),
+ })
+ .refine((value) => value.password === value.confirmPassword, {
+ message: "Passwords do not match.",
+ path: ["confirmPassword"],
+ });
+
+const resetPasswordSearchSchema = authRedirectSearchSchema.extend({
+ error: z.string().optional(),
+ token: z.string().optional(),
+});
+
+export const Route = createFileRoute("/reset-password")({
+ validateSearch: resetPasswordSearchSchema,
+ component: ResetPasswordPage,
+});
+
+function getResetPasswordErrorMessage(error: string | undefined) {
+ switch ((error ?? "").toLowerCase()) {
+ case "invalid_token":
+ return "This reset link is no longer valid. Request a new one to keep going.";
+ case "token_expired":
+ return "This reset link has expired. Request a new one to keep going.";
+ default:
+ return error
+ ? "This reset link can't be used anymore. Request a new one and try again."
+ : null;
+ }
+}
+
+function getResetPasswordPageCopy({
+ isHostedMode,
+ isComplete,
+ routeError,
+ hasToken,
+}: {
+ isHostedMode: boolean;
+ isComplete: boolean;
+ routeError: string | null;
+ hasToken: boolean;
+}) {
+ if (!isHostedMode) {
+ return {
+ title: "Reset password",
+ helperText: "Password reset isn't available right now.",
+ };
+ }
+
+ if (isComplete) {
+ return {
+ title: "Password updated",
+ helperText:
+ "Your password has been updated. Sign in with your new password.",
+ };
+ }
+
+ if (routeError || !hasToken) {
+ return {
+ title: "Reset link expired",
+ helperText:
+ routeError ||
+ "This reset link is no longer valid. Request a new one to keep going.",
+ };
+ }
+
+ return {
+ title: "Reset password",
+ helperText: "Choose a new password for your account.",
+ };
+}
+
+function ResetPasswordPage() {
+ const search = Route.useSearch();
+ const redirectTo = normalizeAuthRedirect(search.redirect);
+ const isHostedMode = isHostedClientAuthMode();
+ const routeError = getResetPasswordErrorMessage(search.error);
+ const token = typeof search.token === "string" ? search.token : null;
+ const form = useForm({
+ defaultValues: {
+ password: "",
+ confirmPassword: "",
+ },
+ validators: {
+ onSubmit: resetPasswordSchema,
+ },
+ onSubmit: async ({ formApi, value }) => {
+ if (!token) {
+ formApi.setErrorMap({
+ onSubmit: {
+ form: "This reset link is no longer valid. Request a new one and try again.",
+ fields: {},
+ },
+ });
+ return;
+ }
+
+ try {
+ const result = await authClient.resetPassword({
+ newPassword: value.password,
+ token,
+ });
+
+ if (result.error) {
+ formApi.setErrorMap({
+ onSubmit: {
+ form: "This reset link is no longer valid. Request a new one and try again.",
+ fields: {},
+ },
+ });
+ return;
+ }
+ } catch {
+ formApi.setErrorMap({
+ onSubmit: {
+ form: "We couldn't update your password right now. Please try again.",
+ fields: {},
+ },
+ });
+ }
+ },
+ });
+
+ return (
+
+ ({
+ isComplete: state.isSubmitSuccessful && !state.errorMap.onSubmit,
+ submitError: state.errorMap.onSubmit,
+ isSubmitting: state.isSubmitting,
+ })}
+ >
+ {({ isComplete, submitError, isSubmitting }) => {
+ const errorMessage = getFormError(submitError);
+ const pageCopy = getResetPasswordPageCopy({
+ isHostedMode,
+ isComplete,
+ routeError,
+ hasToken: !!token,
+ });
+
+ return (
+
+ Back to{" "}
+
+ sign in
+
+
+ }
+ >
+ {!isHostedMode ? null : isComplete ? (
+
+ Continue to sign in
+
+ ) : routeError || !token ? (
+
+ Request a new reset link
+
+ ) : (
+
+ )}
+
+ );
+ }}
+
+
+ );
+}
diff --git a/src/routes/verify-email.tsx b/src/routes/verify-email.tsx
new file mode 100644
index 0000000..79b76ff
--- /dev/null
+++ b/src/routes/verify-email.tsx
@@ -0,0 +1,216 @@
+import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ AuthPageCard,
+ AuthPageShell,
+ authRedirectSearchSchema,
+} from "@/client/features/auth/AuthPage";
+import { authClient, useSession } from "@/lib/auth-client";
+import { isHostedClientAuthMode } from "@/lib/auth-mode";
+import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
+import { z } from "zod";
+
+const verifyEmailSearchSchema = authRedirectSearchSchema.extend({
+ error: z.string().optional(),
+ email: z.string().optional(),
+});
+
+export const Route = createFileRoute("/verify-email")({
+ validateSearch: verifyEmailSearchSchema,
+ component: VerifyEmailPage,
+});
+
+function getVerificationErrorMessage(error: string | undefined) {
+ switch ((error ?? "").toLowerCase()) {
+ case "invalid_token":
+ return "This link is no longer valid. Request a new email to keep going.";
+ case "token_expired":
+ return "This link has expired. Request a new email to keep going.";
+ case "user_not_found":
+ return "We couldn't find this account anymore. Try creating it again.";
+ default:
+ return error
+ ? "We couldn't confirm this email. Request a new email and try again."
+ : null;
+ }
+}
+
+function getVerifyEmailPageCopy({
+ isHostedMode,
+ errorMessage,
+ isWaiting,
+ isPending,
+ isVerified,
+ email,
+}: {
+ isHostedMode: boolean;
+ errorMessage: string | null;
+ isWaiting: boolean;
+ isPending: boolean;
+ isVerified: boolean;
+ email: string | undefined;
+}) {
+ if (!isHostedMode) {
+ return {
+ title: "Verify email",
+ helperText: "Email confirmation isn't available right now.",
+ };
+ }
+
+ if (errorMessage) {
+ return {
+ title: "We couldn't confirm your email",
+ helperText: errorMessage,
+ };
+ }
+
+ if (isWaiting && email) {
+ return {
+ title: "Check your email",
+ helperText: `We sent a confirmation link to ${email}. Open it to confirm your email.`,
+ };
+ }
+
+ if (isPending) {
+ return {
+ title: "Verify email",
+ helperText: "Checking your email confirmation.",
+ };
+ }
+
+ if (isVerified) {
+ return {
+ title: "Email confirmed",
+ helperText: "You're all set. Taking you to your account now.",
+ };
+ }
+
+ return {
+ title: "Email confirmed",
+ helperText: "Your email is confirmed. You can sign in now.",
+ };
+}
+
+function VerifyEmailPage() {
+ const search = Route.useSearch();
+ const navigate = useNavigate();
+ const redirectTo = normalizeAuthRedirect(search.redirect);
+ const isHostedMode = isHostedClientAuthMode();
+ const { data: session, isPending } = useSession();
+ const errorMessage = getVerificationErrorMessage(search.error);
+ const email = search.email;
+ const isWaiting = !errorMessage && !session?.user?.emailVerified && !!email;
+ const [isResending, setIsResending] = useState(false);
+ const isVerified = !!session?.user?.emailVerified;
+ const pageCopy = getVerifyEmailPageCopy({
+ isHostedMode,
+ errorMessage,
+ isWaiting,
+ isPending,
+ isVerified,
+ email,
+ });
+
+ useEffect(() => {
+ if (!isVerified) {
+ return;
+ }
+
+ void navigate({ href: redirectTo, replace: true });
+ }, [isVerified, navigate, redirectTo]);
+
+ async function handleResend() {
+ if (!email) return;
+ setIsResending(true);
+ try {
+ const callbackURL = new URL("/verify-email", window.location.origin);
+ if (redirectTo !== "/")
+ callbackURL.searchParams.set("redirect", redirectTo);
+ const result = await authClient.sendVerificationEmail({
+ email,
+ callbackURL: callbackURL.toString(),
+ });
+ if (result.error) {
+ toast.error(result.error.message || "We couldn't send another email.");
+ return;
+ }
+ toast.success("A new email is on the way.");
+ } catch {
+ toast.error(
+ "We couldn't send another email right now. Please try again.",
+ );
+ } finally {
+ setIsResending(false);
+ }
+ }
+
+ return (
+
+
+ Need to sign in instead?{" "}
+
+ Open sign in
+
+
+ }
+ >
+ {!isHostedMode ? null : errorMessage ? (
+
+
+ {errorMessage}
+
+
+ Back to sign in
+
+
+ ) : isWaiting ? (
+
+
+
+ After you click the link in your email, this page will finish up
+ automatically.
+
+
+
+
+ ) : isPending ? (
+
+
+
+ ) : isVerified ? (
+
+
+
+ ) : (
+
+ Sign in to continue
+
+ )}
+
+
+ );
+}
diff --git a/src/server/email/loops.ts b/src/server/email/loops.ts
new file mode 100644
index 0000000..37e42ed
--- /dev/null
+++ b/src/server/email/loops.ts
@@ -0,0 +1,106 @@
+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;
+}) {
+ 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,
+ },
+ });
+}