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:
parent
638f5a6602
commit
aae759ff1e
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,7 +153,10 @@ function SignInPage() {
|
||||
<label className="form-control block">
|
||||
<span className="label-text text-sm font-medium">Email</span>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
{(field) => {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="email"
|
||||
@ -127,20 +168,22 @@ function SignInPage() {
|
||||
disabled={!isHostedMode || isSessionPending}
|
||||
required
|
||||
/>
|
||||
{field.state.meta.errors[0] ? (
|
||||
<p className="mt-1 text-sm text-error">
|
||||
{field.state.meta.errors[0]}
|
||||
</p>
|
||||
{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) => (
|
||||
{(field) => {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
@ -152,26 +195,60 @@ function SignInPage() {
|
||||
disabled={!isHostedMode || isSessionPending}
|
||||
required
|
||||
/>
|
||||
{field.state.meta.errors[0] ? (
|
||||
<p className="mt-1 text-sm text-error">
|
||||
{field.state.meta.errors[0]}
|
||||
</p>
|
||||
{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, isSubmitting }) => {
|
||||
const errorMessage = getFormError(submitError);
|
||||
return (
|
||||
<>
|
||||
{submitError ? (
|
||||
<p className="text-sm text-error">{submitError}</p>
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-error">{errorMessage}</p>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-primary w-full"
|
||||
@ -180,7 +257,8 @@ function SignInPage() {
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
</AuthPageCard>
|
||||
|
||||
@ -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,10 +252,12 @@ function SignUpPage() {
|
||||
isSubmitting: state.isSubmitting,
|
||||
})}
|
||||
>
|
||||
{({ submitError, isSubmitting }) => (
|
||||
{({ submitError, isSubmitting }) => {
|
||||
const errorMessage = getFormError(submitError);
|
||||
return (
|
||||
<>
|
||||
{submitError ? (
|
||||
<p className="text-sm text-error">{submitError}</p>
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-error">{errorMessage}</p>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-primary w-full"
|
||||
@ -277,7 +266,8 @@ function SignUpPage() {
|
||||
{isSubmitting ? "Creating account..." : "Create account"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
</AuthPageCard>
|
||||
|
||||
@ -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">
|
||||
<AuthPageShell>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
165
src/routes/forgot-password.tsx
Normal file
165
src/routes/forgot-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
src/routes/reset-password.tsx
Normal file
296
src/routes/reset-password.tsx
Normal 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
216
src/routes/verify-email.tsx
Normal 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
106
src/server/email/loops.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user