From f13035f8c523b485d133da167a4de23b82f2c94e Mon Sep 17 00:00:00 2001 From: pulipakaa24 Date: Thu, 2 Apr 2026 14:29:56 -0500 Subject: [PATCH] Email + password login scheme set up, mailgun sending set up. --- App.tsx | 2 +- components/auth/ForgotPassword.tsx | 4 +- components/auth/LoginForm.tsx | 39 +++++++- components/auth/SignUpForm.tsx | 68 ++++++++++++- lib/auth-client.ts | 2 +- package.json | 2 +- server/package.json | 4 +- server/src/auth/auth.ts | 5 + server/src/auth/email.ts | 152 ++++++++++++++++++++++++----- server/tsconfig.json | 4 +- 10 files changed, 241 insertions(+), 41 deletions(-) diff --git a/App.tsx b/App.tsx index 48785d5..1cad9c3 100644 --- a/App.tsx +++ b/App.tsx @@ -44,7 +44,7 @@ export default function App() { if (isPending) return; if (!session) { - setView('login'); + if (view !== 'signup' && view !== 'forgot-password') setView('login'); return; } diff --git a/components/auth/ForgotPassword.tsx b/components/auth/ForgotPassword.tsx index 4139c46..1d709c0 100644 --- a/components/auth/ForgotPassword.tsx +++ b/components/auth/ForgotPassword.tsx @@ -3,7 +3,7 @@ import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; -import { forgetPassword } from '../../lib/auth-client'; +import { requestPasswordReset } from '../../lib/auth-client'; import { ArrowLeft } from 'lucide-react'; const logo = '/logo.png'; @@ -22,7 +22,7 @@ export function ForgotPassword({ onBack }: Props) { e.preventDefault(); setError(''); setLoading(true); - const res = await forgetPassword({ + const res = await requestPasswordReset({ email, redirectTo: `${window.location.origin}/reset-password`, }); diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx index bfca123..a3e55c3 100644 --- a/components/auth/LoginForm.tsx +++ b/components/auth/LoginForm.tsx @@ -1,9 +1,9 @@ -import { useRef, useState } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; -import { signIn } from '../../lib/auth-client'; +import { signIn, sendVerificationEmail } from '../../lib/auth-client'; import { Package, FileCheck, @@ -52,9 +52,24 @@ export function LoginForm({ onSignUp, onForgotPassword }: Props) { const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [resendCooldown, setResendCooldown] = useState(0); + const [resendLoading, setResendLoading] = useState(false); const aboutRef = useRef(null); const heroRef = useRef(null); + useEffect(() => { + if (resendCooldown <= 0) return; + const t = setTimeout(() => setResendCooldown(c => c - 1), 1000); + return () => clearTimeout(t); + }, [resendCooldown]); + + async function handleResend() { + setResendLoading(true); + await sendVerificationEmail({ email, callbackURL: window.location.origin }); + setResendLoading(false); + setResendCooldown(60); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(''); @@ -145,7 +160,25 @@ export function LoginForm({ onSignUp, onForgotPassword }: Props) { autoComplete="current-password" /> - {error &&

{error}

} + {error && ( +
+

{error}

+ {error === 'Email not verified' && ( + + )} +
+ )} diff --git a/components/auth/SignUpForm.tsx b/components/auth/SignUpForm.tsx index b5965f8..8212d01 100644 --- a/components/auth/SignUpForm.tsx +++ b/components/auth/SignUpForm.tsx @@ -3,7 +3,16 @@ import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; -import { signUp } from '../../lib/auth-client'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '../ui/dialog'; +import { signUp, sendVerificationEmail } from '../../lib/auth-client'; +import { useEffect } from 'react'; const logo = '/logo.png'; @@ -12,13 +21,29 @@ interface Props { onVerify: () => void; } -export function SignUpForm({ onLogin, onVerify }: Props) { +export function SignUpForm({ onLogin }: Props) { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [showVerifyDialog, setShowVerifyDialog] = useState(false); + const [resendCooldown, setResendCooldown] = useState(0); + const [resendLoading, setResendLoading] = useState(false); + + useEffect(() => { + if (resendCooldown <= 0) return; + const t = setTimeout(() => setResendCooldown(c => c - 1), 1000); + return () => clearTimeout(t); + }, [resendCooldown]); + + async function handleResend() { + setResendLoading(true); + await sendVerificationEmail({ email, callbackURL: window.location.origin }); + setResendLoading(false); + setResendCooldown(60); + } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -42,11 +67,47 @@ export function SignUpForm({ onLogin, onVerify }: Props) { if (res.error) { setError(res.error.message || 'Failed to create account'); } else { - onVerify(); + setShowVerifyDialog(true); } } return ( + <> + { if (!open) onLogin(); }}> + + + Check your inbox + +
+

+ We sent a verification link to{' '} + {email}. +

+

+ Click the link in the email to activate your account. If you don't see it, check your spam folder. +

+
+
+
+ + + + +
+
@@ -118,5 +179,6 @@ export function SignUpForm({ onLogin, onVerify }: Props) {
+ ); } diff --git a/lib/auth-client.ts b/lib/auth-client.ts index 7a6aeca..0daea0f 100644 --- a/lib/auth-client.ts +++ b/lib/auth-client.ts @@ -9,7 +9,7 @@ export const { signOut, signUp, useSession, - forgetPassword, + requestPasswordReset, resetPassword, sendVerificationEmail, } = authClient; diff --git a/package.json b/package.json index b161a7d..3d9c4f0 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", - "better-auth": "^1.0.0", + "better-auth": "^1.5.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/server/package.json b/server/package.json index 0441176..08151d2 100644 --- a/server/package.json +++ b/server/package.json @@ -9,14 +9,16 @@ "db:migrate": "tsx src/db/migrate.ts" }, "dependencies": { - "@aws-sdk/client-ses": "^3.1013.0", "@better-auth/kysely-adapter": "^1.5.6", + "@node-rs/argon2": "^2.0.2", "better-auth": "^1.5.5", "cors": "^2.8.5", "dotenv": "^17.3.1", "express": "^4.21.2", "express-rate-limit": "^7.5.0", + "form-data": "^4.0.1", "kysely": "^0.28.15", + "mailgun.js": "^11.1.0", "multer": "^2.0.0", "pg": "^8.13.3" }, diff --git a/server/src/auth/auth.ts b/server/src/auth/auth.ts index 046d94f..db13ba7 100644 --- a/server/src/auth/auth.ts +++ b/server/src/auth/auth.ts @@ -1,4 +1,5 @@ import { betterAuth } from 'better-auth'; +import { hash as argon2Hash, verify as argon2Verify } from '@node-rs/argon2'; import { kyselyAdapter } from '@better-auth/kysely-adapter'; import { Kysely, PostgresDialect } from 'kysely'; import { Pool } from 'pg'; @@ -29,6 +30,10 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, requireEmailVerification: true, + password: { + hash: (password) => argon2Hash(password, { memoryCost: 65536, timeCost: 3, parallelism: 4, algorithm: 2 }), + verify: ({ hash, password }) => argon2Verify(hash, password), + }, sendResetPassword: async ({ user, url }) => { await sendEmail({ to: user.email, diff --git a/server/src/auth/email.ts b/server/src/auth/email.ts index 7d6651c..c68aaf4 100644 --- a/server/src/auth/email.ts +++ b/server/src/auth/email.ts @@ -1,7 +1,14 @@ -import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'; +import FormData from 'form-data'; +import Mailgun from 'mailgun.js'; -const ses = new SESClient({ region: process.env.AWS_REGION || 'us-east-1' }); -const FROM = process.env.FROM_EMAIL || 'noreply@labwise.wahwa.com'; +const mailgun = new Mailgun(FormData); +const mg = mailgun.client({ + username: 'api', + key: process.env.MAILGUN_API_KEY || '', +}); + +const DOMAIN = process.env.MAILGUN_DOMAIN || 'sandbox06aa4efa8cc342878b7470a7c9113df3.mailgun.org'; +const FROM = process.env.FROM_EMAIL || `LabWise `; export async function sendEmail({ to, @@ -12,36 +19,127 @@ export async function sendEmail({ subject: string; html: string; }) { - await ses.send( - new SendEmailCommand({ - Source: FROM, - Destination: { ToAddresses: [to] }, - Message: { - Subject: { Data: subject }, - Body: { Html: { Data: html } }, - }, - }) - ); + await mg.messages.create(DOMAIN, { + from: FROM, + to: [to], + subject, + html, + }); } +/* ------------------------------------------------------------------ */ +/* Shared email layout */ +/* ------------------------------------------------------------------ */ + +function emailLayout(body: string) { + return ` + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + +
+ LabWise +
+
+ ${body} +
+

+ © ${new Date().getFullYear()} LabWise — AI-powered lab management +

+
+
+ +`; +} + +/* ------------------------------------------------------------------ */ +/* Email templates */ +/* ------------------------------------------------------------------ */ + export function verificationEmailHtml(url: string) { - return ` -
-

Verify your email

-

Click the button below to verify your email address and activate your LabWise account.

- Verify Email -

If you didn't create a LabWise account, you can ignore this email.

+ return emailLayout(` +

+ Verify your email +

+

+ Thanks for creating a LabWise account. Click the button below to verify your email address and get started. +

+ + + + +
+ + Verify Email + +
+

+ Or copy and paste this link into your browser: +

+

+ ${url} +

+
+

+ If you didn't create a LabWise account, you can safely ignore this email. +

- `; + `); } export function resetPasswordEmailHtml(url: string) { - return ` -
-

Reset your password

-

Click the button below to reset your LabWise password. This link expires in 1 hour.

- Reset Password -

If you didn't request this, you can ignore this email.

+ return emailLayout(` +

+ Reset your password +

+

+ We received a request to reset your LabWise password. Click the button below to choose a new one. This link expires in 1 hour. +

+ + + + +
+ + Reset Password + +
+

+ Or copy and paste this link into your browser: +

+

+ ${url} +

+
+

+ If you didn't request a password reset, you can safely ignore this email. Your password won't be changed. +

- `; + `); } diff --git a/server/tsconfig.json b/server/tsconfig.json index 953117a..43220cf 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "node", + "module": "Node16", + "moduleResolution": "node16", "outDir": "./dist", "rootDir": "./src", "strict": true,