| import React, { useCallback, useState } from 'react'; |
| import { useSetRecoilState } from 'recoil'; |
| import { SmartphoneIcon } from 'lucide-react'; |
| import { motion, AnimatePresence } from 'framer-motion'; |
| import { |
| OGDialog, |
| useToastContext, |
| OGDialogContent, |
| OGDialogHeader, |
| OGDialogTitle, |
| Progress, |
| } from '@librechat/client'; |
| import type { TUser, TVerify2FARequest } from 'librechat-data-provider'; |
| import { |
| useConfirmTwoFactorMutation, |
| useDisableTwoFactorMutation, |
| useEnableTwoFactorMutation, |
| useVerifyTwoFactorMutation, |
| } from '~/data-provider'; |
| import { SetupPhase, QRPhase, VerifyPhase, BackupPhase, DisablePhase } from './TwoFactorPhases'; |
| import { DisableTwoFactorToggle } from './DisableTwoFactorToggle'; |
| import { useAuthContext, useLocalize } from '~/hooks'; |
| import store from '~/store'; |
|
|
| export type Phase = 'setup' | 'qr' | 'verify' | 'backup' | 'disable'; |
|
|
| const phaseVariants = { |
| initial: { opacity: 0, scale: 0.95 }, |
| animate: { opacity: 1, scale: 1, transition: { duration: 0.3, ease: 'easeOut' } }, |
| exit: { opacity: 0, scale: 0.95, transition: { duration: 0.3, ease: 'easeIn' } }, |
| }; |
|
|
| const TwoFactorAuthentication: React.FC = () => { |
| const localize = useLocalize(); |
| const { user } = useAuthContext(); |
| const setUser = useSetRecoilState(store.user); |
| const { showToast } = useToastContext(); |
|
|
| const [secret, setSecret] = useState<string>(''); |
| const [otpauthUrl, setOtpauthUrl] = useState<string>(''); |
| const [downloaded, setDownloaded] = useState<boolean>(false); |
| const [backupCodes, setBackupCodes] = useState<string[]>([]); |
| const [_disableToken, setDisableToken] = useState<string>(''); |
| const [isDialogOpen, setDialogOpen] = useState<boolean>(false); |
| const [verificationToken, setVerificationToken] = useState<string>(''); |
| const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup'); |
|
|
| const { mutate: confirm2FAMutate } = useConfirmTwoFactorMutation(); |
| const { mutate: enable2FAMutate, isLoading: isGenerating } = useEnableTwoFactorMutation(); |
| const { mutate: verify2FAMutate, isLoading: isVerifying } = useVerifyTwoFactorMutation(); |
| const { mutate: disable2FAMutate, isLoading: isDisabling } = useDisableTwoFactorMutation(); |
|
|
| const steps = ['Setup', 'Scan QR', 'Verify', 'Backup']; |
| const phasesLabel: Record<Phase, string> = { |
| setup: 'Setup', |
| qr: 'Scan QR', |
| verify: 'Verify', |
| backup: 'Backup', |
| disable: '', |
| }; |
|
|
| const currentStep = steps.indexOf(phasesLabel[phase]); |
|
|
| const resetState = useCallback(() => { |
| if (user?.twoFactorEnabled && otpauthUrl) { |
| disable2FAMutate(undefined, { |
| onError: () => |
| showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }), |
| }); |
| } |
|
|
| setOtpauthUrl(''); |
| setSecret(''); |
| setBackupCodes([]); |
| setVerificationToken(''); |
| setDisableToken(''); |
| setPhase(user?.twoFactorEnabled ? 'disable' : 'setup'); |
| setDownloaded(false); |
| }, [user, otpauthUrl, disable2FAMutate, localize, showToast]); |
|
|
| const handleGenerateQRCode = useCallback(() => { |
| enable2FAMutate(undefined, { |
| onSuccess: ({ otpauthUrl, backupCodes }) => { |
| setOtpauthUrl(otpauthUrl); |
| setSecret(otpauthUrl.split('secret=')[1].split('&')[0]); |
| setBackupCodes(backupCodes); |
| setPhase('qr'); |
| }, |
| onError: () => showToast({ message: localize('com_ui_2fa_generate_error'), status: 'error' }), |
| }); |
| }, [enable2FAMutate, localize, showToast]); |
|
|
| const handleVerify = useCallback(() => { |
| if (!verificationToken) { |
| return; |
| } |
|
|
| verify2FAMutate( |
| { token: verificationToken }, |
| { |
| onSuccess: () => { |
| showToast({ message: localize('com_ui_2fa_verified') }); |
| confirm2FAMutate( |
| { token: verificationToken }, |
| { |
| onSuccess: () => setPhase('backup'), |
| onError: () => |
| showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }), |
| }, |
| ); |
| }, |
| onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }), |
| }, |
| ); |
| }, [verificationToken, verify2FAMutate, confirm2FAMutate, localize, showToast]); |
|
|
| const handleDownload = useCallback(() => { |
| if (!backupCodes.length) { |
| return; |
| } |
| const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain;charset=utf-8' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = 'backup-codes.txt'; |
| a.click(); |
| URL.revokeObjectURL(url); |
| setDownloaded(true); |
| }, [backupCodes]); |
|
|
| const handleConfirm = useCallback(() => { |
| setDialogOpen(false); |
| setPhase('disable'); |
| showToast({ message: localize('com_ui_2fa_enabled') }); |
| setUser( |
| (prev) => |
| ({ |
| ...prev, |
| backupCodes: backupCodes.map((code) => ({ |
| code, |
| codeHash: code, |
| used: false, |
| usedAt: null, |
| })), |
| twoFactorEnabled: true, |
| }) as TUser, |
| ); |
| }, [setUser, localize, showToast, backupCodes]); |
|
|
| const handleDisableVerify = useCallback( |
| (token: string, useBackup: boolean) => { |
| |
| |
| if (!useBackup && token.trim().length < 6) { |
| return; |
| } |
|
|
| if (useBackup && token.trim().length < 8) { |
| return; |
| } |
|
|
| const payload: TVerify2FARequest = {}; |
| if (useBackup) { |
| payload.backupCode = token.trim(); |
| } else { |
| payload.token = token.trim(); |
| } |
|
|
| disable2FAMutate(payload, { |
| onSuccess: () => { |
| showToast({ message: localize('com_ui_2fa_disabled') }); |
| setDialogOpen(false); |
| setUser( |
| (prev) => |
| ({ |
| ...prev, |
| totpSecret: '', |
| backupCodes: [], |
| twoFactorEnabled: false, |
| }) as TUser, |
| ); |
| setPhase('setup'); |
| setOtpauthUrl(''); |
| }, |
| onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }), |
| }); |
| }, |
| [disable2FAMutate, showToast, localize, setUser], |
| ); |
|
|
| return ( |
| <OGDialog |
| open={isDialogOpen} |
| onOpenChange={(open) => { |
| setDialogOpen(open); |
| if (!open) { |
| resetState(); |
| } |
| }} |
| > |
| <DisableTwoFactorToggle |
| enabled={!!user?.twoFactorEnabled} |
| onChange={() => setDialogOpen(true)} |
| disabled={isVerifying || isDisabling || isGenerating} |
| /> |
| |
| <OGDialogContent className="w-11/12 max-w-lg p-6"> |
| <AnimatePresence mode="wait"> |
| <motion.div |
| key={phase} |
| variants={phaseVariants} |
| initial="initial" |
| animate="animate" |
| exit="exit" |
| className="space-y-6" |
| > |
| <OGDialogHeader> |
| <OGDialogTitle className="mb-2 flex items-center gap-3 text-2xl font-bold"> |
| <SmartphoneIcon className="h-6 w-6 text-primary" /> |
| {user?.twoFactorEnabled |
| ? localize('com_ui_2fa_disable') |
| : localize('com_ui_2fa_setup')} |
| </OGDialogTitle> |
| {user?.twoFactorEnabled && phase !== 'disable' && ( |
| <div className="mt-4 space-y-3"> |
| <Progress |
| value={(steps.indexOf(phasesLabel[phase]) / (steps.length - 1)) * 100} |
| className="h-2 rounded-full" |
| /> |
| <div className="flex justify-between text-sm"> |
| {steps.map((step, index) => ( |
| <motion.span |
| key={step} |
| animate={{ |
| color: |
| currentStep >= index ? 'var(--text-primary)' : 'var(--text-tertiary)', |
| }} |
| className="font-medium" |
| > |
| {step} |
| </motion.span> |
| ))} |
| </div> |
| </div> |
| )} |
| </OGDialogHeader> |
| |
| <AnimatePresence mode="wait"> |
| {phase === 'setup' && ( |
| <SetupPhase |
| isGenerating={isGenerating} |
| onGenerate={handleGenerateQRCode} |
| onNext={() => setPhase('qr')} |
| onError={(error) => showToast({ message: error.message, status: 'error' })} |
| /> |
| )} |
| |
| {phase === 'qr' && ( |
| <QRPhase |
| secret={secret} |
| otpauthUrl={otpauthUrl} |
| onNext={() => setPhase('verify')} |
| onError={(error) => showToast({ message: error.message, status: 'error' })} |
| /> |
| )} |
| |
| {phase === 'verify' && ( |
| <VerifyPhase |
| token={verificationToken} |
| onTokenChange={setVerificationToken} |
| isVerifying={isVerifying} |
| onNext={handleVerify} |
| onError={(error) => showToast({ message: error.message, status: 'error' })} |
| /> |
| )} |
| |
| {phase === 'backup' && ( |
| <BackupPhase |
| backupCodes={backupCodes} |
| onDownload={handleDownload} |
| downloaded={downloaded} |
| onNext={handleConfirm} |
| onError={(error) => showToast({ message: error.message, status: 'error' })} |
| /> |
| )} |
| |
| {phase === 'disable' && ( |
| <DisablePhase |
| onDisable={handleDisableVerify} |
| isDisabling={isDisabling} |
| onError={(error) => showToast({ message: error.message, status: 'error' })} |
| /> |
| )} |
| </AnimatePresence> |
| </motion.div> |
| </AnimatePresence> |
| </OGDialogContent> |
| </OGDialog> |
| ); |
| }; |
|
|
| export default React.memo(TwoFactorAuthentication); |
|
|