Improve personal settings 2FA form

This commit is contained in:
Carlos Monastyrski
2025-09-26 14:31:06 -03:00
parent 832b5f0f49
commit 52b82613e1
2 changed files with 398 additions and 114 deletions
+3 -2
View File
@@ -508,7 +508,7 @@ export const useListUserGroupMemberships = (username: string) => {
}); });
}; };
export const useGetUserTotpRegistration = () => { export const useGetUserTotpRegistration = (options?: { enabled?: boolean }) => {
return useQuery({ return useQuery({
queryKey: userKeys.totpRegistration, queryKey: userKeys.totpRegistration,
queryFn: async () => { queryFn: async () => {
@@ -517,7 +517,8 @@ export const useGetUserTotpRegistration = () => {
); );
return data; return data;
} },
enabled: options?.enabled ?? true
}); });
}; };
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import QRCode from "qrcode";
import TotpRegistration from "@app/components/mfa/TotpRegistration"; import { RecoveryCodesDownload } from "@app/components/mfa/RecoveryCodesDownload";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
Button, Button,
@@ -8,9 +10,9 @@ import {
DeleteActionModal, DeleteActionModal,
EmailServiceSetupModal, EmailServiceSetupModal,
FormControl, FormControl,
Input,
Select, Select,
SelectItem, SelectItem
Switch
} from "@app/components/v2"; } from "@app/components/v2";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { useGetUser, userKeys, useUpdateUserMfa } from "@app/hooks/api"; import { useGetUser, userKeys, useUpdateUserMfa } from "@app/hooks/api";
@@ -18,9 +20,13 @@ import { MfaMethod } from "@app/hooks/api/auth/types";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { import {
useCreateNewTotpRecoveryCodes, useCreateNewTotpRecoveryCodes,
useDeleteUserTotpConfiguration useDeleteUserTotpConfiguration,
useVerifyUserTotpRegistration
} from "@app/hooks/api/users/mutation"; } from "@app/hooks/api/users/mutation";
import { useGetUserTotpConfiguration } from "@app/hooks/api/users/queries"; import {
useGetUserTotpConfiguration,
useGetUserTotpRegistration
} from "@app/hooks/api/users/queries";
import { AuthMethod } from "@app/hooks/api/users/types"; import { AuthMethod } from "@app/hooks/api/users/types";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
@@ -28,24 +34,65 @@ export const MFASection = () => {
const { data: user } = useGetUser(); const { data: user } = useGetUser();
const { mutateAsync } = useUpdateUserMfa(); const { mutateAsync } = useUpdateUserMfa();
const [formData, setFormData] = useState({
isMfaEnabled: user?.isMfaEnabled || false,
selectedMfaMethod: user?.selectedMfaMethod || MfaMethod.EMAIL
});
const [isLoading, setIsLoading] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [qrCodeUrl, setQrCodeUrl] = useState("");
const [showMobileAuthSetup, setShowMobileAuthSetup] = useState(false);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"setUpEmail", "setUpEmail",
"deleteTotpConfig" "deleteTotpConfig",
"downloadRecoveryCodes"
] as const); ] as const);
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle(); const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
const { data: totpConfiguration, isPending: isTotpConfigurationLoading } = const { data: totpConfiguration } = useGetUserTotpConfiguration();
useGetUserTotpConfiguration(); const { data: totpRegistration, isPending: isTotpRegistrationLoading } =
useGetUserTotpRegistration({
enabled: showMobileAuthSetup
});
const { mutateAsync: deleteTotpConfiguration } = useDeleteUserTotpConfiguration(); const { mutateAsync: deleteTotpConfiguration } = useDeleteUserTotpConfiguration();
const { mutateAsync: createTotpRecoveryCodes } = useCreateNewTotpRecoveryCodes(); const { mutateAsync: createTotpRecoveryCodes } = useCreateNewTotpRecoveryCodes();
const { mutateAsync: verifyUserTotp } = useVerifyUserTotpRegistration();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: serverDetails } = useFetchServerStatus(); const { data: serverDetails } = useFetchServerStatus();
// Update form data when user data changes
useEffect(() => {
if (user) {
setFormData({
isMfaEnabled: user.isMfaEnabled,
selectedMfaMethod: user.selectedMfaMethod || MfaMethod.EMAIL
});
}
}, [user]);
useEffect(() => {
const generateQRCode = async () => {
if (totpRegistration?.otpUrl) {
const url = await QRCode.toDataURL(totpRegistration.otpUrl);
setQrCodeUrl(url);
}
};
if (showMobileAuthSetup && totpRegistration?.otpUrl) {
generateQRCode();
}
}, [totpRegistration, showMobileAuthSetup]);
const handleTotpDeletion = async () => { const handleTotpDeletion = async () => {
try { try {
await deleteTotpConfiguration(); await deleteTotpConfiguration();
await mutateAsync({
selectedMfaMethod: MfaMethod.EMAIL
});
createNotification({ createNotification({
text: "Successfully deleted mobile authenticator", text: "Successfully deleted mobile authenticator and switched to email authentication",
type: "success" type: "success"
}); });
@@ -82,30 +129,35 @@ export const MFASection = () => {
} }
}; };
const updateSelectedMfa = async (mfaMethod: MfaMethod) => { const handleFormDataChange = (field: string, value: any) => {
try { setFormData((prev) => ({
if (!user) return; ...prev,
[field]: value
}));
await mutateAsync({ // Show mobile auth setup when mobile authenticator is selected and we're enabling 2FA
selectedMfaMethod: mfaMethod if (field === "selectedMfaMethod" && value === MfaMethod.TOTP && formData.isMfaEnabled) {
}); setShowMobileAuthSetup(true);
} else if (field === "selectedMfaMethod" && value !== MfaMethod.TOTP) {
createNotification({ setShowMobileAuthSetup(false);
text: "Successfully updated selected 2FA method", setTotpCode("");
type: "success" setShouldShowRecoveryCodes.off();
}); if (totpConfiguration?.isVerified) {
} catch (err) { deleteTotpConfiguration().catch(console.error);
createNotification({ }
text: "Something went wrong while updating selected 2FA method.", } else if (field === "isMfaEnabled" && value && formData.selectedMfaMethod === MfaMethod.TOTP) {
type: "error" setShowMobileAuthSetup(true);
}); } else if (field === "isMfaEnabled" && !value) {
console.error(err); setShowMobileAuthSetup(false);
setTotpCode("");
setShouldShowRecoveryCodes.off();
} }
}; };
const toggleMfa = async (state: boolean) => { const handleSaveChanges = async () => {
try { try {
if (!user) return; if (!user) return;
if (user.authMethods.includes(AuthMethod.LDAP)) { if (user.authMethods.includes(AuthMethod.LDAP)) {
createNotification({ createNotification({
text: "Two-factor authentication is not available for LDAP users.", text: "Two-factor authentication is not available for LDAP users.",
@@ -114,119 +166,350 @@ export const MFASection = () => {
return; return;
} }
const newUser = await mutateAsync({ if (!serverDetails?.emailConfigured && formData.isMfaEnabled) {
isMfaEnabled: state handlePopUpOpen("setUpEmail");
}); return;
}
createNotification({ setIsLoading(true);
text: `${
newUser.isMfaEnabled // If enabling 2FA with mobile authenticator, verify TOTP first
? "Successfully turned on two-factor authentication." if (
: "Successfully turned off two-factor authentication." formData.isMfaEnabled &&
}`, formData.selectedMfaMethod === MfaMethod.TOTP &&
type: "success" !totpConfiguration?.isVerified
}); ) {
if (!totpCode.trim()) {
createNotification({
text: "Please enter the verification code from your authenticator app",
type: "error"
});
setIsLoading(false);
return;
}
try {
await verifyUserTotp({ totp: totpCode });
handlePopUpOpen("downloadRecoveryCodes");
createNotification({
text: "Successfully configured mobile authenticator. Please save your recovery codes!",
type: "success"
});
await queryClient.invalidateQueries({ queryKey: userKeys.totpConfiguration });
} catch {
createNotification({
text: "Failed to verify TOTP code. Please try again.",
type: "error"
});
setIsLoading(false);
return;
}
}
// If disabling 2FA and there's a TOTP configuration, delete it
if (!formData.isMfaEnabled && user.isMfaEnabled && totpConfiguration?.isVerified) {
try {
await deleteTotpConfiguration();
createNotification({
text: "Mobile authenticator removed",
type: "success"
});
// Refresh TOTP configuration
await queryClient.invalidateQueries({ queryKey: userKeys.totpConfiguration });
} catch {
// Continue with disabling 2FA even if TOTP deletion fails
}
}
const updates: any = {};
// Only update if values have changed
if (formData.isMfaEnabled !== user.isMfaEnabled) {
updates.isMfaEnabled = formData.isMfaEnabled;
}
if (formData.selectedMfaMethod !== user.selectedMfaMethod) {
updates.selectedMfaMethod = formData.selectedMfaMethod;
}
if (Object.keys(updates).length > 0) {
await mutateAsync(updates);
createNotification({
text: "Successfully updated two-factor authentication settings",
type: "success"
});
}
// Reset form state
setShowMobileAuthSetup(false);
setTotpCode("");
setShouldShowRecoveryCodes.off();
} catch (err) { } catch (err) {
createNotification({ createNotification({
text: "Something went wrong while toggling the two-factor authentication.", text: "Something went wrong while updating two-factor authentication settings.",
type: "error" type: "error"
}); });
console.error(err); console.error(err);
} finally {
setIsLoading(false);
} }
}; };
const hasChanges =
user &&
(formData.isMfaEnabled !== user.isMfaEnabled ||
formData.selectedMfaMethod !== user.selectedMfaMethod);
const isFormValid = () => {
if (!formData.isMfaEnabled) return true;
if (formData.selectedMfaMethod === MfaMethod.EMAIL) return true;
if (formData.selectedMfaMethod === MfaMethod.TOTP) {
if (totpConfiguration?.isVerified) return true;
return totpCode.trim().length > 0;
}
return false;
};
return ( return (
<> <>
<div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <form
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p> onSubmit={(e) => {
e.preventDefault();
handleSaveChanges();
}}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<h2 className="mb-6 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</h2>
{user && ( {user && (
<Switch <div className="space-y-4">
className="data-[state=checked]:bg-primary" <div className="max-w-md">
id="isTwoFAEnabled" <FormControl
isChecked={user?.isMfaEnabled} label="Enable 2-factor authentication"
onCheckedChange={(state) => { helperText="Adds an extra layer of security to your account"
if (serverDetails?.emailConfigured) { >
toggleMfa(state as boolean); <Select
} else { value={formData.isMfaEnabled ? "enabled" : "disabled"}
handlePopUpOpen("setUpEmail"); onValueChange={(value) =>
} handleFormDataChange("isMfaEnabled", value === "enabled")
}} }
> className="w-full"
Enable 2-factor authentication >
</Switch> <SelectItem value="disabled">Disabled</SelectItem>
)} <SelectItem value="enabled">Enabled</SelectItem>
{user?.isMfaEnabled && ( </Select>
<FormControl label="Selected 2FA method" className="mt-3"> </FormControl>
<Select </div>
className="min-w-[20rem] border border-mineshaft-500"
onValueChange={updateSelectedMfa} {formData.isMfaEnabled && (
defaultValue={user.selectedMfaMethod ?? MfaMethod.EMAIL} <div className="max-w-md">
> <FormControl
<SelectItem value={MfaMethod.EMAIL} key="mfa-method-email"> label="Authentication method"
Email helperText="Choose your preferred method for two-factor authentication"
</SelectItem> >
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp"> <Select
Mobile Authenticator value={formData.selectedMfaMethod}
</SelectItem> onValueChange={(value) =>
</Select> handleFormDataChange("selectedMfaMethod", value as MfaMethod)
</FormControl> }
)} className="w-full"
<div className="mt-8 text-lg font-semibold text-mineshaft-100">Mobile Authenticator</div> >
{isTotpConfigurationLoading ? ( <SelectItem value={MfaMethod.EMAIL}>Email</SelectItem>
<ContentLoader /> <SelectItem value={MfaMethod.TOTP}>Mobile Authenticator</SelectItem>
) : ( </Select>
<div> </FormControl>
{totpConfiguration?.isVerified ? (
<div className="mt-2">
<div className="flex flex-row gap-2">
<Button colorSchema="secondary" onClick={setShouldShowRecoveryCodes.toggle}>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button colorSchema="secondary" onClick={handleGenerateMoreRecoveryCodes}>
Generate more codes
</Button>
<Button colorSchema="danger" onClick={() => handlePopUpOpen("deleteTotpConfig")}>
Delete
</Button>
</div>
{shouldShowRecoveryCodes && totpConfiguration.recoveryCodes && (
<div className="mt-4 bg-mineshaft-600 p-4">
{totpConfiguration.recoveryCodes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
)}
</div> </div>
) : ( )}
<>
<div className="text-sm text-gray-400"> {showMobileAuthSetup && !totpConfiguration?.isVerified && (
For added security, you can configure a mobile authenticator and set it as your <div className="space-y-6">
selected 2FA method. <h3 className="mb-6 text-lg font-semibold text-mineshaft-100">
Setup Mobile Authenticator
</h3>
<div className="mb-8">
<h4 className="mb-2 text-sm font-medium text-mineshaft-200">
Step 1: Scan QR Code
</h4>
<p className="mb-4 text-sm text-mineshaft-300">
Download a two-factor authentication app (Google Authenticator, Authy, etc.) and
scan the QR code below
</p>
<div>
{isTotpRegistrationLoading && (
<div className="py-12">
<ContentLoader />
</div>
)}
{!isTotpRegistrationLoading && qrCodeUrl && (
<div>
<div className="mb-4 w-72 pl-4">
<div className="inline-flex items-center justify-center rounded-xl bg-white p-6 shadow-lg">
<img
src={qrCodeUrl}
alt="QR Code for mobile authenticator setup"
className="h-48 w-48"
/>
</div>
</div>
{totpRegistration?.otpUrl && (
<div>
<p className="mb-2 text-xs text-mineshaft-400">
Can&apos;t scan? Enter this code manually:{" "}
<code className="rounded bg-mineshaft-700 px-3 py-1 font-mono text-sm text-mineshaft-100">
{totpRegistration.otpUrl.split("secret=")[1]?.split("&")[0] ||
"Loading..."}
</code>
</p>
</div>
)}
</div>
)}
</div>
</div> </div>
<div className="ml-6 mt-6 flex min-w-full">
<TotpRegistration <div>
onComplete={async () => { <h4 className="mb-2 text-sm font-medium text-mineshaft-200">
await queryClient.invalidateQueries({ queryKey: userKeys.totpConfiguration }); Step 2: Enter verification code
}} </h4>
/> <p className="mb-4 text-sm text-mineshaft-300">
Enter the 6-digit code from your authenticator app to complete setup
</p>
<div className="max-w-48">
<FormControl isRequired>
<Input
value={totpCode}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
setTotpCode(value);
}}
onPaste={(e) => {
e.preventDefault();
const pastedData = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, 6);
setTotpCode(pastedData);
}}
placeholder="Enter 2FA code"
className="font-mono tracking-wider"
maxLength={6}
/>
</FormControl>
</div>
</div> </div>
</> </div>
)}
{hasChanges && (
<div className="flex gap-2 pt-4">
<Button
type="submit"
isLoading={isLoading}
disabled={!isFormValid()}
colorSchema="primary"
variant="outline_bg"
>
Save Changes
</Button>
<Button
type="button"
variant="outline_bg"
onClick={() => {
if (user) {
setFormData({
isMfaEnabled: user.isMfaEnabled,
selectedMfaMethod: user.selectedMfaMethod || MfaMethod.EMAIL
});
}
setShowMobileAuthSetup(false);
setTotpCode("");
setShouldShowRecoveryCodes.off();
}}
disabled={isLoading}
className="border-mineshaft-500 text-mineshaft-300 hover:border-mineshaft-400"
>
Cancel
</Button>
</div>
)}
{user?.isMfaEnabled && totpConfiguration?.isVerified && (
<div className="mt-8 border-t border-mineshaft-600 pt-6">
<h3 className="mb-4 text-lg font-semibold text-mineshaft-100">
Mobile Authenticator Management
</h3>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={setShouldShowRecoveryCodes.toggle}
>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={handleGenerateMoreRecoveryCodes}
>
Generate more codes
</Button>
<Button
colorSchema="danger"
variant="outline_bg"
onClick={() => handlePopUpOpen("deleteTotpConfig")}
>
Remove Authenticator
</Button>
</div>
{shouldShowRecoveryCodes && (
<div className="mt-6 grid w-full grid-cols-4 gap-3">
{totpConfiguration.recoveryCodes.map((code) => (
<code
key={code}
className="block rounded bg-mineshaft-800 px-3 py-2 text-center font-mono text-sm text-mineshaft-100"
>
{code}
</code>
))}
</div>
)}
</div>
</div>
)} )}
</div> </div>
)} )}
</div> </form>
<EmailServiceSetupModal <EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen} isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/> />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteTotpConfig.isOpen} isOpen={popUp.deleteTotpConfig.isOpen}
title="Are you sure you want to delete the configured authenticator?" title="Remove mobile authenticator?"
subTitle="This action is irreversible. You’ll have to go through the setup process to enable it again." subTitle="This action is irreversible. You'll have to go through the setup process to enable it again."
onChange={(isOpen) => handlePopUpToggle("deleteTotpConfig", isOpen)} onChange={(isOpen) => handlePopUpToggle("deleteTotpConfig", isOpen)}
deleteKey="confirm" deleteKey="confirm"
onDeleteApproved={handleTotpDeletion} onDeleteApproved={handleTotpDeletion}
/> />
<RecoveryCodesDownload
isOpen={popUp.downloadRecoveryCodes?.isOpen || false}
onClose={() => handlePopUpClose("downloadRecoveryCodes")}
recoveryCodes={totpRegistration?.recoveryCodes || []}
onDownloadComplete={() => handlePopUpClose("downloadRecoveryCodes")}
/>
</> </>
); );
}; };