Bernt Christian Egeland vor 7 Monaten
Ursprung
Commit
96d597c210

+ 10 - 0
package-lock.json

@@ -27,6 +27,7 @@
         "nodemailer": "^8.0.1",
         "pdf-lib": "^1.17.1",
         "prisma": "^6.19.2",
+        "qrcode.react": "^4.2.0",
         "radix-ui": "^1.4.3",
         "react": "19.2.3",
         "react-day-picker": "^9.13.2",
@@ -11747,6 +11748,15 @@
       ],
       "license": "MIT"
     },
+    "node_modules/qrcode.react": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
+      "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/qs": {
       "version": "6.14.1",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",

+ 1 - 0
package.json

@@ -31,6 +31,7 @@
     "nodemailer": "^8.0.1",
     "pdf-lib": "^1.17.1",
     "prisma": "^6.19.2",
+    "qrcode.react": "^4.2.0",
     "radix-ui": "^1.4.3",
     "react": "19.2.3",
     "react-day-picker": "^9.13.2",

+ 18 - 0
prisma/migrations/20260215140404_2fa/migration.sql

@@ -0,0 +1,18 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN     "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false;
+
+-- CreateTable
+CREATE TABLE "two_factor" (
+    "id" TEXT NOT NULL,
+    "secret" TEXT NOT NULL,
+    "backupCodes" TEXT NOT NULL,
+    "userId" TEXT NOT NULL,
+
+    CONSTRAINT "two_factor_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "two_factor_userId_key" ON "two_factor"("userId");
+
+-- AddForeignKey
+ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 13 - 0
prisma/schema.prisma

@@ -17,6 +17,8 @@ model User {
   createdAt     DateTime @default(now())
   updatedAt     DateTime @updatedAt
 
+  twoFactorEnabled Boolean @default(false)
+
   sessions               Session[]
   accounts               Account[]
   vehicles               Vehicle[]
@@ -25,6 +27,7 @@ model User {
   inventoryParts         InventoryPart[]
   quotes                 Quote[]
   customFieldDefinitions CustomFieldDefinition[]
+  twoFactor              TwoFactor?
 
   @@map("users")
 }
@@ -92,6 +95,16 @@ model Verification {
   @@map("verifications")
 }
 
+model TwoFactor {
+  id          String @id @default(cuid())
+  secret      String
+  backupCodes String
+  userId      String @unique
+  user        User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  @@map("two_factor")
+}
+
 model Vehicle {
   id            String    @id @default(cuid())
   make          String

+ 303 - 2
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -5,14 +5,23 @@ import { useSession } from '@/lib/auth-client'
 import { authClient } from '@/lib/auth-client'
 import { toast } from 'sonner'
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
-import { KeyRound, Loader2, Save, User } from 'lucide-react'
+import { Check, Copy, KeyRound, Loader2, Save, Shield, ShieldOff, User } from 'lucide-react'
+import { QRCodeSVG } from 'qrcode.react'
 import { updateEmail } from '@/features/settings/Actions/accountActions'
 
-export function AccountSettings() {
+export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: { twoFactorEnabled: boolean }) {
   const { data: session } = useSession()
   const [name, setName] = useState('')
   const [email, setEmail] = useState('')
@@ -29,6 +38,106 @@ export function AccountSettings() {
   const [confirmPassword, setConfirmPassword] = useState('')
   const [savingPassword, setSavingPassword] = useState(false)
 
+  // 2FA state
+  const [twoFactorPassword, setTwoFactorPassword] = useState('')
+  const [totpURI, setTotpURI] = useState('')
+  const [backupCodes, setBackupCodes] = useState<string[]>([])
+  const [enabling2FA, setEnabling2FA] = useState(false)
+  const [disabling2FA, setDisabling2FA] = useState(false)
+  const [twoFactorEnabled, setTwoFactorEnabled] = useState(initialTwoFactorEnabled)
+  const [dialogOpen, setDialogOpen] = useState(false)
+  const [setupStep, setSetupStep] = useState<'password' | 'qr' | 'verify' | 'backup'>('password')
+  const [verifyCode, setVerifyCode] = useState('')
+  const [verifying2FA, setVerifying2FA] = useState(false)
+  const [copiedBackup, setCopiedBackup] = useState(false)
+
+  const handleEnable2FA = async () => {
+    if (!twoFactorPassword) {
+      toast.error('Please enter your password')
+      return
+    }
+    setEnabling2FA(true)
+    try {
+      const result = await authClient.twoFactor.enable({ password: twoFactorPassword })
+      if (result.error) {
+        toast.error(result.error.message || 'Failed to enable 2FA')
+      } else {
+        setTotpURI(result.data?.totpURI || '')
+        setBackupCodes(result.data?.backupCodes || [])
+        setSetupStep('qr')
+      }
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : 'Failed to enable 2FA')
+    }
+    setEnabling2FA(false)
+  }
+
+  const handleDisable2FA = async () => {
+    if (!twoFactorPassword) {
+      toast.error('Please enter your password')
+      return
+    }
+    setDisabling2FA(true)
+    try {
+      const result = await authClient.twoFactor.disable({ password: twoFactorPassword })
+      if (result.error) {
+        toast.error(result.error.message || 'Failed to disable 2FA')
+      } else {
+        setTwoFactorEnabled(false)
+        setTwoFactorPassword('')
+        toast.success('Two-factor authentication disabled')
+      }
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : 'Failed to disable 2FA')
+    }
+    setDisabling2FA(false)
+  }
+
+  const handleCopyBackupCodes = () => {
+    navigator.clipboard.writeText(backupCodes.join('\n'))
+    setCopiedBackup(true)
+    toast.success('Backup codes copied to clipboard')
+    setTimeout(() => setCopiedBackup(false), 2000)
+  }
+
+  const handleVerify2FA = async () => {
+    if (!verifyCode.trim()) {
+      toast.error('Please enter the code from your authenticator app')
+      return
+    }
+    setVerifying2FA(true)
+    try {
+      const result = await authClient.twoFactor.verifyTotp({ code: verifyCode })
+      if (result.error) {
+        toast.error(result.error.message || 'Invalid code. Please try again.')
+      } else {
+        setSetupStep('backup')
+      }
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : 'Verification failed')
+    }
+    setVerifying2FA(false)
+  }
+
+  const handleOpenSetupDialog = () => {
+    setTwoFactorPassword('')
+    setVerifyCode('')
+    setSetupStep('password')
+    setCopiedBackup(false)
+    setDialogOpen(true)
+  }
+
+  const handleFinishSetup = () => {
+    setTwoFactorEnabled(true)
+    setDialogOpen(false)
+    setSetupStep('password')
+    setTotpURI('')
+    setBackupCodes([])
+    setTwoFactorPassword('')
+    setVerifyCode('')
+    toast.success('Two-factor authentication enabled')
+  }
+
   const handleUpdateProfile = async () => {
     setSavingProfile(true)
     try {
@@ -211,6 +320,198 @@ export function AccountSettings() {
           </div>
         </CardContent>
       </Card>
+      {/* Two-Factor Authentication */}
+      <Card className="border-0 shadow-sm">
+        <CardHeader className="flex flex-row items-center gap-3 pb-4">
+          <Shield className="h-5 w-5 text-muted-foreground" />
+          <CardTitle className="text-lg">Two-Factor Authentication</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          {twoFactorEnabled ? (
+            <>
+              <div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-4 py-3">
+                <Shield className="h-5 w-5 text-emerald-500" />
+                <span className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
+                  Two-factor authentication is enabled
+                </span>
+              </div>
+              <p className="text-sm text-muted-foreground">
+                Enter your password to disable two-factor authentication.
+              </p>
+              <div className="max-w-sm space-y-2">
+                <Label htmlFor="2fa-disable-password">Password</Label>
+                <Input
+                  id="2fa-disable-password"
+                  type="password"
+                  value={twoFactorPassword}
+                  onChange={(e) => setTwoFactorPassword(e.target.value)}
+                  placeholder="Enter your password"
+                />
+              </div>
+              <Separator />
+              <Button
+                variant="destructive"
+                onClick={handleDisable2FA}
+                disabled={disabling2FA}
+              >
+                {disabling2FA ? (
+                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                ) : (
+                  <ShieldOff className="mr-2 h-4 w-4" />
+                )}
+                Disable 2FA
+              </Button>
+            </>
+          ) : (
+            <>
+              <p className="text-sm text-muted-foreground">
+                Add an extra layer of security to your account by enabling two-factor authentication
+                with an authenticator app.
+              </p>
+              <Separator />
+              <Button onClick={handleOpenSetupDialog}>
+                <Shield className="mr-2 h-4 w-4" />
+                Enable 2FA
+              </Button>
+            </>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* 2FA Setup Dialog */}
+      <Dialog open={dialogOpen} onOpenChange={(open) => {
+        if (!open && setupStep !== 'backup' && setupStep !== 'verify') {
+          setDialogOpen(false)
+          setSetupStep('password')
+          setTwoFactorPassword('')
+          setVerifyCode('')
+        }
+      }}>
+        <DialogContent className="sm:max-w-md">
+          {setupStep === 'password' && (
+            <>
+              <DialogHeader>
+                <DialogTitle>Enable Two-Factor Authentication</DialogTitle>
+                <DialogDescription>
+                  Enter your password to begin setting up 2FA with an authenticator app.
+                </DialogDescription>
+              </DialogHeader>
+              <div className="space-y-2 py-2">
+                <Label htmlFor="2fa-enable-password">Password</Label>
+                <Input
+                  id="2fa-enable-password"
+                  type="password"
+                  value={twoFactorPassword}
+                  onChange={(e) => setTwoFactorPassword(e.target.value)}
+                  placeholder="Enter your password"
+                  onKeyDown={(e) => {
+                    if (e.key === 'Enter') handleEnable2FA()
+                  }}
+                />
+              </div>
+              <DialogFooter>
+                <Button variant="outline" onClick={() => setDialogOpen(false)}>
+                  Cancel
+                </Button>
+                <Button onClick={handleEnable2FA} disabled={enabling2FA}>
+                  {enabling2FA && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                  Continue
+                </Button>
+              </DialogFooter>
+            </>
+          )}
+
+          {setupStep === 'qr' && (
+            <>
+              <DialogHeader>
+                <DialogTitle>Scan QR Code</DialogTitle>
+                <DialogDescription>
+                  Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
+                </DialogDescription>
+              </DialogHeader>
+              <div className="flex justify-center rounded-lg bg-white p-6">
+                <QRCodeSVG value={totpURI} size={200} />
+              </div>
+              <DialogFooter>
+                <Button onClick={() => setSetupStep('verify')} className="w-full">
+                  Continue
+                </Button>
+              </DialogFooter>
+            </>
+          )}
+
+          {setupStep === 'verify' && (
+            <>
+              <DialogHeader>
+                <DialogTitle>Verify Code</DialogTitle>
+                <DialogDescription>
+                  Enter the 6-digit code from your authenticator app to verify it&apos;s set up correctly.
+                </DialogDescription>
+              </DialogHeader>
+              <div className="space-y-2 py-2">
+                <Label htmlFor="2fa-verify-code">Authentication Code</Label>
+                <Input
+                  id="2fa-verify-code"
+                  type="text"
+                  inputMode="numeric"
+                  placeholder="000000"
+                  value={verifyCode}
+                  onChange={(e) => setVerifyCode(e.target.value)}
+                  autoFocus
+                  autoComplete="one-time-code"
+                  className="text-center text-lg tracking-widest"
+                  onKeyDown={(e) => {
+                    if (e.key === 'Enter') handleVerify2FA()
+                  }}
+                />
+              </div>
+              <DialogFooter>
+                <Button variant="outline" onClick={() => setSetupStep('qr')}>
+                  Back
+                </Button>
+                <Button onClick={handleVerify2FA} disabled={verifying2FA}>
+                  {verifying2FA && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                  Verify
+                </Button>
+              </DialogFooter>
+            </>
+          )}
+
+          {setupStep === 'backup' && (
+            <>
+              <DialogHeader>
+                <DialogTitle>Save Backup Codes</DialogTitle>
+                <DialogDescription className="text-amber-700 dark:text-amber-400">
+                  Save these codes in a safe place. You can use them to sign in if you lose access to
+                  your authenticator app. Each code can only be used once.
+                </DialogDescription>
+              </DialogHeader>
+              <div className="rounded-lg border bg-muted/50 p-4">
+                <div className="grid grid-cols-2 gap-2 font-mono text-sm">
+                  {backupCodes.map((code, i) => (
+                    <div key={i} className="rounded bg-background px-3 py-1.5 text-center">
+                      {code}
+                    </div>
+                  ))}
+                </div>
+              </div>
+              <DialogFooter className="flex-col gap-2 sm:flex-col">
+                <Button variant="outline" onClick={handleCopyBackupCodes} className="w-full">
+                  {copiedBackup ? (
+                    <Check className="mr-2 h-4 w-4" />
+                  ) : (
+                    <Copy className="mr-2 h-4 w-4" />
+                  )}
+                  {copiedBackup ? 'Copied' : 'Copy Codes'}
+                </Button>
+                <Button onClick={handleFinishSetup} className="w-full">
+                  I&apos;ve saved my backup codes
+                </Button>
+              </DialogFooter>
+            </>
+          )}
+        </DialogContent>
+      </Dialog>
     </div>
   )
 }

+ 14 - 2
src/app/(authenticated)/settings/account/page.tsx

@@ -1,5 +1,17 @@
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth";
+import { db } from "@/lib/db";
+import { redirect } from "next/navigation";
 import { AccountSettings } from "./account-settings";
 
-export default function AccountSettingsPage() {
-  return <AccountSettings />;
+export default async function AccountSettingsPage() {
+  const session = await auth.api.getSession({ headers: await headers() });
+  if (!session) redirect("/auth/sign-in");
+
+  const user = await db.user.findUnique({
+    where: { id: session.user.id },
+    select: { twoFactorEnabled: true },
+  });
+
+  return <AccountSettings twoFactorEnabled={user?.twoFactorEnabled ?? false} />;
 }

+ 119 - 0
src/app/(public)/auth/verify-2fa/page.tsx

@@ -0,0 +1,119 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+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 { Checkbox } from '@/components/ui/checkbox'
+import { useGlassModal } from '@/components/glass-modal'
+import { Gauge, Loader2, Shield } from 'lucide-react'
+
+export default function VerifyTwoFactorPage() {
+  const [code, setCode] = useState('')
+  const [useBackupCode, setUseBackupCode] = useState(false)
+  const [trustDevice, setTrustDevice] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const router = useRouter()
+  const modal = useGlassModal()
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!code.trim()) return
+    setLoading(true)
+
+    try {
+      const result = useBackupCode
+        ? await authClient.twoFactor.verifyBackupCode({ code, trustDevice })
+        : await authClient.twoFactor.verifyTotp({ code, trustDevice })
+
+      if (result.error) {
+        modal.open('error', 'Verification Failed', result.error.message || 'Invalid code')
+      } else {
+        router.push('/')
+        router.refresh()
+      }
+    } catch {
+      modal.open('error', 'Verification Failed', 'An unexpected error occurred')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  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>
+          <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
+            <Shield className="h-6 w-6 text-primary" />
+          </div>
+          <h1 className="text-2xl font-bold tracking-tight">Two-Factor Authentication</h1>
+          <p className="mt-1 text-sm text-muted-foreground">
+            {useBackupCode
+              ? 'Enter one of your backup codes'
+              : 'Enter the 6-digit code from your authenticator app'}
+          </p>
+        </div>
+
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="code">{useBackupCode ? 'Backup Code' : 'Authentication Code'}</Label>
+            <Input
+              id="code"
+              type="text"
+              inputMode={useBackupCode ? 'text' : 'numeric'}
+              placeholder={useBackupCode ? 'Enter backup code' : '000000'}
+              value={code}
+              onChange={(e) => setCode(e.target.value)}
+              required
+              autoFocus
+              autoComplete="one-time-code"
+              className="h-11 bg-background/50 text-center text-lg tracking-widest"
+            />
+          </div>
+
+          <div className="flex items-center space-x-2">
+            <Checkbox
+              id="trust-device"
+              checked={trustDevice}
+              onCheckedChange={(checked) => setTrustDevice(checked === true)}
+            />
+            <Label htmlFor="trust-device" className="text-sm font-normal text-muted-foreground">
+              Trust this device for 30 days
+            </Label>
+          </div>
+
+          <Button type="submit" className="h-11 w-full" disabled={loading}>
+            {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
+            Verify
+          </Button>
+        </form>
+
+        <div className="mt-6 text-center">
+          <button
+            type="button"
+            onClick={() => {
+              setUseBackupCode(!useBackupCode)
+              setCode('')
+            }}
+            className="text-sm font-medium text-primary hover:underline"
+          >
+            {useBackupCode ? 'Use authenticator app instead' : 'Use a backup code'}
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 10 - 1
src/lib/auth-client.ts

@@ -1,5 +1,14 @@
 import { createAuthClient } from "better-auth/react";
+import { twoFactorClient } from "better-auth/plugins/two-factor";
 
-export const authClient = createAuthClient();
+export const authClient = createAuthClient({
+  plugins: [
+    twoFactorClient({
+      onTwoFactorRedirect: () => {
+        window.location.href = "/auth/verify-2fa";
+      },
+    }),
+  ],
+});
 
 export const { signIn, signUp, signOut, useSession } = authClient;

+ 2 - 0
src/lib/auth.ts

@@ -1,6 +1,7 @@
 import { betterAuth } from "better-auth";
 import { prismaAdapter } from "better-auth/adapters/prisma";
 import { nextCookies } from "better-auth/next-js";
+import { twoFactor } from "better-auth/plugins/two-factor";
 import { db } from "./db";
 
 const baseURL = process.env.NEXT_PUBLIC_APP_URL;
@@ -53,6 +54,7 @@ export const auth = betterAuth({
     },
   },
   plugins: [
+    twoFactor({ issuer: "Torqvoice" }),
     nextCookies(), // Must be last plugin
   ],
 });