mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-26 21:28:50 +01:00
🔒 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:
parent
46ceae1a93
commit
f0f09138bd
63 changed files with 1976 additions and 129 deletions
|
|
@ -85,7 +85,8 @@ function AuthLayout({
|
|||
</h1>
|
||||
)}
|
||||
{children}
|
||||
{(pathname.includes('login') || pathname.includes('register')) && (
|
||||
{!pathname.includes('2fa') &&
|
||||
(pathname.includes('login') || pathname.includes('register')) && (
|
||||
<SocialLoginRender startupConfig={startupConfig} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -166,9 +166,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||
type="submit"
|
||||
className="
|
||||
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
|
||||
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
|
||||
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
|
||||
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
|
||||
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
|
||||
"
|
||||
>
|
||||
{localize('com_auth_continue')}
|
||||
|
|
|
|||
176
client/src/components/Auth/TwoFactorScreen.tsx
Normal file
176
client/src/components/Auth/TwoFactorScreen.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '~/components';
|
||||
import { useVerifyTwoFactorTempMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface VerifyPayload {
|
||||
tempToken: string;
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
}
|
||||
|
||||
type TwoFactorFormInputs = {
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
const TwoFactorScreen: React.FC = React.memo(() => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const tempTokenRaw = searchParams.get('tempToken');
|
||||
const tempToken = tempTokenRaw !== null && tempTokenRaw !== '' ? tempTokenRaw : '';
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TwoFactorFormInputs>();
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [useBackup, setUseBackup] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { mutate: verifyTempMutate } = useVerifyTwoFactorTempMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.token != null && result.token !== '') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsLoading(true);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setIsLoading(false);
|
||||
const err = error as { response?: { data?: { message?: unknown } } };
|
||||
const errorMsg =
|
||||
typeof err.response?.data?.message === 'string'
|
||||
? err.response.data.message
|
||||
: 'Error verifying 2FA';
|
||||
showToast({ message: errorMsg, status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: TwoFactorFormInputs) => {
|
||||
const payload: VerifyPayload = { tempToken };
|
||||
if (useBackup && data.backupCode != null && data.backupCode !== '') {
|
||||
payload.backupCode = data.backupCode;
|
||||
} else if (data.token != null && data.token !== '') {
|
||||
payload.token = data.token;
|
||||
}
|
||||
verifyTempMutate(payload);
|
||||
},
|
||||
[tempToken, useBackup, verifyTempMutate],
|
||||
);
|
||||
|
||||
const toggleBackupOn = useCallback(() => {
|
||||
setUseBackup(true);
|
||||
}, []);
|
||||
|
||||
const toggleBackupOff = useCallback(() => {
|
||||
setUseBackup(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Label className="flex justify-center break-keep text-center text-sm text-text-primary">
|
||||
{localize('com_auth_two_factor')}
|
||||
</Label>
|
||||
{!useBackup && (
|
||||
<div className="my-4 flex justify-center text-text-primary">
|
||||
<Controller
|
||||
name="token"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={value != null ? value : ''}
|
||||
onChange={onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
)}
|
||||
/>
|
||||
{errors.token && <span className="text-sm text-red-500">{errors.token.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
{useBackup && (
|
||||
<div className="my-4 flex justify-center text-text-primary">
|
||||
<Controller
|
||||
name="backupCode"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputOTP
|
||||
maxLength={8}
|
||||
value={value != null ? value : ''}
|
||||
onChange={onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
>
|
||||
<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>
|
||||
</InputOTP>
|
||||
)}
|
||||
/>
|
||||
{errors.backupCode && (
|
||||
<span className="text-sm text-red-500">{errors.backupCode.message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={localize('com_auth_continue')}
|
||||
data-testid="login-button"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-80 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
{isLoading ? localize('com_auth_email_verifying_ellipsis') : localize('com_ui_verify')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center">
|
||||
{!useBackup ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBackupOn}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_ui_use_backup_code')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBackupOff}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_ui_use_2fa_code')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TwoFactorScreen;
|
||||
|
|
@ -4,3 +4,4 @@ export { default as ResetPassword } from './ResetPassword';
|
|||
export { default as VerifyEmail } from './VerifyEmail';
|
||||
export { default as ApiErrorWatcher } from './ApiErrorWatcher';
|
||||
export { default as RequestPasswordReset } from './RequestPasswordReset';
|
||||
export { default as TwoFactorScreen } from './TwoFactorScreen';
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||
|
||||
let statusText: string;
|
||||
if (!status) {
|
||||
statusText = text ?? localize('com_endpoint_import');
|
||||
statusText = text ?? localize('com_ui_import');
|
||||
} else if (status === 'success') {
|
||||
statusText = successText ?? localize('com_ui_upload_success');
|
||||
} else {
|
||||
|
|
@ -72,12 +72,12 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||
)}
|
||||
>
|
||||
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
<span className="flex text-xs ">{statusText}</span>
|
||||
<span className="flex text-xs">{statusText}</span>
|
||||
<input
|
||||
id={`file-upload-${id}`}
|
||||
value=""
|
||||
type="file"
|
||||
className={cn('hidden ', className)}
|
||||
className={cn('hidden', className)}
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from './BackupPhase';
|
||||
export * from './QRPhase';
|
||||
export * from './VerifyPhase';
|
||||
export * from './SetupPhase';
|
||||
export * from './DisablePhase';
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
68
client/src/components/ui/InputOTP.tsx
Normal file
68
client/src/components/ui/InputOTP.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { Minus } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = 'InputOTP';
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = 'InputOTPGroup';
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-md relative flex h-11 w-11 items-center justify-center border-y border-r border-input shadow-sm transition-all first:rounded-l-xl first:border-l last:rounded-r-xl',
|
||||
isActive && 'z-10 ring-1 ring-ring',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = 'InputOTPSlot';
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = 'InputOTPSeparator';
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
22
client/src/components/ui/Progress.tsx
Normal file
22
client/src/components/ui/Progress.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
|
|
@ -24,6 +24,8 @@ export * from './Textarea';
|
|||
export * from './TextareaAutosize';
|
||||
export * from './Tooltip';
|
||||
export * from './Pagination';
|
||||
export * from './Progress';
|
||||
export * from './InputOTP';
|
||||
export { default as Combobox } from './Combobox';
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export { default as FileUpload } from './FileUpload';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue