This commit is contained in:
2025-02-12 13:39:14 +08:00
parent 44ab95b64d
commit e0788cbeb5
10 changed files with 676 additions and 8 deletions

View File

@@ -1,12 +1,36 @@
import { Input, Checkbox, Button, Link } from "@heroui/react";
import {
Input,
Checkbox,
Button,
Link,
useDisclosure,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@heroui/react";
import { IconMail, IconLock } from "@tabler/icons-react";
import { useState } from "react";
import { toast } from "react-toastify";
import http, { login } from "../http";
import TwoFactorAuthenticationModule from "./TwoFactorAuthenticationModule";
export const checkTwoFactorStatus = async (email: string) => {
try {
const answer = await http.post("/User/has-2fa", {
email: email,
});
return answer.data.enabled as boolean;
} catch {
return false;
}
};
export default function LoginView({ onSignup }: { onSignup: () => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const validateFields = () => {
if (!email || !password) {
@@ -16,9 +40,22 @@ export default function LoginView({ onSignup }: { onSignup: () => void }) {
return true;
};
const handleLogin = async () => {
const checkFor2FA = async () => {
if (!validateFields()) return;
checkTwoFactorStatus(email)
.then((answer) => {
if (answer) {
onOpen();
} else {
handleLogin();
}
})
.catch(() => {
toast.error("Something went wrong! Please try again.");
});
};
const handleLogin = async () => {
const loginRequest = {
email,
password,
@@ -79,7 +116,7 @@ export default function LoginView({ onSignup }: { onSignup: () => void }) {
</Link>
</div>
<div className="flex flex-col gap-4 w-full">
<Button color="primary" className="w-full" onPress={handleLogin}>
<Button color="primary" className="w-full" onPress={checkFor2FA}>
Login
</Button>
<div className="flex flex-row gap-2 w-full justify-center *:my-auto">
@@ -89,6 +126,18 @@ export default function LoginView({ onSignup }: { onSignup: () => void }) {
</Link>
</div>
</div>
<Modal size="lg" isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>Two-Factor Authentication</ModalHeader>
<ModalBody>
<TwoFactorAuthenticationModule
email={email}
onTwoFactorSuccess={handleLogin}
/>
</ModalBody>
<ModalFooter></ModalFooter>
</ModalContent>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import { Button, Image, Input } from "@heroui/react";
import TwoFactorAuthenticationModule from "./TwoFactorAuthenticationModule";
import { checkTwoFactorStatus } from "./LoginView";
import http from "../http";
import { toast } from "react-toastify";
import { UserProfile } from "../models/user-profile";
export default function Manage2FAView({
onClose,
userProfile,
}: {
onClose: () => void;
userProfile: UserProfile;
}) {
const [setup2FAStepperCount, setSetup2FAStepperCount] = useState(0);
const [disable2FAStepperCount, setDisable2FAStepperCount] = useState(0);
const [isTwoFactorEnabled, setIsTwoFactorEnabled] = useState(false);
const [setupQRBase64, setSetupQRBase64] = useState("");
const [setupBase32Secret, setSetupBase32Secret] = useState("");
const [userPassword, setUserPassword] = useState("");
const disableTwoFactor = async () => {
http
.post("/User/disable-2fa", {
id: userProfile.id,
})
.then(() => {
setDisable2FAStepperCount(2);
});
};
const verifyAccount = () => {
if (userPassword.length === 0) {
return;
}
http
.post("/User/login", {
verify: true,
email: userProfile.email,
password: userPassword,
})
.then(() => {
disableTwoFactor();
})
.catch(() => {
toast.error("Invalid password");
});
};
const enableTwoFactor = () => {
http.post("/User/enable-2fa").then((response) => {
setSetupQRBase64(response.data.qrCodeUrl);
setSetupBase32Secret(response.data.secret);
setSetup2FAStepperCount(1);
});
};
useEffect(() => {
checkTwoFactorStatus(userProfile.email)
.then((answer) => {
setIsTwoFactorEnabled(answer);
})
.catch(() => {
toast.error("Something went wrong. Please try again.");
});
}, []);
return (
<div>
{userProfile && (
<>
{!isTwoFactorEnabled && (
<div>
{setup2FAStepperCount === 0 && (
<div className="flex flex-col gap-4">
<p>
This setup will guide you through the enabling of
Two-Factors Authorization (2FA).
</p>
<Button
onPress={() => {
enableTwoFactor();
}}
>
Continue
</Button>
</div>
)}
{setup2FAStepperCount === 1 && (
<div className="flex flex-col gap-4">
<p>
Please scan the QR code below using an authenticator app of
your choice.
</p>
{setupQRBase64 && (
<Image
className="shadow-medium"
src={setupQRBase64}
alt="2FA SETUP QR"
/>
)}
<p>Or alternatively, manually enter the secret in the app:</p>
<Input value={setupBase32Secret} readOnly />
<div className="w-full flex flex-row justify-end">
<Button
onPress={() => {
setSetup2FAStepperCount(2);
}}
>
Continue
</Button>
</div>
</div>
)}
{setup2FAStepperCount === 2 && (
<div className="flex flex-col gap-4">
<p>Let's give it a try.</p>
<TwoFactorAuthenticationModule
email={userProfile.email}
onTwoFactorSuccess={() => {
setSetup2FAStepperCount(3);
}}
/>
<div className="w-full flex flex-row justify-end">
<Button
variant="light"
onPress={() => {
setSetup2FAStepperCount(3);
}}
>
Skip
</Button>
<Button
onPress={() => {
enableTwoFactor();
}}
>
Setup again
</Button>
</div>
</div>
)}
{setup2FAStepperCount === 3 && (
<div className="flex flex-col gap-4">
<p>
All set! You will be asked to provide the passcode next time
you log in.
</p>
<div className="w-full flex flex-row justify-end">
<Button onPress={onClose}>Finish</Button>
</div>
</div>
)}
</div>
)}
{isTwoFactorEnabled && (
<div>
{disable2FAStepperCount === 0 && (
<div className="flex flex-col gap-4 w-full">
<p>
Are you sure you want to disable Two-Factors Authorization
(2FA)?
</p>
<div className="flex flex-row gap-2 w-full justify-end">
<Button
variant="light"
color="danger"
onPress={() => {
setDisable2FAStepperCount(1);
}}
>
Confirm
</Button>
<Button color="primary" onPress={onClose}>
Cancel
</Button>
</div>
</div>
)}
{disable2FAStepperCount === 1 && (
<div className="flex flex-col gap-4 w-full">
<p>Let's verify that it's you.</p>
<Input
type="password"
label="Password"
value={userPassword}
onValueChange={setUserPassword}
/>
<div className="w-full flex flex-row justify-end">
<Button
onPress={() => {
verifyAccount();
}}
>
Continue
</Button>
</div>
</div>
)}
{disable2FAStepperCount === 2 && (
<div className="flex flex-col gap-4 w-full">
<p>2FA has been disabled.</p>
<div className="w-full flex flex-row justify-end">
<Button onPress={onClose}>Finish</Button>
</div>
</div>
)}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import http from "../http";
import { toast } from "react-toastify";
import { InputOtp } from "@heroui/react";
export default function TwoFactorsAuthenticationModule({
email,
onTwoFactorSuccess,
}: {
email: string;
onTwoFactorSuccess: () => void;
}) {
const [twoFactorToken, setTwoFactorToken] = useState("");
const [twoFactorVerifying, setTwoFactorVerifying] = useState(false);
useEffect(() => {
if (!(twoFactorToken.length == 6 && !twoFactorVerifying)) {
return;
}
setTwoFactorVerifying(true);
http
.post("/User/verify-2fa", {
email: email,
token: twoFactorToken,
})
.then(() => {
onTwoFactorSuccess();
})
.catch((error) => {
toast.error(error);
})
.finally(() => {
setTwoFactorToken("");
setTwoFactorVerifying(false);
});
}, [twoFactorToken]);
return (
<div className="text-center flex flex-col gap-4 w-full *:mx-auto">
<InputOtp
length={6}
value={twoFactorToken}
onValueChange={setTwoFactorToken}
size="lg"
isDisabled={twoFactorVerifying}
/>
<p className="text-sm opacity-50">
Please enter the 6 digits passcode from your authenticator app.
</p>
</div>
);
}

View File

@@ -19,11 +19,22 @@ import remarkGfm from "remark-gfm";
import { IconDownload, IconEdit, IconUpload } from "@tabler/icons-react";
import { toast } from "react-toastify";
import ChangePasswordView from "../components/ChangePasswordView";
import Manage2FAView from "../components/Manage2FAView";
export default function MemberPage() {
const accessToken = getAccessToken();
const navigate = useNavigate();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const {
isOpen: changePasswordModalIsOpen,
onOpen: changePasswordModalOnOpen,
onOpenChange: changePasswordModalOnOpenChange,
} = useDisclosure();
const {
isOpen: twoFactorAuthModalIsOpen,
onOpen: twoFactorAuthModalOnOpen,
onOpenChange: twoFactorAuthModalOnOpenChange,
} = useDisclosure();
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
@@ -185,13 +196,29 @@ export default function MemberPage() {
<Button variant="light" color="danger" onPress={logout}>
Log out
</Button>
<Button variant="light" color="primary" onPress={onOpen}>
Change password
</Button>
<div className="flex flex-row gap-2">
<Button
variant="light"
color="primary"
onPress={twoFactorAuthModalOnOpen}
>
Manage 2FA
</Button>
<Button
variant="light"
color="primary"
onPress={changePasswordModalOnOpen}
>
Change password
</Button>
</div>
</div>
</Card>
)}
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<Modal
isOpen={changePasswordModalIsOpen}
onOpenChange={changePasswordModalOnOpenChange}
>
<ModalContent>
{(onClose) => (
<>
@@ -204,6 +231,28 @@ export default function MemberPage() {
)}
</ModalContent>
</Modal>
<Modal
isOpen={twoFactorAuthModalIsOpen}
onOpenChange={twoFactorAuthModalOnOpen}
>
<ModalContent>
{(on2FAClose) => (
<>
<ModalHeader />
<ModalBody>
{userProfile && (
<Manage2FAView
onClose={on2FAClose}
userProfile={userProfile}
/>
)}
</ModalBody>
<ModalFooter />
</>
)}
</ModalContent>
</Modal>
</div>
);
}