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
This commit is contained in:
Ben Senescu 2026-03-26 20:25:45 -04:00 committed by Ben Senescu
parent 638f5a6602
commit aae759ff1e
11 changed files with 1130 additions and 144 deletions

View File

@ -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

View File

@ -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({
</div>
);
}
export function AuthPageShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-[100dvh] bg-base-200">
<div className="min-h-[100dvh] flex items-center justify-center p-4">
{children}
</div>
</div>
);
}

View File

@ -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;
}

View File

@ -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

View File

@ -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<Record<string, string>>(
(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<string | null>(
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 (
<AuthPageCard
title="Sign in"
@ -115,72 +153,112 @@ function SignInPage() {
<label className="form-control block">
<span className="label-text text-sm font-medium">Email</span>
<form.Field name="email">
{(field) => (
<>
<input
type="email"
className="input input-bordered w-full mt-1"
placeholder="you@example.com"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode || isSessionPending}
required
/>
{field.state.meta.errors[0] ? (
<p className="mt-1 text-sm text-error">
{field.state.meta.errors[0]}
</p>
) : null}
</>
)}
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<>
<input
type="email"
className="input input-bordered w-full mt-1"
placeholder="you@example.com"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode || isSessionPending}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</>
);
}}
</form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">Password</span>
<form.Field name="password">
{(field) => (
<>
<input
type="password"
className="input input-bordered w-full mt-1"
placeholder="Enter your password"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="current-password"
disabled={!isHostedMode || isSessionPending}
required
/>
{field.state.meta.errors[0] ? (
<p className="mt-1 text-sm text-error">
{field.state.meta.errors[0]}
</p>
) : null}
</>
)}
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<>
<input
type="password"
className="input input-bordered w-full mt-1"
placeholder="Enter your password"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="current-password"
disabled={!isHostedMode || isSessionPending}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</>
);
}}
</form.Field>
</label>
<div className="text-right">
<Link
to="/forgot-password"
search={getSignInSearch(redirectTo)}
className="link link-hover text-sm"
>
Forgot password?
</Link>
</div>
{verificationEmail ? (
<div className="alert alert-warning items-start">
<div className="space-y-3">
<p className="text-sm">
Please check {verificationEmail} for a link to confirm your
email.
</p>
<button
type="button"
className="btn btn-sm btn-outline"
onClick={() => {
void handleResendVerification();
}}
disabled={isSendingVerification}
>
{isSendingVerification
? "Sending email..."
: "Send another email"}
</button>
</div>
</div>
) : null}
<form.Subscribe
selector={(state) => ({
submitError: state.errorMap.onSubmit,
isSubmitting: state.isSubmitting,
})}
>
{({ submitError, isSubmitting }) => (
<>
{submitError ? (
<p className="text-sm text-error">{submitError}</p>
) : null}
<button
className="btn btn-primary w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>
</>
)}
{({ submitError, isSubmitting }) => {
const errorMessage = getFormError(submitError);
return (
<>
{errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p>
) : null}
<button
className="btn btn-primary w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>
</>
);
}}
</form.Subscribe>
</form>
</AuthPageCard>

View File

@ -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<Record<string, string>>(
(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 ? (
<p className="text-sm text-error">{submitError}</p>
) : null}
<button
className="btn btn-primary w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting}
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</>
)}
{({ submitError, isSubmitting }) => {
const errorMessage = getFormError(submitError);
return (
<>
{errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p>
) : null}
<button
className="btn btn-primary w-full"
disabled={!isHostedMode || isSessionPending || isSubmitting}
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</>
);
}}
</form.Subscribe>
</form>
</AuthPageCard>

View File

@ -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 (
<div className="min-h-[100dvh] bg-base-200">
<div className="min-h-[100dvh] flex items-center justify-center p-4">
<Outlet />
</div>
</div>
<AuthPageShell>
<Outlet />
</AuthPageShell>
);
}

View File

@ -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 (
<AuthPageShell>
<form.Subscribe
selector={(state) => ({
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 (
<AuthPageCard
title={isSuccess ? "Check your email" : "Forgot password"}
helperText={
isSuccess
? `If an account exists for ${submittedEmail}, we sent a reset link.`
: isHostedMode
? "Enter your email and we'll send you a password reset link."
: "Password reset isn't available right now."
}
footer={
<p className="text-sm text-base-content/70">
Remembered it?{" "}
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="link link-primary"
>
Back to sign in
</Link>
</p>
}
>
{isSuccess ? (
<div className="alert alert-success">
<span>
If an account exists for that email, you'll receive password
reset instructions shortly.
</span>
</div>
) : (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<label className="form-control block">
<span className="label-text text-sm font-medium">
Email
</span>
<form.Field name="email">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<>
<input
type="email"
className="input input-bordered w-full mt-1"
placeholder="you@example.com"
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
autoComplete="email"
disabled={!isHostedMode}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</>
);
}}
</form.Field>
</label>
{errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p>
) : null}
<button
className="btn btn-primary w-full"
disabled={!isHostedMode || isSubmitting}
>
{isSubmitting ? "Sending reset link..." : "Send reset link"}
</button>
</form>
)}
</AuthPageCard>
);
}}
</form.Subscribe>
</AuthPageShell>
);
}

View File

@ -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 (
<AuthPageShell>
<form.Subscribe
selector={(state) => ({
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 (
<AuthPageCard
title={pageCopy.title}
helperText={pageCopy.helperText}
footer={
<p className="text-sm text-base-content/70">
Back to{" "}
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="link link-primary"
>
sign in
</Link>
</p>
}
>
{!isHostedMode ? null : isComplete ? (
<a
href={
redirectTo === "/"
? "/sign-in"
: `/sign-in?redirect=${encodeURIComponent(redirectTo)}`
}
className="btn btn-primary w-full"
>
Continue to sign in
</a>
) : routeError || !token ? (
<Link
to="/forgot-password"
search={getSignInSearch(redirectTo)}
className="btn btn-primary w-full"
>
Request a new reset link
</Link>
) : (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<label className="form-control block">
<span className="label-text text-sm font-medium">
New password
</span>
<form.Field name="password">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<>
<input
type="password"
className="input input-bordered w-full mt-1"
placeholder="Create a new password"
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
autoComplete="new-password"
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</>
);
}}
</form.Field>
</label>
<label className="form-control block">
<span className="label-text text-sm font-medium">
Confirm password
</span>
<form.Field name="confirmPassword">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<>
<input
type="password"
className="input input-bordered w-full mt-1"
placeholder="Confirm your new password"
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
autoComplete="new-password"
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</>
);
}}
</form.Field>
</label>
{errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p>
) : null}
<button
className="btn btn-primary w-full"
disabled={isSubmitting}
>
{isSubmitting ? "Updating password..." : "Update password"}
</button>
</form>
)}
</AuthPageCard>
);
}}
</form.Subscribe>
</AuthPageShell>
);
}

216
src/routes/verify-email.tsx Normal file
View File

@ -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 (
<AuthPageShell>
<AuthPageCard
title={pageCopy.title}
helperText={pageCopy.helperText}
footer={
<p className="text-sm text-base-content/70">
Need to sign in instead?{" "}
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="link link-primary"
>
Open sign in
</Link>
</p>
}
>
{!isHostedMode ? null : errorMessage ? (
<div className="space-y-3">
<div className="alert alert-error">
<span>{errorMessage}</span>
</div>
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="btn btn-primary w-full"
>
Back to sign in
</Link>
</div>
) : isWaiting ? (
<div className="space-y-4">
<div className="alert alert-success">
<span>
After you click the link in your email, this page will finish up
automatically.
</span>
</div>
<button
type="button"
className="btn btn-outline w-full"
onClick={() => void handleResend()}
disabled={isResending}
>
{isResending ? "Sending email..." : "Send another email"}
</button>
</div>
) : isPending ? (
<div className="flex justify-center py-4">
<span className="loading loading-spinner loading-md" />
</div>
) : isVerified ? (
<div className="flex justify-center py-4">
<span className="loading loading-spinner loading-md" />
</div>
) : (
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="btn btn-primary w-full"
>
Sign in to continue
</Link>
)}
</AuthPageCard>
</AuthPageShell>
);
}

106
src/server/email/loops.ts Normal file
View File

@ -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<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,
},
});
}