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 ( +
+
+ {children} +
+
+ ); +} 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. + +
+ ) : ( +
{ + event.preventDefault(); + void form.handleSubmit(); + }} + > + + + {errorMessage ? ( +

{errorMessage}

+ ) : null} + +
+ )} +
+ ); + }} +
+
+ ); +} 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 + + ) : ( +
{ + event.preventDefault(); + void form.handleSubmit(); + }} + > + + + + + {errorMessage ? ( +

{errorMessage}

+ ) : null} + +
+ )} +
+ ); + }} +
+
+ ); +} 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, + }, + }); +}