mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-15 12:16:33 +01:00
* fix: require OTP verification for 2FA re-enrollment and backup code regeneration * fix: require OTP verification for account deletion when 2FA is enabled * refactor: Improve code formatting and readability in TwoFactorController and UserController - Reformatted code in TwoFactorController and UserController for better readability by aligning parameters and breaking long lines. - Updated test cases in deleteUser.spec.js and TwoFactorController.spec.js to enhance clarity by formatting object parameters consistently. * refactor: Consolidate OTP and backup code verification logic in TwoFactorController and UserController - Introduced a new `verifyOTPOrBackupCode` function to streamline the verification process for TOTP tokens and backup codes across multiple controllers. - Updated the `enable2FA`, `disable2FA`, and `deleteUserController` methods to utilize the new verification function, enhancing code reusability and readability. - Adjusted related tests to reflect the changes in verification logic, ensuring consistent behavior across different scenarios. - Improved error handling and response messages for verification failures, providing clearer feedback to users. * chore: linting * refactor: Update BackupCodesItem component to enhance OTP verification logic - Consolidated OTP input handling by moving the 2FA verification UI logic to a more consistent location within the component. - Improved the state management for OTP readiness, ensuring the regenerate button is only enabled when the OTP is ready. - Cleaned up imports by removing redundant type imports, enhancing code clarity and maintainability. * chore: lint * fix: stage 2FA re-enrollment in pending fields to prevent disarmament window enable2FA now writes to pendingTotpSecret/pendingBackupCodes instead of overwriting the live fields. confirm2FA performs the atomic swap only after the new TOTP code is verified. If the user abandons mid-flow, their existing 2FA remains active and intact.
212 lines
7 KiB
TypeScript
212 lines
7 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import { LockIcon, Trash } from 'lucide-react';
|
|
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
|
import {
|
|
InputOTPSeparator,
|
|
OGDialogContent,
|
|
OGDialogTrigger,
|
|
OGDialogHeader,
|
|
InputOTPGroup,
|
|
OGDialogTitle,
|
|
InputOTPSlot,
|
|
OGDialog,
|
|
InputOTP,
|
|
Spinner,
|
|
Button,
|
|
Label,
|
|
Input,
|
|
} from '@librechat/client';
|
|
import type { TDeleteUserRequest } from 'librechat-data-provider';
|
|
import { useDeleteUserMutation } from '~/data-provider';
|
|
import { useAuthContext } from '~/hooks/AuthContext';
|
|
import { LocalizeFunction } from '~/common';
|
|
import { useLocalize } from '~/hooks';
|
|
import { cn } from '~/utils';
|
|
|
|
const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolean }) => {
|
|
const localize = useLocalize();
|
|
const { user, logout } = useAuthContext();
|
|
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUserMutation({
|
|
onSuccess: () => logout(),
|
|
});
|
|
|
|
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
|
const [isLocked, setIsLocked] = useState(true);
|
|
const [otpToken, setOtpToken] = useState('');
|
|
const [useBackup, setUseBackup] = useState(false);
|
|
|
|
const needs2FA = !!user?.twoFactorEnabled;
|
|
|
|
const handleDeleteUser = () => {
|
|
if (isLocked) {
|
|
return;
|
|
}
|
|
|
|
let payload: TDeleteUserRequest | undefined;
|
|
if (needs2FA && otpToken.trim()) {
|
|
payload = useBackup ? { backupCode: otpToken.trim() } : { token: otpToken.trim() };
|
|
}
|
|
|
|
deleteUser(payload);
|
|
};
|
|
|
|
const handleInputChange = useCallback(
|
|
(newEmailInput: string) => {
|
|
const isEmailCorrect =
|
|
newEmailInput.trim().toLowerCase() === user?.email.trim().toLowerCase();
|
|
setIsLocked(!isEmailCorrect);
|
|
},
|
|
[user?.email],
|
|
);
|
|
|
|
const otpReady = !needs2FA || otpToken.length === (useBackup ? 8 : 6);
|
|
|
|
return (
|
|
<>
|
|
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
|
<div className="flex items-center justify-between">
|
|
<Label id="delete-account-label">{localize('com_nav_delete_account')}</Label>
|
|
<OGDialogTrigger asChild>
|
|
<Button
|
|
aria-labelledby="delete-account-label"
|
|
variant="destructive"
|
|
onClick={() => setDialogOpen(true)}
|
|
disabled={disabled}
|
|
>
|
|
{localize('com_ui_delete')}
|
|
</Button>
|
|
</OGDialogTrigger>
|
|
</div>
|
|
<OGDialogContent className="w-11/12 max-w-md">
|
|
<OGDialogHeader>
|
|
<OGDialogTitle className="text-lg font-medium leading-6">
|
|
{localize('com_nav_delete_account_confirm')}
|
|
</OGDialogTitle>
|
|
</OGDialogHeader>
|
|
<div className="mb-8 text-sm text-black dark:text-white">
|
|
<ul className="font-semibold text-amber-600">
|
|
<li>{localize('com_nav_delete_warning')}</li>
|
|
<li>{localize('com_nav_delete_data_info')}</li>
|
|
</ul>
|
|
</div>
|
|
<div className="flex-col items-center justify-center">
|
|
<div className="mb-4">
|
|
{renderInput(
|
|
localize('com_nav_delete_account_email_placeholder'),
|
|
'email-confirm-input',
|
|
user?.email ?? '',
|
|
(e) => handleInputChange(e.target.value),
|
|
)}
|
|
</div>
|
|
{needs2FA && (
|
|
<div className="mb-4 space-y-3">
|
|
<Label className="text-sm font-medium">
|
|
{localize('com_ui_2fa_verification_required')}
|
|
</Label>
|
|
<div className="flex justify-center">
|
|
<InputOTP
|
|
value={otpToken}
|
|
onChange={setOtpToken}
|
|
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
|
|
type="button"
|
|
onClick={() => {
|
|
setUseBackup(!useBackup);
|
|
setOtpToken('');
|
|
}}
|
|
className="text-sm text-primary hover:underline"
|
|
>
|
|
{useBackup ? localize('com_ui_use_2fa_code') : localize('com_ui_use_backup_code')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{renderDeleteButton(handleDeleteUser, isDeleting, isLocked || !otpReady, localize)}
|
|
</div>
|
|
</OGDialogContent>
|
|
</OGDialog>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderInput = (
|
|
label: string,
|
|
id: string,
|
|
value: string,
|
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
|
|
) => (
|
|
<div className="mb-4">
|
|
<label className="mb-1 block text-sm font-medium text-black dark:text-white" htmlFor={id}>
|
|
{label}
|
|
</label>
|
|
<Input id={id} onChange={onChange} placeholder={value} />
|
|
</div>
|
|
);
|
|
|
|
const renderDeleteButton = (
|
|
handleDeleteUser: () => void,
|
|
isDeleting: boolean,
|
|
isLocked: boolean,
|
|
localize: LocalizeFunction,
|
|
) => (
|
|
<button
|
|
className={cn(
|
|
'mt-4 flex w-full items-center justify-center rounded-lg bg-surface-tertiary px-4 py-2 transition-all duration-200',
|
|
isLocked ? 'cursor-not-allowed opacity-30' : 'bg-destructive text-destructive-foreground',
|
|
)}
|
|
onClick={handleDeleteUser}
|
|
disabled={isDeleting || isLocked}
|
|
>
|
|
{isDeleting ? (
|
|
<div className="flex h-6 justify-center">
|
|
<Spinner className="icon-sm m-auto" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{isLocked ? (
|
|
<>
|
|
<LockIcon className="size-5" aria-hidden="true" />
|
|
<span className="ml-2">{localize('com_ui_locked')}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Trash className="size-5" aria-hidden="true" />
|
|
<span className="ml-2">{localize('com_nav_delete_account_button')}</span>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
|
|
export default DeleteAccount;
|