Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
e9d24ed866

+ 73 - 0
package-lock.json

@@ -34,6 +34,7 @@
         "react-dom": "19.2.3",
         "react-hook-form": "^7.71.1",
         "recharts": "^3.7.0",
+        "resend": "^6.9.2",
         "sonner": "^2.0.7",
         "stripe": "^20.3.1",
         "tailwind-merge": "^3.4.0",
@@ -4134,6 +4135,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "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",
@@ -8147,6 +8154,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "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/fast-uri": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -11545,6 +11558,12 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/postal-mime": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
+      "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
+      "license": "MIT-0"
+    },
     "node_modules/postcss": {
       "version": "8.5.6",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -12262,6 +12281,27 @@
       "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
       "license": "MIT"
     },
+    "node_modules/resend": {
+      "version": "6.9.2",
+      "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz",
+      "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==",
+      "license": "MIT",
+      "dependencies": {
+        "postal-mime": "2.7.3",
+        "svix": "1.84.1"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "peerDependencies": {
+        "@react-email/render": "*"
+      },
+      "peerDependenciesMeta": {
+        "@react-email/render": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/resolve": {
       "version": "1.22.11",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -12973,6 +13013,16 @@
       "dev": true,
       "license": "MIT"
     },
+    "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/statuses": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -13380,6 +13430,16 @@
       "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
       "license": "ISC"
     },
+    "node_modules/svix": {
+      "version": "1.84.1",
+      "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
+      "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==",
+      "license": "MIT",
+      "dependencies": {
+        "standardwebhooks": "1.0.0",
+        "uuid": "^10.0.0"
+      }
+    },
     "node_modules/tagged-tag": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
@@ -14024,6 +14084,19 @@
       "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
       "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/validate-npm-package-name": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",

+ 1 - 0
package.json

@@ -38,6 +38,7 @@
     "react-dom": "19.2.3",
     "react-hook-form": "^7.71.1",
     "recharts": "^3.7.0",
+    "resend": "^6.9.2",
     "sonner": "^2.0.7",
     "stripe": "^20.3.1",
     "tailwind-merge": "^3.4.0",

+ 97 - 0
src/app/(public)/auth/forgot-password/page.tsx

@@ -0,0 +1,97 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { authClient } from '@/lib/auth-client'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Gauge, Loader2, ArrowLeft } from 'lucide-react'
+
+export default function ForgotPasswordPage() {
+  const [email, setEmail] = useState('')
+  const [loading, setLoading] = useState(false)
+  const [sent, setSent] = useState(false)
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setLoading(true)
+
+    try {
+      await authClient.requestPasswordReset({
+        email,
+        redirectTo: '/auth/reset-password',
+      })
+    } catch {
+      // Silently handle - don't reveal whether the email exists
+    } finally {
+      setLoading(false)
+      setSent(true)
+    }
+  }
+
+  return (
+    <div className="grid-bg flex min-h-screen items-center justify-center p-4">
+      <div className="absolute inset-0 overflow-hidden">
+        <div className="absolute -top-40 -right-40 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
+        <div className="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
+      </div>
+
+      <div className="glass relative z-10 w-full max-w-md rounded-2xl p-8 shadow-2xl">
+        <div className="mb-8 text-center">
+          <div className="mb-4 inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2">
+            <Gauge className="h-5 w-5 text-primary" />
+            <span className="gradient-text text-sm font-bold tracking-wider uppercase">
+              Torqvoice
+            </span>
+          </div>
+          <h1 className="text-2xl font-bold tracking-tight">Reset your password</h1>
+          <p className="mt-1 text-sm text-muted-foreground">
+            Enter your email and we&apos;ll send you a reset link
+          </p>
+        </div>
+
+        {sent ? (
+          <div className="space-y-4">
+            <p className="text-center text-sm text-muted-foreground">
+              If an account exists with that email, a password reset link has been sent. Check your
+              inbox.
+            </p>
+            <Link href="/auth/sign-in" className="block">
+              <Button variant="outline" className="h-11 w-full">
+                <ArrowLeft className="mr-2 h-4 w-4" />
+                Back to Sign In
+              </Button>
+            </Link>
+          </div>
+        ) : (
+          <form onSubmit={handleSubmit} className="space-y-4">
+            <div className="space-y-2">
+              <Label htmlFor="email">Email</Label>
+              <Input
+                id="email"
+                type="email"
+                placeholder="you@example.com"
+                value={email}
+                onChange={(e) => setEmail(e.target.value)}
+                required
+                className="h-11 bg-background/50"
+              />
+            </div>
+
+            <Button type="submit" className="h-11 w-full" disabled={loading}>
+              {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
+              Send Reset Link
+            </Button>
+          </form>
+        )}
+
+        <p className="mt-6 text-center text-sm text-muted-foreground">
+          <Link href="/auth/sign-in" className="font-medium text-primary hover:underline">
+            Back to Sign In
+          </Link>
+        </p>
+      </div>
+    </div>
+  )
+}

+ 157 - 0
src/app/(public)/auth/reset-password/page.tsx

@@ -0,0 +1,157 @@
+'use client'
+
+import { Suspense, useState } from 'react'
+import { useSearchParams } from 'next/navigation'
+import Link from 'next/link'
+import { authClient } from '@/lib/auth-client'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { useGlassModal } from '@/components/glass-modal'
+import { Gauge, Loader2 } from 'lucide-react'
+
+function ResetPasswordInner() {
+  const [newPassword, setNewPassword] = useState('')
+  const [confirmPassword, setConfirmPassword] = useState('')
+  const [loading, setLoading] = useState(false)
+  const [success, setSuccess] = useState(false)
+  const searchParams = useSearchParams()
+  const modal = useGlassModal()
+  const token = searchParams.get('token') || ''
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+
+    if (newPassword !== confirmPassword) {
+      modal.open('error', 'Passwords do not match', 'Please ensure both passwords are the same.')
+      return
+    }
+
+    if (newPassword.length < 8) {
+      modal.open('error', 'Password too short', 'Password must be at least 8 characters long.')
+      return
+    }
+
+    setLoading(true)
+
+    try {
+      const result = await authClient.resetPassword({
+        newPassword,
+        token,
+      })
+
+      if (result.error) {
+        modal.open(
+          'error',
+          'Reset Failed',
+          result.error.message || 'Could not reset password. The link may have expired.'
+        )
+      } else {
+        setSuccess(true)
+      }
+    } catch {
+      modal.open('error', 'Reset Failed', 'An unexpected error occurred. Please try again.')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  if (!token) {
+    return (
+      <div className="space-y-4 text-center">
+        <p className="text-sm text-muted-foreground">
+          Invalid or missing reset token. Please request a new password reset link.
+        </p>
+        <Link href="/auth/forgot-password">
+          <Button variant="outline" className="h-11 w-full">
+            Request New Link
+          </Button>
+        </Link>
+      </div>
+    )
+  }
+
+  if (success) {
+    return (
+      <div className="space-y-4 text-center">
+        <p className="text-sm text-muted-foreground">
+          Your password has been reset successfully. You can now sign in with your new password.
+        </p>
+        <Link href="/auth/sign-in">
+          <Button className="h-11 w-full">Sign In</Button>
+        </Link>
+      </div>
+    )
+  }
+
+  return (
+    <form onSubmit={handleSubmit} className="space-y-4">
+      <div className="space-y-2">
+        <Label htmlFor="new-password">New Password</Label>
+        <Input
+          id="new-password"
+          type="password"
+          placeholder="Enter new password"
+          value={newPassword}
+          onChange={(e) => setNewPassword(e.target.value)}
+          required
+          minLength={8}
+          className="h-11 bg-background/50"
+        />
+      </div>
+
+      <div className="space-y-2">
+        <Label htmlFor="confirm-password">Confirm Password</Label>
+        <Input
+          id="confirm-password"
+          type="password"
+          placeholder="Confirm new password"
+          value={confirmPassword}
+          onChange={(e) => setConfirmPassword(e.target.value)}
+          required
+          minLength={8}
+          className="h-11 bg-background/50"
+        />
+      </div>
+
+      <Button type="submit" className="h-11 w-full" disabled={loading}>
+        {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
+        Reset Password
+      </Button>
+    </form>
+  )
+}
+
+export default function ResetPasswordPage() {
+  return (
+    <div className="grid-bg flex min-h-screen items-center justify-center p-4">
+      <div className="absolute inset-0 overflow-hidden">
+        <div className="absolute -top-40 -right-40 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
+        <div className="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
+      </div>
+
+      <div className="glass relative z-10 w-full max-w-md rounded-2xl p-8 shadow-2xl">
+        <div className="mb-8 text-center">
+          <div className="mb-4 inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2">
+            <Gauge className="h-5 w-5 text-primary" />
+            <span className="gradient-text text-sm font-bold tracking-wider uppercase">
+              Torqvoice
+            </span>
+          </div>
+          <h1 className="text-2xl font-bold tracking-tight">Set new password</h1>
+          <p className="mt-1 text-sm text-muted-foreground">Enter your new password below</p>
+        </div>
+
+        <Suspense>
+          <ResetPasswordInner />
+        </Suspense>
+
+        <p className="mt-6 text-center text-sm text-muted-foreground">
+          <Link href="/auth/sign-in" className="font-medium text-primary hover:underline">
+            Back to Sign In
+          </Link>
+        </p>
+      </div>
+    </div>
+  )
+}

+ 5 - 0
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -76,6 +76,11 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
             required
             className="h-11 bg-background/50"
           />
+          <div className="flex justify-end">
+            <Link href="/auth/forgot-password" className="text-xs text-muted-foreground hover:underline">
+              Forgot password?
+            </Link>
+          </div>
         </div>
 
         <Button type="submit" className="h-11 w-full" disabled={loading}>

+ 71 - 0
src/features/admin/Actions/testEmailConnection.ts

@@ -0,0 +1,71 @@
+"use server";
+
+import { withSuperAdmin } from "@/lib/with-super-admin";
+import { db } from "@/lib/db";
+import { sendMail } from "@/lib/email";
+import { SYSTEM_SETTING_KEYS } from "../Schema/systemSettingsSchema";
+
+export async function testEmailConnection() {
+  return withSuperAdmin(async (ctx) => {
+    const user = await db.user.findUnique({
+      where: { id: ctx.userId },
+      select: { email: true, name: true },
+    });
+
+    if (!user?.email) {
+      throw new Error("Could not find your email address");
+    }
+
+    // Determine provider
+    const providerSetting = await db.systemSetting.findUnique({
+      where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
+    });
+    const provider = providerSetting?.value === "resend" ? "resend" : "smtp";
+
+    // Get from address based on provider
+    let from: string;
+    if (provider === "resend") {
+      const fromSetting = await db.systemSetting.findUnique({
+        where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL },
+      });
+      const fromName = await db.systemSetting.findUnique({
+        where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_NAME },
+      });
+      const fromEmail = fromSetting?.value || user.email;
+      const senderName = fromName?.value || "Torqvoice";
+      from = `${senderName} <${fromEmail}>`;
+    } else {
+      const fromSetting = await db.systemSetting.findUnique({
+        where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_EMAIL },
+      });
+      const fromName = await db.systemSetting.findUnique({
+        where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_NAME },
+      });
+      const fromEmail =
+        fromSetting?.value || process.env.SMTP_FROM_EMAIL || user.email;
+      const senderName = fromName?.value || "Torqvoice";
+      from = `${senderName} <${fromEmail}>`;
+    }
+
+    await sendMail({
+      from,
+      to: user.email,
+      subject: "Email Test - Torqvoice",
+      html: `
+        <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+          <h2>Email Configuration Test</h2>
+          <p>This is a test email from your Torqvoice platform.</p>
+          <p>If you're reading this, your <strong>${provider.toUpperCase()}</strong> settings are configured correctly.</p>
+          <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+          <p style="color: #6b7280; font-size: 12px;">
+            Sent to: ${user.email}<br/>
+            Provider: ${provider}<br/>
+            Time: ${new Date().toISOString()}
+          </p>
+        </div>
+      `,
+    });
+
+    return { sentTo: user.email };
+  });
+}

+ 0 - 50
src/features/admin/Actions/testSmtpConnection.ts

@@ -1,50 +0,0 @@
-"use server";
-
-import { withSuperAdmin } from "@/lib/with-super-admin";
-import { db } from "@/lib/db";
-import { sendMail } from "@/lib/email";
-import { SYSTEM_SETTING_KEYS } from "../Schema/systemSettingsSchema";
-
-export async function testSmtpConnection() {
-  return withSuperAdmin(async (ctx) => {
-    const user = await db.user.findUnique({
-      where: { id: ctx.userId },
-      select: { email: true, name: true },
-    });
-
-    if (!user?.email) {
-      throw new Error("Could not find your email address");
-    }
-
-    // Get the from email from system settings, fall back to env var
-    const fromSetting = await db.systemSetting.findUnique({
-      where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_EMAIL },
-    });
-    const fromName = await db.systemSetting.findUnique({
-      where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_NAME },
-    });
-
-    const from = fromSetting?.value || process.env.SMTP_FROM_EMAIL || user.email;
-    const senderName = fromName?.value || "Torqvoice";
-
-    await sendMail({
-      from: `${senderName} <${from}>`,
-      to: user.email,
-      subject: "SMTP Test - Torqvoice",
-      html: `
-        <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
-          <h2>SMTP Configuration Test</h2>
-          <p>This is a test email from your Torqvoice platform.</p>
-          <p>If you're reading this, your SMTP settings are configured correctly.</p>
-          <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
-          <p style="color: #6b7280; font-size: 12px;">
-            Sent to: ${user.email}<br/>
-            Time: ${new Date().toISOString()}
-          </p>
-        </div>
-      `,
-    });
-
-    return { sentTo: user.email };
-  });
-}

+ 246 - 116
src/features/admin/Components/admin-settings.tsx

@@ -12,7 +12,7 @@ import { Loader2, Send } from 'lucide-react'
 import { SYSTEM_SETTING_KEYS } from '../Schema/systemSettingsSchema'
 import type { SystemSettingsMap } from '../Schema/systemSettingsSchema'
 import { setSystemSettings } from '../Actions/setSystemSettings'
-import { testSmtpConnection } from '../Actions/testSmtpConnection'
+import { testEmailConnection } from '../Actions/testEmailConnection'
 
 export function AdminSettings({
   initial,
@@ -28,6 +28,12 @@ export function AdminSettings({
   const [registrationDisabled, setRegistrationDisabled] = useState(
     initial[SYSTEM_SETTING_KEYS.REGISTRATION_DISABLED] === 'true'
   )
+
+  // Email provider
+  const [emailProvider, setEmailProvider] = useState<'smtp' | 'resend'>(
+    (initial[SYSTEM_SETTING_KEYS.EMAIL_PROVIDER] as 'smtp' | 'resend') || 'smtp'
+  )
+
   // SMTP settings
   const [smtpHost, setSmtpHost] = useState(initial[SYSTEM_SETTING_KEYS.SMTP_HOST] || '')
   const [smtpPort, setSmtpPort] = useState(initial[SYSTEM_SETTING_KEYS.SMTP_PORT] || '587')
@@ -47,10 +53,22 @@ export function AdminSettings({
     initial[SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS] === 'true'
   )
 
+  // Resend settings
+  const [resendApiKey, setResendApiKey] = useState(
+    initial[SYSTEM_SETTING_KEYS.RESEND_API_KEY] || ''
+  )
+  const [resendFromEmail, setResendFromEmail] = useState(
+    initial[SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL] || ''
+  )
+  const [resendFromName, setResendFromName] = useState(
+    initial[SYSTEM_SETTING_KEYS.RESEND_FROM_NAME] || ''
+  )
+
   const handleSave = () => {
     startTransition(async () => {
       const data: Record<string, string> = {
         [SYSTEM_SETTING_KEYS.REGISTRATION_DISABLED]: String(registrationDisabled),
+        [SYSTEM_SETTING_KEYS.EMAIL_PROVIDER]: emailProvider,
         [SYSTEM_SETTING_KEYS.SMTP_HOST]: smtpHost,
         [SYSTEM_SETTING_KEYS.SMTP_PORT]: smtpPort,
         [SYSTEM_SETTING_KEYS.SMTP_USER]: smtpUser,
@@ -60,6 +78,9 @@ export function AdminSettings({
         [SYSTEM_SETTING_KEYS.SMTP_FROM_NAME]: smtpFromName,
         [SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED]: String(smtpRejectUnauthorized),
         [SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS]: String(smtpRequireTls),
+        [SYSTEM_SETTING_KEYS.RESEND_API_KEY]: resendApiKey,
+        [SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL]: resendFromEmail,
+        [SYSTEM_SETTING_KEYS.RESEND_FROM_NAME]: resendFromName,
       }
 
       const result = await setSystemSettings(data)
@@ -72,20 +93,25 @@ export function AdminSettings({
     })
   }
 
-  const handleTestSmtp = async () => {
+  const handleTestEmail = async () => {
     setIsTesting(true)
     try {
-      const result = await testSmtpConnection()
+      const result = await testEmailConnection()
       if (result.success) {
         toast.success(`Test email sent to ${result.data?.sentTo}`)
       } else {
-        toast.error(result.error ?? 'SMTP test failed')
+        toast.error(result.error ?? 'Email test failed')
       }
     } finally {
       setIsTesting(false)
     }
   }
 
+  const isTestDisabled =
+    isTesting ||
+    (emailProvider === 'smtp' && !smtpHost) ||
+    (emailProvider === 'resend' && !resendApiKey)
+
   return (
     <div className="space-y-6">
       {/* Platform Settings */}
@@ -111,140 +137,244 @@ export function AdminSettings({
         </CardContent>
       </Card>
 
-      {/* SMTP Settings */}
+      {/* Email Provider Selection */}
       <Card>
         <CardHeader>
-          <CardTitle>SMTP Email Settings</CardTitle>
+          <CardTitle>Email Provider</CardTitle>
           <CardDescription>
-            Configure email delivery for the platform. These settings override environment variables
-            when set.
+            Choose how the platform sends emails (password resets, invoices, notifications)
           </CardDescription>
         </CardHeader>
-        <CardContent className="space-y-6">
-          <div className="grid gap-4 sm:grid-cols-2">
-            <div className="space-y-2">
-              <Label htmlFor="smtp-host">SMTP Host</Label>
-              <Input
-                id="smtp-host"
-                placeholder="smtp.example.com"
-                value={smtpHost}
-                onChange={(e) => setSmtpHost(e.target.value)}
-              />
+        <CardContent>
+          <div className="flex gap-2">
+            <Button
+              type="button"
+              variant={emailProvider === 'smtp' ? 'default' : 'outline'}
+              onClick={() => setEmailProvider('smtp')}
+              className="flex-1"
+            >
+              SMTP
+            </Button>
+            <Button
+              type="button"
+              variant={emailProvider === 'resend' ? 'default' : 'outline'}
+              onClick={() => setEmailProvider('resend')}
+              className="flex-1"
+            >
+              Resend
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* SMTP Settings */}
+      {emailProvider === 'smtp' && (
+        <Card>
+          <CardHeader>
+            <CardTitle>SMTP Email Settings</CardTitle>
+            <CardDescription>
+              Configure email delivery for the platform. These settings override environment
+              variables when set.
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-6">
+            <div className="grid gap-4 sm:grid-cols-2">
+              <div className="space-y-2">
+                <Label htmlFor="smtp-host">SMTP Host</Label>
+                <Input
+                  id="smtp-host"
+                  placeholder="smtp.example.com"
+                  value={smtpHost}
+                  onChange={(e) => setSmtpHost(e.target.value)}
+                />
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="smtp-port">SMTP Port</Label>
+                <Input
+                  id="smtp-port"
+                  placeholder="587"
+                  value={smtpPort}
+                  onChange={(e) => setSmtpPort(e.target.value)}
+                />
+              </div>
             </div>
-            <div className="space-y-2">
-              <Label htmlFor="smtp-port">SMTP Port</Label>
-              <Input
-                id="smtp-port"
-                placeholder="587"
-                value={smtpPort}
-                onChange={(e) => setSmtpPort(e.target.value)}
-              />
+
+            <div className="grid gap-4 sm:grid-cols-2">
+              <div className="space-y-2">
+                <Label htmlFor="smtp-user">Username</Label>
+                <Input
+                  id="smtp-user"
+                  placeholder="user@example.com"
+                  value={smtpUser}
+                  onChange={(e) => setSmtpUser(e.target.value)}
+                />
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="smtp-pass">Password</Label>
+                <Input
+                  id="smtp-pass"
+                  type="password"
+                  placeholder="••••••••"
+                  value={smtpPass}
+                  onChange={(e) => setSmtpPass(e.target.value)}
+                />
+              </div>
             </div>
-          </div>
 
-          <div className="grid gap-4 sm:grid-cols-2">
-            <div className="space-y-2">
-              <Label htmlFor="smtp-user">Username</Label>
-              <Input
-                id="smtp-user"
-                placeholder="user@example.com"
-                value={smtpUser}
-                onChange={(e) => setSmtpUser(e.target.value)}
-              />
+            <div className="grid gap-4 sm:grid-cols-2">
+              <div className="space-y-2">
+                <Label htmlFor="smtp-from-email">From Email</Label>
+                <Input
+                  id="smtp-from-email"
+                  placeholder="noreply@example.com"
+                  value={smtpFromEmail}
+                  onChange={(e) => setSmtpFromEmail(e.target.value)}
+                />
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="smtp-from-name">From Name</Label>
+                <Input
+                  id="smtp-from-name"
+                  placeholder="Torqvoice"
+                  value={smtpFromName}
+                  onChange={(e) => setSmtpFromName(e.target.value)}
+                />
+              </div>
             </div>
-            <div className="space-y-2">
-              <Label htmlFor="smtp-pass">Password</Label>
-              <Input
-                id="smtp-pass"
-                type="password"
-                placeholder="••••••••"
-                value={smtpPass}
-                onChange={(e) => setSmtpPass(e.target.value)}
-              />
+
+            <div className="space-y-4">
+              <div className="flex items-center justify-between">
+                <div className="space-y-0.5">
+                  <Label htmlFor="smtp-secure">TLS Connection (Port 465)</Label>
+                  <p className="text-xs text-muted-foreground">
+                    Enable for implicit TLS. Disable for STARTTLS (port 587/25).
+                  </p>
+                </div>
+                <Switch id="smtp-secure" checked={smtpSecure} onCheckedChange={setSmtpSecure} />
+              </div>
+
+              <div className="flex items-center justify-between">
+                <div className="space-y-0.5">
+                  <Label htmlFor="smtp-reject-unauthorized">Verify TLS Certificates</Label>
+                  <p className="text-xs text-muted-foreground">
+                    Disable to allow self-signed certificates (not recommended for production)
+                  </p>
+                </div>
+                <Switch
+                  id="smtp-reject-unauthorized"
+                  checked={smtpRejectUnauthorized}
+                  onCheckedChange={setSmtpRejectUnauthorized}
+                />
+              </div>
+
+              <div className="flex items-center justify-between">
+                <div className="space-y-0.5">
+                  <Label htmlFor="smtp-require-tls">Require TLS Upgrade</Label>
+                  <p className="text-xs text-muted-foreground">
+                    Force TLS upgrade on STARTTLS connections
+                  </p>
+                </div>
+                <Switch
+                  id="smtp-require-tls"
+                  checked={smtpRequireTls}
+                  onCheckedChange={setSmtpRequireTls}
+                />
+              </div>
             </div>
-          </div>
 
-          <div className="grid gap-4 sm:grid-cols-2">
-            <div className="space-y-2">
-              <Label htmlFor="smtp-from-email">From Email</Label>
-              <Input
-                id="smtp-from-email"
-                placeholder="noreply@example.com"
-                value={smtpFromEmail}
-                onChange={(e) => setSmtpFromEmail(e.target.value)}
-              />
+            <div className="flex items-center gap-2 pt-2">
+              <Button
+                type="button"
+                variant="outline"
+                onClick={handleTestEmail}
+                disabled={isTestDisabled}
+              >
+                {isTesting ? (
+                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                ) : (
+                  <Send className="mr-2 h-4 w-4" />
+                )}
+                Send Test Email
+              </Button>
+              <p className="text-xs text-muted-foreground">
+                Save settings first, then send a test email to your account
+              </p>
             </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Resend Settings */}
+      {emailProvider === 'resend' && (
+        <Card>
+          <CardHeader>
+            <CardTitle>Resend Email Settings</CardTitle>
+            <CardDescription>
+              Configure email delivery using Resend. Get your API key from{' '}
+              <a
+                href="https://resend.com"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="underline"
+              >
+                resend.com
+              </a>
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-6">
             <div className="space-y-2">
-              <Label htmlFor="smtp-from-name">From Name</Label>
+              <Label htmlFor="resend-api-key">API Key</Label>
               <Input
-                id="smtp-from-name"
-                placeholder="Torqvoice"
-                value={smtpFromName}
-                onChange={(e) => setSmtpFromName(e.target.value)}
+                id="resend-api-key"
+                type="password"
+                placeholder="re_••••••••"
+                value={resendApiKey}
+                onChange={(e) => setResendApiKey(e.target.value)}
               />
             </div>
-          </div>
 
-          <div className="space-y-4">
-            <div className="flex items-center justify-between">
-              <div className="space-y-0.5">
-                <Label htmlFor="smtp-secure">TLS Connection (Port 465)</Label>
-                <p className="text-xs text-muted-foreground">
-                  Enable for implicit TLS. Disable for STARTTLS (port 587/25).
-                </p>
+            <div className="grid gap-4 sm:grid-cols-2">
+              <div className="space-y-2">
+                <Label htmlFor="resend-from-email">From Email</Label>
+                <Input
+                  id="resend-from-email"
+                  placeholder="noreply@yourdomain.com"
+                  value={resendFromEmail}
+                  onChange={(e) => setResendFromEmail(e.target.value)}
+                />
               </div>
-              <Switch id="smtp-secure" checked={smtpSecure} onCheckedChange={setSmtpSecure} />
-            </div>
-
-            <div className="flex items-center justify-between">
-              <div className="space-y-0.5">
-                <Label htmlFor="smtp-reject-unauthorized">Verify TLS Certificates</Label>
-                <p className="text-xs text-muted-foreground">
-                  Disable to allow self-signed certificates (not recommended for production)
-                </p>
+              <div className="space-y-2">
+                <Label htmlFor="resend-from-name">From Name</Label>
+                <Input
+                  id="resend-from-name"
+                  placeholder="Torqvoice"
+                  value={resendFromName}
+                  onChange={(e) => setResendFromName(e.target.value)}
+                />
               </div>
-              <Switch
-                id="smtp-reject-unauthorized"
-                checked={smtpRejectUnauthorized}
-                onCheckedChange={setSmtpRejectUnauthorized}
-              />
             </div>
 
-            <div className="flex items-center justify-between">
-              <div className="space-y-0.5">
-                <Label htmlFor="smtp-require-tls">Require TLS Upgrade</Label>
-                <p className="text-xs text-muted-foreground">
-                  Force TLS upgrade on STARTTLS connections
-                </p>
-              </div>
-              <Switch
-                id="smtp-require-tls"
-                checked={smtpRequireTls}
-                onCheckedChange={setSmtpRequireTls}
-              />
+            <div className="flex items-center gap-2 pt-2">
+              <Button
+                type="button"
+                variant="outline"
+                onClick={handleTestEmail}
+                disabled={isTestDisabled}
+              >
+                {isTesting ? (
+                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                ) : (
+                  <Send className="mr-2 h-4 w-4" />
+                )}
+                Send Test Email
+              </Button>
+              <p className="text-xs text-muted-foreground">
+                Save settings first, then send a test email to your account
+              </p>
             </div>
-          </div>
-
-          <div className="flex items-center gap-2 pt-2">
-            <Button
-              type="button"
-              variant="outline"
-              onClick={handleTestSmtp}
-              disabled={isTesting || !smtpHost}
-            >
-              {isTesting ? (
-                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-              ) : (
-                <Send className="mr-2 h-4 w-4" />
-              )}
-              Send Test Email
-            </Button>
-            <p className="text-xs text-muted-foreground">
-              Save settings first, then send a test email to your account
-            </p>
-          </div>
-        </CardContent>
-      </Card>
+          </CardContent>
+        </Card>
+      )}
 
       {/* Save Button */}
       <div className="flex justify-end">

+ 8 - 0
src/features/admin/Schema/systemSettingsSchema.ts

@@ -4,6 +4,9 @@ export const SYSTEM_SETTING_KEYS = {
   // Registration
   REGISTRATION_DISABLED: "registration.disabled",
 
+  // Email Provider
+  EMAIL_PROVIDER: "email.provider",
+
   // SMTP
   SMTP_HOST: "smtp.host",
   SMTP_PORT: "smtp.port",
@@ -14,6 +17,11 @@ export const SYSTEM_SETTING_KEYS = {
   SMTP_FROM_NAME: "smtp.fromName",
   SMTP_REJECT_UNAUTHORIZED: "smtp.rejectUnauthorized",
   SMTP_REQUIRE_TLS: "smtp.requireTls",
+
+  // Resend
+  RESEND_API_KEY: "resend.apiKey",
+  RESEND_FROM_EMAIL: "resend.fromEmail",
+  RESEND_FROM_NAME: "resend.fromName",
 } as const;
 
 export type SystemSettingKey =

+ 63 - 0
src/lib/auth.ts

@@ -15,6 +15,69 @@ export const auth = betterAuth({
   }),
   emailAndPassword: {
     enabled: true,
+    sendResetPassword: async ({ user, url }) => {
+      const { SYSTEM_SETTING_KEYS } = await import(
+        "@/features/admin/Schema/systemSettingsSchema"
+      );
+
+      // Determine provider and get from address
+      const providerSetting = await db.systemSetting.findUnique({
+        where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
+      });
+      const provider =
+        providerSetting?.value === "resend" ? "resend" : "smtp";
+
+      let fromEmail: string;
+      let fromName: string;
+
+      if (provider === "resend") {
+        const emailSetting = await db.systemSetting.findUnique({
+          where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL },
+        });
+        const nameSetting = await db.systemSetting.findUnique({
+          where: { key: SYSTEM_SETTING_KEYS.RESEND_FROM_NAME },
+        });
+        fromEmail = emailSetting?.value || "noreply@example.com";
+        fromName = nameSetting?.value || "Torqvoice";
+      } else {
+        const emailSetting = await db.systemSetting.findUnique({
+          where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_EMAIL },
+        });
+        const nameSetting = await db.systemSetting.findUnique({
+          where: { key: SYSTEM_SETTING_KEYS.SMTP_FROM_NAME },
+        });
+        fromEmail =
+          emailSetting?.value ||
+          process.env.SMTP_FROM_EMAIL ||
+          "noreply@example.com";
+        fromName = nameSetting?.value || "Torqvoice";
+      }
+
+      const { sendMail } = await import("@/lib/email");
+      await sendMail({
+        from: `${fromName} <${fromEmail}>`,
+        to: user.email,
+        subject: "Reset your Torqvoice password",
+        html: `
+          <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+            <h2>Password Reset</h2>
+            <p>Hi${user.name ? ` ${user.name}` : ""},</p>
+            <p>We received a request to reset your password. Click the button below to set a new password:</p>
+            <div style="margin: 24px 0;">
+              <a href="${url}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
+                Reset Password
+              </a>
+            </div>
+            <p style="color: #6b7280; font-size: 14px;">If you didn't request this, you can safely ignore this email.</p>
+            <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+            <p style="color: #6b7280; font-size: 12px;">
+              This link will expire shortly. If it doesn't work, copy and paste this URL into your browser:<br/>
+              <a href="${url}" style="color: #6b7280;">${url}</a>
+            </p>
+          </div>
+        `,
+      });
+    },
   },
   session: {
     expiresIn: 60 * 60 * 24 * 7, // 7 days

+ 58 - 1
src/lib/email.ts

@@ -1,4 +1,5 @@
 import nodemailer from "nodemailer";
+import { Resend } from "resend";
 import { db } from "./db";
 import { SYSTEM_SETTING_KEYS } from "@/features/admin/Schema/systemSettingsSchema";
 
@@ -13,6 +14,13 @@ export interface SendMailOptions {
   }[];
 }
 
+async function getEmailProvider(): Promise<"smtp" | "resend"> {
+  const setting = await db.systemSetting.findUnique({
+    where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER },
+  });
+  return setting?.value === "resend" ? "resend" : "smtp";
+}
+
 async function getSmtpSettings() {
   const keys = [
     SYSTEM_SETTING_KEYS.SMTP_HOST,
@@ -87,7 +95,7 @@ async function getTransporter() {
   });
 }
 
-export async function sendMail(options: SendMailOptions) {
+async function sendViaSmtp(options: SendMailOptions) {
   const transporter = await getTransporter();
   await transporter.sendMail({
     from: options.from,
@@ -100,3 +108,52 @@ export async function sendMail(options: SendMailOptions) {
     })),
   });
 }
+
+async function getResendSettings() {
+  const keys = [
+    SYSTEM_SETTING_KEYS.RESEND_API_KEY,
+    SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL,
+    SYSTEM_SETTING_KEYS.RESEND_FROM_NAME,
+  ];
+
+  const rows = await db.systemSetting.findMany({
+    where: { key: { in: keys } },
+  });
+
+  const map = new Map(rows.map((r) => [r.key, r.value]));
+  return map;
+}
+
+async function sendViaResend(options: SendMailOptions) {
+  const settings = await getResendSettings();
+  const apiKey = settings.get(SYSTEM_SETTING_KEYS.RESEND_API_KEY);
+
+  if (!apiKey) {
+    throw new Error(
+      "Resend is not configured. Add your Resend API key in Admin Settings.",
+    );
+  }
+
+  const resend = new Resend(apiKey);
+
+  await resend.emails.send({
+    from: options.from,
+    to: options.to,
+    subject: options.subject,
+    html: options.html,
+    attachments: options.attachments?.map((a) => ({
+      filename: a.filename,
+      content: a.content,
+    })),
+  });
+}
+
+export async function sendMail(options: SendMailOptions) {
+  const provider = await getEmailProvider();
+
+  if (provider === "resend") {
+    await sendViaResend(options);
+  } else {
+    await sendViaSmtp(options);
+  }
+}