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
|
# Required when AUTH_MODE=hosted
|
||||||
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters
|
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters
|
||||||
# BETTER_AUTH_URL=http://localhost:3001
|
# 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[]) {
|
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({
|
export function AuthPageCard({
|
||||||
@ -49,3 +61,13 @@ export function AuthPageCard({
|
|||||||
</div>
|
</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 { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||||
@ -6,6 +6,10 @@ import { db } from "@/db";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { baseAuthConfig } from "@/lib/auth-config";
|
import { baseAuthConfig } from "@/lib/auth-config";
|
||||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
|
import {
|
||||||
|
sendHostedPasswordResetEmail,
|
||||||
|
sendHostedVerificationEmail,
|
||||||
|
} from "@/server/email/loops";
|
||||||
|
|
||||||
const hostedBaseUrlSchema = z
|
const hostedBaseUrlSchema = z
|
||||||
.string()
|
.string()
|
||||||
@ -25,6 +29,35 @@ function createAuth() {
|
|||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
secret: getHostedSecret(),
|
secret: getHostedSecret(),
|
||||||
...baseAuthConfig,
|
...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),
|
trustedOrigins: getTrustedOrigins(baseUrl),
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "sqlite",
|
provider: "sqlite",
|
||||||
@ -95,11 +128,24 @@ function getHostedSecret() {
|
|||||||
return secret;
|
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() {
|
export function hasHostedAuthConfig() {
|
||||||
try {
|
try {
|
||||||
getHostedBaseUrl();
|
getHostedBaseUrl();
|
||||||
getHostedSecret();
|
getHostedSecret();
|
||||||
return true;
|
return hasHostedAuthEmailConfig();
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
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.
|
// 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 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 AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as ProjectRouteRouteImport } from './routes/_project/route'
|
import { Route as ProjectRouteRouteImport } from './routes/_project/route'
|
||||||
import { Route as AppRouteRouteImport } from './routes/_app/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 ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
|
||||||
import { Route as ProjectPProjectIdAuditIssuesResultIdRouteImport } from './routes/_project/p/$projectId/audit/issues/$resultId'
|
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({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
id: '/_auth',
|
id: '/_auth',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
@ -128,6 +146,9 @@ const ProjectPProjectIdAuditIssuesResultIdRoute =
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof AppIndexRoute
|
'/': typeof AppIndexRoute
|
||||||
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
'/sign-up': typeof AuthSignUpRoute
|
'/sign-up': typeof AuthSignUpRoute
|
||||||
@ -146,6 +167,9 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof AppIndexRoute
|
'/': typeof AppIndexRoute
|
||||||
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
'/sign-up': typeof AuthSignUpRoute
|
'/sign-up': typeof AuthSignUpRoute
|
||||||
@ -165,6 +189,9 @@ export interface FileRoutesById {
|
|||||||
'/_app': typeof AppRouteRouteWithChildren
|
'/_app': typeof AppRouteRouteWithChildren
|
||||||
'/_project': typeof ProjectRouteRouteWithChildren
|
'/_project': typeof ProjectRouteRouteWithChildren
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/_app/billing': typeof AppBillingRoute
|
'/_app/billing': typeof AppBillingRoute
|
||||||
'/_auth/sign-in': typeof AuthSignInRoute
|
'/_auth/sign-in': typeof AuthSignInRoute
|
||||||
'/_auth/sign-up': typeof AuthSignUpRoute
|
'/_auth/sign-up': typeof AuthSignUpRoute
|
||||||
@ -186,6 +213,9 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/forgot-password'
|
||||||
|
| '/reset-password'
|
||||||
|
| '/verify-email'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
@ -204,6 +234,9 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/forgot-password'
|
||||||
|
| '/reset-password'
|
||||||
|
| '/verify-email'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
@ -222,6 +255,9 @@ export interface FileRouteTypes {
|
|||||||
| '/_app'
|
| '/_app'
|
||||||
| '/_project'
|
| '/_project'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
|
| '/forgot-password'
|
||||||
|
| '/reset-password'
|
||||||
|
| '/verify-email'
|
||||||
| '/_app/billing'
|
| '/_app/billing'
|
||||||
| '/_auth/sign-in'
|
| '/_auth/sign-in'
|
||||||
| '/_auth/sign-up'
|
| '/_auth/sign-up'
|
||||||
@ -244,11 +280,35 @@ export interface RootRouteChildren {
|
|||||||
AppRouteRoute: typeof AppRouteRouteWithChildren
|
AppRouteRoute: typeof AppRouteRouteWithChildren
|
||||||
ProjectRouteRoute: typeof ProjectRouteRouteWithChildren
|
ProjectRouteRoute: typeof ProjectRouteRouteWithChildren
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
|
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||||
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
|
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
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': {
|
'/_auth': {
|
||||||
id: '/_auth'
|
id: '/_auth'
|
||||||
path: ''
|
path: ''
|
||||||
@ -472,6 +532,9 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
AppRouteRoute: AppRouteRouteWithChildren,
|
AppRouteRoute: AppRouteRouteWithChildren,
|
||||||
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
|
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||||
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
|
VerifyEmailRoute: VerifyEmailRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
|
getFieldError,
|
||||||
|
getFormError,
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
@ -21,71 +25,105 @@ export const Route = createFileRoute("/_auth/sign-in")({
|
|||||||
|
|
||||||
function getHelperText(isHostedMode: boolean) {
|
function getHelperText(isHostedMode: boolean) {
|
||||||
if (!isHostedMode) {
|
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.";
|
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() {
|
function SignInPage() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
|
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
|
||||||
search.redirect,
|
search.redirect,
|
||||||
);
|
);
|
||||||
const helperText = getHelperText(isHostedMode);
|
const helperText = getHelperText(isHostedMode);
|
||||||
|
const [verificationEmail, setVerificationEmail] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isSendingVerification, setIsSendingVerification] = useState(false);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
validators: {
|
validators: {
|
||||||
onSubmit: ({ value }) => getSignInValidationErrors(value),
|
onSubmit: signInSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async ({ formApi, value }) => {
|
onSubmit: async ({ formApi, value }) => {
|
||||||
try {
|
try {
|
||||||
|
const email = value.email.trim();
|
||||||
|
setVerificationEmail(null);
|
||||||
|
|
||||||
const result = await authClient.signIn.email({
|
const result = await authClient.signIn.email({
|
||||||
email: value.email.trim(),
|
email,
|
||||||
password: value.password,
|
password: value.password,
|
||||||
callbackURL: redirectTo,
|
callbackURL: redirectTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (!result.error) {
|
||||||
formApi.setErrorMap({
|
return;
|
||||||
onSubmit: result.error.message || "Unable to sign in.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
} catch {
|
||||||
formApi.setErrorMap({
|
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 (
|
return (
|
||||||
<AuthPageCard
|
<AuthPageCard
|
||||||
title="Sign in"
|
title="Sign in"
|
||||||
@ -115,72 +153,112 @@ function SignInPage() {
|
|||||||
<label className="form-control block">
|
<label className="form-control block">
|
||||||
<span className="label-text text-sm font-medium">Email</span>
|
<span className="label-text text-sm font-medium">Email</span>
|
||||||
<form.Field name="email">
|
<form.Field name="email">
|
||||||
{(field) => (
|
{(field) => {
|
||||||
<>
|
const error = getFieldError(field.state.meta.errors);
|
||||||
<input
|
|
||||||
type="email"
|
return (
|
||||||
className="input input-bordered w-full mt-1"
|
<>
|
||||||
placeholder="you@example.com"
|
<input
|
||||||
value={field.state.value}
|
type="email"
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
className="input input-bordered w-full mt-1"
|
||||||
autoComplete="email"
|
placeholder="you@example.com"
|
||||||
disabled={!isHostedMode || isSessionPending}
|
value={field.state.value}
|
||||||
required
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
/>
|
autoComplete="email"
|
||||||
{field.state.meta.errors[0] ? (
|
disabled={!isHostedMode || isSessionPending}
|
||||||
<p className="mt-1 text-sm text-error">
|
required
|
||||||
{field.state.meta.errors[0]}
|
/>
|
||||||
</p>
|
{error ? (
|
||||||
) : null}
|
<p className="mt-1 text-sm text-error">{error}</p>
|
||||||
</>
|
) : null}
|
||||||
)}
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="form-control block">
|
<label className="form-control block">
|
||||||
<span className="label-text text-sm font-medium">Password</span>
|
<span className="label-text text-sm font-medium">Password</span>
|
||||||
<form.Field name="password">
|
<form.Field name="password">
|
||||||
{(field) => (
|
{(field) => {
|
||||||
<>
|
const error = getFieldError(field.state.meta.errors);
|
||||||
<input
|
|
||||||
type="password"
|
return (
|
||||||
className="input input-bordered w-full mt-1"
|
<>
|
||||||
placeholder="Enter your password"
|
<input
|
||||||
value={field.state.value}
|
type="password"
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
className="input input-bordered w-full mt-1"
|
||||||
autoComplete="current-password"
|
placeholder="Enter your password"
|
||||||
disabled={!isHostedMode || isSessionPending}
|
value={field.state.value}
|
||||||
required
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
/>
|
autoComplete="current-password"
|
||||||
{field.state.meta.errors[0] ? (
|
disabled={!isHostedMode || isSessionPending}
|
||||||
<p className="mt-1 text-sm text-error">
|
required
|
||||||
{field.state.meta.errors[0]}
|
/>
|
||||||
</p>
|
{error ? (
|
||||||
) : null}
|
<p className="mt-1 text-sm text-error">{error}</p>
|
||||||
</>
|
) : null}
|
||||||
)}
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</label>
|
</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
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
submitError: state.errorMap.onSubmit,
|
submitError: state.errorMap.onSubmit,
|
||||||
isSubmitting: state.isSubmitting,
|
isSubmitting: state.isSubmitting,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{({ submitError, isSubmitting }) => (
|
{({ submitError, isSubmitting }) => {
|
||||||
<>
|
const errorMessage = getFormError(submitError);
|
||||||
{submitError ? (
|
return (
|
||||||
<p className="text-sm text-error">{submitError}</p>
|
<>
|
||||||
) : null}
|
{errorMessage ? (
|
||||||
<button
|
<p className="text-sm text-error">{errorMessage}</p>
|
||||||
className="btn btn-primary w-full"
|
) : null}
|
||||||
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
<button
|
||||||
>
|
className="btn btn-primary w-full"
|
||||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
||||||
</button>
|
>
|
||||||
</>
|
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||||
)}
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
</form>
|
</form>
|
||||||
</AuthPageCard>
|
</AuthPageCard>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
getFieldError,
|
getFieldError,
|
||||||
|
getFormError,
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
@ -16,7 +17,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const signUpSchema = z
|
const signUpSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().trim().optional(),
|
name: z.string().trim(),
|
||||||
email: z.string().trim().email("Enter a valid email address."),
|
email: z.string().trim().email("Enter a valid email address."),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
@ -35,13 +36,6 @@ const signUpSchema = z
|
|||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
});
|
});
|
||||||
|
|
||||||
type SignUpValues = {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
confirmPassword: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/sign-up")({
|
export const Route = createFileRoute("/_auth/sign-up")({
|
||||||
validateSearch: authRedirectSearchSchema,
|
validateSearch: authRedirectSearchSchema,
|
||||||
component: SignUpPage,
|
component: SignUpPage,
|
||||||
@ -50,36 +44,12 @@ export const Route = createFileRoute("/_auth/sign-up")({
|
|||||||
function getHelperText(isHostedMode: boolean) {
|
function getHelperText(isHostedMode: boolean) {
|
||||||
return isHostedMode
|
return isHostedMode
|
||||||
? "Create your OpenSEO account."
|
? "Create your OpenSEO account."
|
||||||
: "Account creation is only available when AUTH_MODE=hosted.";
|
: "Account creation isn't available right now.";
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SignUpPage() {
|
function SignUpPage() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
|
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
|
||||||
search.redirect,
|
search.redirect,
|
||||||
);
|
);
|
||||||
@ -93,7 +63,7 @@ function SignUpPage() {
|
|||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
},
|
},
|
||||||
validators: {
|
validators: {
|
||||||
onSubmit: ({ value }) => getSignUpValidationErrors(value),
|
onSubmit: signUpSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async ({ formApi, value }) => {
|
onSubmit: async ({ formApi, value }) => {
|
||||||
try {
|
try {
|
||||||
@ -104,17 +74,34 @@ function SignUpPage() {
|
|||||||
name: resolvedName,
|
name: resolvedName,
|
||||||
email,
|
email,
|
||||||
password: value.password,
|
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) {
|
if (result.error) {
|
||||||
formApi.setErrorMap({
|
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 {
|
} catch {
|
||||||
formApi.setErrorMap({
|
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,
|
isSubmitting: state.isSubmitting,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{({ submitError, isSubmitting }) => (
|
{({ submitError, isSubmitting }) => {
|
||||||
<>
|
const errorMessage = getFormError(submitError);
|
||||||
{submitError ? (
|
return (
|
||||||
<p className="text-sm text-error">{submitError}</p>
|
<>
|
||||||
) : null}
|
{errorMessage ? (
|
||||||
<button
|
<p className="text-sm text-error">{errorMessage}</p>
|
||||||
className="btn btn-primary w-full"
|
) : null}
|
||||||
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
<button
|
||||||
>
|
className="btn btn-primary w-full"
|
||||||
{isSubmitting ? "Creating account..." : "Create account"}
|
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
||||||
</button>
|
>
|
||||||
</>
|
{isSubmitting ? "Creating account..." : "Create account"}
|
||||||
)}
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
</form>
|
</form>
|
||||||
</AuthPageCard>
|
</AuthPageCard>
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect } from "react";
|
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 { useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||||
@ -30,10 +33,8 @@ function AuthPageLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[100dvh] bg-base-200">
|
<AuthPageShell>
|
||||||
<div className="min-h-[100dvh] flex items-center justify-center p-4">
|
<Outlet />
|
||||||
<Outlet />
|
</AuthPageShell>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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