🔒 feat: Two-Factor Authentication with Backup Codes & QR support (#5685)

* 🔒 feat: add Two-Factor Authentication (2FA) with backup codes & QR support (#5684)

* working version for generating TOTP and authenticate.

* better looking UI

* refactored + better TOTP logic

* fixed issue with UI

* fixed issue: remove initial setup when closing window before completion.

* added: onKeyDown for verify and disable

* refactored some code and cleaned it up a bit.

* refactored some code and cleaned it up a bit.

* refactored some code and cleaned it up a bit.

* refactored some code and cleaned it up a bit.

* fixed issue after updating to new main branch

* updated example

* refactored controllers

* removed `passport-totp` not used.

* update the generateBackupCodes function to generate 10 codes by default:

* update the backup codes to an object.

* fixed issue with backup codes not working

* be able to disable 2FA with backup codes.

* removed new env. replaced with JWT_SECRET

*  style: improved a11y and style for TwoFactorAuthentication

* 🔒 fix: small types checks

*  feat: improve 2FA UI components

* fix: remove unnecessary console log

* add option to disable 2FA with backup codes

* - add option to refresh backup codes
- (optional) maybe show the user which backup codes have already been used?

* removed text to be able to merge the main.

* removed eng tx to be able to merge

* fix: migrated lang to new format.

* feat: rewrote whole 2FA UI + refactored 2FA backend

* chore: resolving conflicts

* chore: resolving conflicts

* fix: missing packages, because of resolving conflicts.

* fix: UI issue and improved a11y

* fix: 2FA backup code not working

* fix: update localization keys for UI consistency

* fix: update button label to use localized text

* fix: refactor backup codes regeneration and update localization keys

* fix: remove outdated translation for shared links management

* fix: remove outdated 2FA code prompts from translation.json

* fix: add cursor styles for backup codes item based on usage state

* fix: resolve conflict issue

* fix: resolve conflict issue

* fix: resolve conflict issue

* fix: missing packages in package-lock.json

* fix: add disabled opacity to the verify button in TwoFactorScreen

* ⚙ fix: update 2FA logic to rely on backup codes instead of TOTP status

* ⚙️ fix: Simplify user retrieval in 2FA logic by removing unnecessary TOTP secret query

* ⚙️ test: Add unit tests for TwoFactorAuthController and twoFactorControllers

* ⚙️ fix: Ensure backup codes are validated as an array before usage in 2FA components

* ⚙️ fix: Update module path mappings in tests to use relative paths

* ⚙️ fix: Update moduleNameMapper in jest.config.js to remove the caret from path mapping

* ⚙️ refactor: Simplify import paths in TwoFactorAuthController and twoFactorControllers test files

* ⚙️ test: Mock twoFactorService methods in twoFactorControllers tests

* ⚙️ refactor: Comment out unused imports and mock setups in test files for two-factor authentication

* ⚙️ refactor: removed files

* refactor: Exclude totpSecret from user data retrieval in AuthController, LoginController, and jwtStrategy

* refactor: Consolidate backup code verification to apply DRY and remove default array in user schema

* refactor: Enhance two-factor authentication ux/flow with improved error handling and loading state management, prevent redirect to /login

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Ruben Talstra 2025-02-18 01:09:36 +01:00 committed by GitHub
parent 46ceae1a93
commit f0f09138bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1976 additions and 129 deletions

View file

@ -2,19 +2,36 @@ import React from 'react';
import DisplayUsernameMessages from './DisplayUsernameMessages';
import DeleteAccount from './DeleteAccount';
import Avatar from './Avatar';
import EnableTwoFactorItem from './TwoFactorAuthentication';
import BackupCodesItem from './BackupCodesItem';
import { useAuthContext } from '~/hooks';
function Account() {
const user = useAuthContext();
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="pb-3">
<DisplayUsernameMessages />
</div>
<div className="pb-3">
<Avatar />
</div>
{user?.user?.provider === 'local' && (
<>
<div className="pb-3">
<EnableTwoFactorItem />
</div>
{Array.isArray(user.user?.backupCodes) && user.user?.backupCodes.length > 0 && (
<div className="pb-3">
<BackupCodesItem />
</div>
)}
</>
)}
<div className="pb-3">
<DeleteAccount />
</div>
<div className="pb-3">
<DisplayUsernameMessages />
</div>
</div>
);
}

View file

@ -47,7 +47,7 @@ function Avatar() {
const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({
onSuccess: (data) => {
showToast({ message: localize('com_ui_upload_success') });
setUser((prev) => ({ ...prev, avatar: data.url } as TUser));
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
openButtonRef.current?.click();
},
onError: (error) => {
@ -133,9 +133,11 @@ function Avatar() {
>
<div className="flex items-center justify-between">
<span>{localize('com_nav_profile_picture')}</span>
<OGDialogTrigger ref={openButtonRef} className="btn btn-neutral relative">
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
<span>{localize('com_nav_change_picture')}</span>
<OGDialogTrigger ref={openButtonRef}>
<Button variant="outline">
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
<span>{localize('com_nav_change_picture')}</span>
</Button>
</OGDialogTrigger>
</div>

View file

@ -0,0 +1,194 @@
import React, { useState } from 'react';
import { RefreshCcw, ShieldX } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { TBackupCode, TRegenerateBackupCodesResponse, type TUser } from 'librechat-data-provider';
import {
OGDialog,
OGDialogContent,
OGDialogTitle,
OGDialogTrigger,
Button,
Label,
Spinner,
TooltipAnchor,
} from '~/components';
import { useRegenerateBackupCodesMutation } from '~/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
import { useToastContext } from '~/Providers';
import { useSetRecoilState } from 'recoil';
import store from '~/store';
const BackupCodesItem: React.FC = () => {
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
const setUser = useSetRecoilState(store.user);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const { mutate: regenerateBackupCodes, isLoading } = useRegenerateBackupCodesMutation();
const fetchBackupCodes = (auto: boolean = false) => {
regenerateBackupCodes(undefined, {
onSuccess: (data: TRegenerateBackupCodesResponse) => {
const newBackupCodes: TBackupCode[] = data.backupCodesHash.map((codeHash) => ({
codeHash,
used: false,
usedAt: null,
}));
setUser((prev) => ({ ...prev, backupCodes: newBackupCodes }) as TUser);
showToast({
message: localize('com_ui_backup_codes_regenerated'),
status: 'success',
});
// Trigger file download only when user explicitly clicks the button.
if (!auto && newBackupCodes.length) {
const codesString = data.backupCodes.join('\n');
const blob = new Blob([codesString], { 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);
}
},
onError: () =>
showToast({
message: localize('com_ui_backup_codes_regenerate_error'),
status: 'error',
}),
});
};
const handleRegenerate = () => {
fetchBackupCodes(false);
};
return (
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Label className="font-light">{localize('com_ui_backup_codes')}</Label>
</div>
<OGDialogTrigger asChild>
<Button aria-label="Show Backup Codes" variant="outline">
{localize('com_ui_show')}
</Button>
</OGDialogTrigger>
</div>
<OGDialogContent className="w-11/12 max-w-lg">
<OGDialogTitle className="mb-6 text-2xl font-semibold">
{localize('com_ui_backup_codes')}
</OGDialogTitle>
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="mt-4"
>
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? (
<>
<div className="grid grid-cols-2 gap-4">
{user?.backupCodes.map((code, index) => {
const isUsed = code.used;
const description = `Backup code number ${index + 1}, ${
isUsed
? `used on ${code.usedAt ? new Date(code.usedAt).toLocaleDateString() : 'an unknown date'}`
: 'not used yet'
}`;
return (
<motion.div
key={code.codeHash}
role="listitem"
tabIndex={0}
aria-label={description}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
onFocus={() => {
const announcement = new CustomEvent('announce', {
detail: { message: description },
});
document.dispatchEvent(announcement);
}}
className={`flex flex-col rounded-xl border p-4 backdrop-blur-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
isUsed
? 'border-red-200 bg-red-50/80 dark:border-red-800 dark:bg-red-900/20'
: 'border-green-200 bg-green-50/80 dark:border-green-800 dark:bg-green-900/20'
} `}
>
<div className="flex items-center justify-between" aria-hidden="true">
<span className="text-sm font-medium text-text-secondary">
#{index + 1}
</span>
<TooltipAnchor
description={
code.usedAt ? new Date(code.usedAt).toLocaleDateString() : ''
}
disabled={!isUsed}
focusable={false}
className={isUsed ? 'cursor-pointer' : 'cursor-default'}
render={
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${
isUsed
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}
>
{isUsed ? localize('com_ui_used') : localize('com_ui_not_used')}
</span>
}
/>
</div>
</motion.div>
);
})}
</div>
<div className="mt-12 flex justify-center">
<Button
onClick={handleRegenerate}
disabled={isLoading}
variant="default"
className="px-8 py-3 transition-all disabled:opacity-50"
>
{isLoading ? (
<Spinner className="mr-2" />
) : (
<RefreshCcw className="mr-2 h-4 w-4" />
)}
{isLoading
? localize('com_ui_regenerating')
: localize('com_ui_regenerate_backup')}
</Button>
</div>
</>
) : (
<div className="flex flex-col items-center gap-4 p-6 text-center">
<ShieldX className="h-12 w-12 text-text-primary" />
<p className="text-lg text-text-secondary">{localize('com_ui_no_backup_codes')}</p>
<Button
onClick={handleRegenerate}
disabled={isLoading}
variant="default"
className="px-8 py-3 transition-all disabled:opacity-50"
>
{isLoading && <Spinner className="mr-2" />}
{localize('com_ui_generate_backup')}
</Button>
</div>
)}
</motion.div>
</AnimatePresence>
</OGDialogContent>
</OGDialog>
);
};
export default React.memo(BackupCodesItem);

View file

@ -57,7 +57,7 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
</Button>
</OGDialogTrigger>
</div>
<OGDialogContent className="w-11/12 max-w-2xl">
<OGDialogContent className="w-11/12 max-w-md">
<OGDialogHeader>
<OGDialogTitle className="text-lg font-medium leading-6">
{localize('com_nav_delete_account_confirm')}

View file

@ -0,0 +1,36 @@
import React from 'react';
import { motion } from 'framer-motion';
import { LockIcon, UnlockIcon } from 'lucide-react';
import { Label, Button } from '~/components';
import { useLocalize } from '~/hooks';
interface DisableTwoFactorToggleProps {
enabled: boolean;
onChange: () => void;
disabled?: boolean;
}
export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
enabled,
onChange,
disabled,
}) => {
const localize = useLocalize();
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Label className="font-light"> {localize('com_nav_2fa')}</Label>
</div>
<div className="flex items-center gap-3">
<Button
variant={enabled ? 'destructive' : 'outline'}
onClick={onChange}
disabled={disabled}
>
{enabled ? localize('com_ui_2fa_disable') : localize('com_ui_2fa_enable')}
</Button>
</div>
</div>
);
};

View file

@ -0,0 +1,298 @@
import React, { useCallback, useState } from 'react';
import { useSetRecoilState } from 'recoil';
import { SmartphoneIcon } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import type { TUser, TVerify2FARequest } from 'librechat-data-provider';
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle, Progress } from '~/components';
import { SetupPhase, QRPhase, VerifyPhase, BackupPhase, DisablePhase } from './TwoFactorPhases';
import { DisableTwoFactorToggle } from './DisableTwoFactorToggle';
import { useAuthContext, useLocalize } from '~/hooks';
import { useToastContext } from '~/Providers';
import store from '~/store';
import {
useConfirmTwoFactorMutation,
useDisableTwoFactorMutation,
useEnableTwoFactorMutation,
useVerifyTwoFactorMutation,
} from '~/data-provider';
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 [disableToken, setDisableToken] = useState<string>('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [verificationToken, setVerificationToken] = useState<string>('');
const [phase, setPhase] = useState<Phase>(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? '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 (Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && otpauthUrl) {
disable2FAMutate(undefined, {
onError: () =>
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
});
}
setOtpauthUrl('');
setSecret('');
setBackupCodes([]);
setVerificationToken('');
setDisableToken('');
setPhase(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? '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,
})),
}) as TUser,
);
}, [setUser, localize, showToast, backupCodes]);
const handleDisableVerify = useCallback(
(token: string, useBackup: boolean) => {
// Validate: if not using backup, ensure token has at least 6 digits;
// if using backup, ensure backup code has at least 8 characters.
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();
}
verify2FAMutate(payload, {
onSuccess: () => {
disable2FAMutate(undefined, {
onSuccess: () => {
showToast({ message: localize('com_ui_2fa_disabled') });
setDialogOpen(false);
setUser(
(prev) =>
({
...prev,
totpSecret: '',
backupCodes: [],
}) as TUser,
);
setPhase('setup');
setOtpauthUrl('');
},
onError: () =>
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
});
},
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
});
},
[disableToken, verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
);
return (
<OGDialog
open={isDialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
resetState();
}
}}
>
<DisableTwoFactorToggle
enabled={Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0}
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" />
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? localize('com_ui_2fa_disable') : localize('com_ui_2fa_setup')}
</OGDialogTitle>
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && 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);

View file

@ -0,0 +1,60 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Download } from 'lucide-react';
import { Button, Label } from '~/components';
import { useLocalize } from '~/hooks';
const fadeAnimation = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.2 },
};
interface BackupPhaseProps {
onNext: () => void;
onError: (error: Error) => void;
backupCodes: string[];
onDownload: () => void;
downloaded: boolean;
}
export const BackupPhase: React.FC<BackupPhaseProps> = ({
backupCodes,
onDownload,
downloaded,
onNext,
}) => {
const localize = useLocalize();
return (
<motion.div {...fadeAnimation} className="space-y-6">
<Label className="break-keep text-sm">{localize('com_ui_download_backup_tooltip')}</Label>
<div className="grid grid-cols-2 gap-4 rounded-xl bg-surface-secondary p-6">
{backupCodes.map((code, index) => (
<motion.div
key={code}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="rounded-lg bg-surface-tertiary p-3"
>
<div className="flex items-center justify-between">
<span className="hidden text-sm text-text-secondary sm:inline">#{index + 1}</span>
<span className="font-mono text-lg">{code}</span>
</div>
</motion.div>
))}
</div>
<div className="flex gap-4">
<Button variant="outline" onClick={onDownload} className="flex-1 gap-2">
<Download className="h-4 w-4" />
<span className="hidden sm:inline">{localize('com_ui_download_backup')}</span>
</Button>
<Button onClick={onNext} disabled={!downloaded} className="flex-1">
{localize('com_ui_complete_setup')}
</Button>
</div>
</motion.div>
);
};

View file

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
import {
Button,
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
Spinner,
} from '~/components';
import { useLocalize } from '~/hooks';
const fadeAnimation = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.2 },
};
interface DisablePhaseProps {
onSuccess?: () => void;
onError?: (error: Error) => void;
onDisable: (token: string, useBackup: boolean) => void;
isDisabling: boolean;
}
export const DisablePhase: React.FC<DisablePhaseProps> = ({ onDisable, isDisabling }) => {
const localize = useLocalize();
const [token, setToken] = useState('');
const [useBackup, setUseBackup] = useState(false);
return (
<motion.div {...fadeAnimation} className="space-y-8">
<div className="flex justify-center">
<InputOTP
value={token}
onChange={setToken}
maxLength={useBackup ? 8 : 6}
pattern={useBackup ? REGEXP_ONLY_DIGITS_AND_CHARS : REGEXP_ONLY_DIGITS}
className="gap-2"
>
{useBackup ? (
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
<InputOTPSlot index={7} />
</InputOTPGroup>
) : (
<>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</>
)}
</InputOTP>
</div>
<Button
variant="destructive"
onClick={() => onDisable(token, useBackup)}
disabled={isDisabling || token.length !== (useBackup ? 8 : 6)}
className="w-full rounded-xl px-6 py-3 transition-all disabled:opacity-50"
>
{isDisabling === true && <Spinner className="mr-2" />}
{isDisabling ? localize('com_ui_disabling') : localize('com_ui_2fa_disable')}
</Button>
<button
onClick={() => setUseBackup(!useBackup)}
className="text-sm text-primary hover:underline"
>
{useBackup ? localize('com_ui_use_2fa_code') : localize('com_ui_use_backup_code')}
</button>
</motion.div>
);
};

View file

@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, Check } from 'lucide-react';
import { Input, Button, Label } from '~/components';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const fadeAnimation = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.2 },
};
interface QRPhaseProps {
secret: string;
otpauthUrl: string;
onNext: () => void;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
export const QRPhase: React.FC<QRPhaseProps> = ({ secret, otpauthUrl, onNext }) => {
const localize = useLocalize();
const [isCopying, setIsCopying] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(secret);
setIsCopying(true);
setTimeout(() => setIsCopying(false), 2000);
};
return (
<motion.div {...fadeAnimation} className="space-y-6">
<div className="flex flex-col items-center space-y-6">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="rounded-2xl bg-white p-4 shadow-lg"
>
<QRCodeSVG value={otpauthUrl} size={240} />
</motion.div>
<div className="w-full space-y-3">
<Label className="text-sm font-medium text-text-secondary">
{localize('com_ui_secret_key')}
</Label>
<div className="flex gap-2">
<Input value={secret} readOnly className="font-mono text-lg tracking-wider" />
<Button
size="sm"
variant="outline"
onClick={handleCopy}
className={cn('h-auto shrink-0', isCopying ? 'cursor-default' : '')}
>
{isCopying ? <Check className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
</div>
</div>
<Button onClick={onNext} className="w-full">
{localize('com_ui_continue')}
</Button>
</motion.div>
);
};

View file

@ -0,0 +1,42 @@
import React from 'react';
import { QrCode } from 'lucide-react';
import { motion } from 'framer-motion';
import { Button, Spinner } from '~/components';
import { useLocalize } from '~/hooks';
const fadeAnimation = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.2 },
};
interface SetupPhaseProps {
onNext: () => void;
onError: (error: Error) => void;
isGenerating: boolean;
onGenerate: () => void;
}
export const SetupPhase: React.FC<SetupPhaseProps> = ({ isGenerating, onGenerate, onNext }) => {
const localize = useLocalize();
return (
<motion.div {...fadeAnimation} className="space-y-6">
<div className="rounded-xl bg-surface-secondary p-6">
<h3 className="mb-4 flex justify-center text-lg font-medium">
{localize('com_ui_2fa_account_security')}
</h3>
<Button
variant="default"
onClick={onGenerate}
className="flex w-full"
disabled={isGenerating}
>
{isGenerating ? <Spinner className="size-5" /> : <QrCode className="size-5" />}
{isGenerating ? localize('com_ui_generating') : localize('com_ui_generate_qrcode')}
</Button>
</div>
</motion.div>
);
};

View file

@ -0,0 +1,58 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Button, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '~/components';
import { REGEXP_ONLY_DIGITS } from 'input-otp';
import { useLocalize } from '~/hooks';
const fadeAnimation = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.2 },
};
interface VerifyPhaseProps {
token: string;
onTokenChange: (value: string) => void;
isVerifying: boolean;
onNext: () => void;
onError: (error: Error) => void;
}
export const VerifyPhase: React.FC<VerifyPhaseProps> = ({
token,
onTokenChange,
isVerifying,
onNext,
}) => {
const localize = useLocalize();
return (
<motion.div {...fadeAnimation} className="space-y-8">
<div className="flex justify-center">
<InputOTP
value={token}
onChange={onTokenChange}
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
className="gap-2"
>
<InputOTPGroup>
{Array.from({ length: 3 }).map((_, i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
{Array.from({ length: 3 }).map((_, i) => (
<InputOTPSlot key={i + 3} index={i + 3} />
))}
</InputOTPGroup>
</InputOTP>
</div>
<Button onClick={onNext} disabled={isVerifying || token.length !== 6} className="w-full">
{localize('com_ui_verify')}
</Button>
</motion.div>
);
};

View file

@ -0,0 +1,5 @@
export * from './BackupPhase';
export * from './QRPhase';
export * from './VerifyPhase';
export * from './SetupPhase';
export * from './DisablePhase';

View file

@ -82,7 +82,7 @@ function ImportConversations() {
onClick={handleImportClick}
onKeyDown={handleKeyDown}
disabled={!allowImport}
aria-label={localize('com_ui_import_conversation')}
aria-label={localize('com_ui_import')}
className="btn btn-neutral relative"
>
{allowImport ? (
@ -90,7 +90,7 @@ function ImportConversations() {
) : (
<Spinner className="mr-1 w-4" />
)}
<span>{localize('com_ui_import_conversation')}</span>
<span>{localize('com_ui_import')}</span>
</button>
<input
ref={fileInputRef}

View file

@ -270,9 +270,7 @@ export default function SharedLinks() {
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
<button className="btn btn-neutral relative">
{localize('com_nav_shared_links_manage')}
</button>
<Button variant="outline">{localize('com_ui_manage')}</Button>
</OGDialogTrigger>
<OGDialogContent

View file

@ -12,7 +12,7 @@ export default function ArchivedChats() {
<OGDialog>
<OGDialogTrigger asChild>
<Button variant="outline" aria-label="Archived chats">
{localize('com_nav_archived_chats_manage')}
{localize('com_ui_manage')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate