|
|
@@ -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'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've saved my backup codes
|
|
|
+ </Button>
|
|
|
+ </DialogFooter>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
</div>
|
|
|
)
|
|
|
}
|