diff --git a/auth/.env.example b/auth/.env.example index 6e16447..f264af4 100644 --- a/auth/.env.example +++ b/auth/.env.example @@ -9,3 +9,7 @@ DATABASE_URL=postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch # Port the auth service listens on PORT=3001 + +# Resend email provider for transactional email +RESEND_API_KEY=re_your_api_key_here +FROM_EMAIL=CartSnitch diff --git a/auth/package-lock.json b/auth/package-lock.json index 0051e96..ce0c339 100644 --- a/auth/package-lock.json +++ b/auth/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "bcrypt": "^6.0.0", "better-auth": "^1.2.0", - "pg": "^8.13.0" + "pg": "^8.13.0", + "resend": "^6.11.0" }, "devDependencies": { "@types/bcrypt": "^6.0.0", @@ -633,6 +634,12 @@ "node": ">=14" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -858,6 +865,12 @@ "@esbuild/win32-x64": "0.27.4" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1028,6 +1041,12 @@ "split2": "^4.1.0" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1067,6 +1086,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.11.0.tgz", + "integrity": "sha512-S9gxOccfwc+E6Cr3q28Gu8NkiIjYlYPlj9rqk4zkIuzlEoh8sWu/IvJSg7U7t+o3g0Ov2IOCzcneUaCi/M/WdQ==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "svix": "1.90.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1098,6 +1138,26 @@ "node": ">= 10.x" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/svix": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz", + "integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1139,6 +1199,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/auth/package.json b/auth/package.json index c4dcf1f..9eef257 100644 --- a/auth/package.json +++ b/auth/package.json @@ -10,15 +10,16 @@ "generate": "npx @better-auth/cli generate" }, "dependencies": { + "bcrypt": "^6.0.0", "better-auth": "^1.2.0", "pg": "^8.13.0", - "bcrypt": "^6.0.0" + "resend": "^6.11.0" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/node": "^22.0.0", "@types/pg": "^8.11.0", - "@types/bcrypt": "^6.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } -} +} \ No newline at end of file diff --git a/auth/src/auth.ts b/auth/src/auth.ts index c882aac..95bbe2c 100644 --- a/auth/src/auth.ts +++ b/auth/src/auth.ts @@ -1,6 +1,7 @@ import { betterAuth } from "better-auth"; import bcrypt from "bcrypt"; import pg from "pg"; +import { Resend } from "resend"; const { Pool } = pg; @@ -21,6 +22,9 @@ export const pool = new Pool({ connectionString: databaseUrl ?? "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch", }); +const resend = new Resend(process.env.RESEND_API_KEY); +const fromEmail = process.env.FROM_EMAIL || "CartSnitch "; + export const auth = betterAuth({ database: pool, basePath: "/auth", @@ -41,6 +45,19 @@ export const auth = betterAuth({ }, }, + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + await resend.emails.send({ + from: fromEmail, + to: user.email, + subject: "Verify your CartSnitch email", + html: `

Hi ${user.name || ""},

Click the link below to verify your email address:

Verify Email

This link expires in 1 hour.

— CartSnitch

`, + }); + }, + }, + session: { modelName: "sessions", fields: { @@ -103,4 +120,4 @@ export const auth = betterAuth({ "https://cartsnitch.dev.farh.net", "https://cartsnitch.uat.farh.net", ], -}); +}); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index ee4c2dc..953553a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { AccountLinking } from './pages/AccountLinking.tsx' import { Login } from './pages/Login.tsx' import { Register } from './pages/Register.tsx' import { ForgotPassword } from './pages/ForgotPassword.tsx' +import { VerifyEmail } from './pages/VerifyEmail.tsx' const queryClient = new QueryClient({ defaultOptions: { @@ -47,6 +48,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index c75e2d6..f40ea2d 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -9,6 +9,9 @@ export function Register() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [registrationComplete, setRegistrationComplete] = useState(false) + const [resendLoading, setResendLoading] = useState(false) + const [resendMessage, setResendMessage] = useState('') const navigate = useNavigate() const setAuthenticated = useAuthStore((s) => s.setAuthenticated) @@ -38,15 +41,7 @@ export function Register() { throw new Error(authError.message ?? 'Registration failed') } - // After successful signUp, force a session fetch to confirm the cookie is set - // before navigating to the protected route - const sessionResult = await authClient.getSession() - if (sessionResult.data) { - navigate('/') - } else { - // Session not established — show success message and link to login - setError('Account created! Please sign in.') - } + setRegistrationComplete(true) } catch { if (import.meta.env.VITE_MOCK_AUTH === 'true') { setAuthenticated(true) @@ -59,6 +54,49 @@ export function Register() { } } + async function handleResendVerification() { + setResendLoading(true) + setResendMessage('') + try { + const { error } = await authClient.sendVerificationEmail({ email }) + if (error) { + setResendMessage('Failed to resend. Please try again.') + } else { + setResendMessage('Verification email sent!') + } + } finally { + setResendLoading(false) + } + } + + if (registrationComplete) { + return ( +
+

Check your email

+

+ We sent a verification link to {email}. Click it to activate your account. +

+ + {resendMessage && ( +

{resendMessage}

+ )} +

+ Already have an account?{' '} + + Sign in + +

+
+ ) + } + return (

Create Account

diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx new file mode 100644 index 0000000..64da657 --- /dev/null +++ b/src/pages/VerifyEmail.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { authClient } from "../lib/auth-client.ts"; + +export function VerifyEmail() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [status, setStatus] = useState<"verifying" | "success" | "error">("verifying"); + const [resendEmail, setResendEmail] = useState(""); + const [showResend, setShowResend] = useState(false); + const [resending, setResending] = useState(false); + const [resendMessage, setResendMessage] = useState(""); + + useEffect(() => { + const token = searchParams.get("token"); + const callbackURL = searchParams.get("callbackURL") || "/"; + + if (!token) { + setStatus("error"); + return; + } + + authClient.verifyEmail({ query: { token } }) + .then(() => { + setStatus("success"); + setTimeout(() => { + navigate(callbackURL); + }, 2000); + }) + .catch(() => { + setStatus("error"); + }); + }, [searchParams, navigate]); + + async function handleResend() { + if (!resendEmail) { + setResendMessage("Please enter your email address."); + return; + } + + setResending(true); + setResendMessage(""); + + try { + const { error } = await authClient.sendVerificationEmail({ email: resendEmail }); + if (error) { + setResendMessage("Failed to resend. Please try again."); + } else { + setResendMessage("Verification email sent!"); + setShowResend(false); + } + } finally { + setResending(false); + } + } + + return ( +
+ {status === "verifying" && ( + <> +
+

Verifying your email...

+

Please wait while we verify your email address.

+ + )} + + {status === "success" && ( + <> +

Email verified!

+

Redirecting you shortly...

+ + )} + + {status === "error" && ( + <> +

Verification failed

+

The verification link may have expired or is invalid.

+ + {!showResend ? ( + + ) : ( +
+ setResendEmail(e.target.value)} + className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" + /> + + {resendMessage && ( +

{resendMessage}

+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file