Enable2FaForm.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. "use client";
  2. import { useState } from "react";
  3. import { Button } from "@/components/ui/button";
  4. import { Input } from "@/components/ui/input";
  5. import { Label } from "@/components/ui/label";
  6. import { AlertCircle, CheckCircle2 } from "lucide-react";
  7. import { createApiClient } from "@app/api";
  8. import { useEnvContext } from "@app/hooks/useEnvContext";
  9. import { AxiosResponse } from "axios";
  10. import {
  11. RequestTotpSecretBody,
  12. RequestTotpSecretResponse,
  13. VerifyTotpBody,
  14. VerifyTotpResponse
  15. } from "@server/routers/auth";
  16. import { z } from "zod";
  17. import { useForm } from "react-hook-form";
  18. import { zodResolver } from "@hookform/resolvers/zod";
  19. import {
  20. Form,
  21. FormControl,
  22. FormField,
  23. FormItem,
  24. FormLabel,
  25. FormMessage
  26. } from "@app/components/ui/form";
  27. import {
  28. Credenza,
  29. CredenzaBody,
  30. CredenzaClose,
  31. CredenzaContent,
  32. CredenzaDescription,
  33. CredenzaFooter,
  34. CredenzaHeader,
  35. CredenzaTitle
  36. } from "@app/components/Credenza";
  37. import { useToast } from "@app/hooks/useToast";
  38. import { formatAxiosError } from "@app/lib/utils";
  39. import CopyTextBox from "@app/components/CopyTextBox";
  40. import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
  41. import { useUserContext } from "@app/hooks/useUserContext";
  42. const enableSchema = z.object({
  43. password: z.string().min(1, { message: "Password is required" })
  44. });
  45. const confirmSchema = z.object({
  46. code: z.string().length(6, { message: "Invalid code" })
  47. });
  48. type Enable2FaProps = {
  49. open: boolean;
  50. setOpen: (val: boolean) => void;
  51. };
  52. export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
  53. const [step, setStep] = useState(1);
  54. const [secretKey, setSecretKey] = useState("");
  55. const [secretUri, setSecretUri] = useState("");
  56. const [verificationCode, setVerificationCode] = useState("");
  57. const [error, setError] = useState("");
  58. const [success, setSuccess] = useState(false);
  59. const [loading, setLoading] = useState(false);
  60. const [backupCodes, setBackupCodes] = useState<string[]>([]);
  61. const { toast } = useToast();
  62. const { user, updateUser } = useUserContext();
  63. const api = createApiClient(useEnvContext());
  64. const enableForm = useForm<z.infer<typeof enableSchema>>({
  65. resolver: zodResolver(enableSchema),
  66. defaultValues: {
  67. password: ""
  68. }
  69. });
  70. const confirmForm = useForm<z.infer<typeof confirmSchema>>({
  71. resolver: zodResolver(confirmSchema),
  72. defaultValues: {
  73. code: ""
  74. }
  75. });
  76. const request2fa = async (values: z.infer<typeof enableSchema>) => {
  77. setLoading(true);
  78. const res = await api
  79. .post<AxiosResponse<RequestTotpSecretResponse>>(
  80. `/auth/2fa/request`,
  81. {
  82. password: values.password
  83. } as RequestTotpSecretBody
  84. )
  85. .catch((e) => {
  86. toast({
  87. title: "Unable to enable 2FA",
  88. description: formatAxiosError(
  89. e,
  90. "An error occurred while enabling 2FA"
  91. ),
  92. variant: "destructive"
  93. });
  94. });
  95. if (res && res.data.data.secret) {
  96. setSecretKey(res.data.data.secret);
  97. setSecretUri(res.data.data.uri);
  98. setStep(2);
  99. }
  100. setLoading(false);
  101. };
  102. const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
  103. setLoading(true);
  104. const res = await api
  105. .post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/enable`, {
  106. code: values.code
  107. } as VerifyTotpBody)
  108. .catch((e) => {
  109. toast({
  110. title: "Unable to enable 2FA",
  111. description: formatAxiosError(
  112. e,
  113. "An error occurred while enabling 2FA"
  114. ),
  115. variant: "destructive"
  116. });
  117. });
  118. if (res && res.data.data.valid) {
  119. setBackupCodes(res.data.data.backupCodes || []);
  120. updateUser({ twoFactorEnabled: true });
  121. setStep(3);
  122. }
  123. setLoading(false);
  124. };
  125. const handleVerify = () => {
  126. if (verificationCode.length !== 6) {
  127. setError("Please enter a 6-digit code");
  128. return;
  129. }
  130. if (verificationCode === "123456") {
  131. setSuccess(true);
  132. setStep(3);
  133. } else {
  134. setError("Invalid code. Please try again.");
  135. }
  136. };
  137. function reset() {
  138. setLoading(false);
  139. setStep(1);
  140. setSecretKey("");
  141. setSecretUri("");
  142. setVerificationCode("");
  143. setError("");
  144. setSuccess(false);
  145. setBackupCodes([]);
  146. enableForm.reset();
  147. confirmForm.reset();
  148. }
  149. return (
  150. <Credenza
  151. open={open}
  152. onOpenChange={(val) => {
  153. setOpen(val);
  154. reset();
  155. }}
  156. >
  157. <CredenzaContent>
  158. <CredenzaHeader>
  159. <CredenzaTitle>
  160. Enable Two-factor Authentication
  161. </CredenzaTitle>
  162. <CredenzaDescription>
  163. Secure your account with an extra layer of protection
  164. </CredenzaDescription>
  165. </CredenzaHeader>
  166. <CredenzaBody>
  167. {step === 1 && (
  168. <Form {...enableForm}>
  169. <form
  170. onSubmit={enableForm.handleSubmit(request2fa)}
  171. className="space-y-4"
  172. id="form"
  173. >
  174. <div className="space-y-4">
  175. <FormField
  176. control={enableForm.control}
  177. name="password"
  178. render={({ field }) => (
  179. <FormItem>
  180. <FormLabel>Password</FormLabel>
  181. <FormControl>
  182. <Input
  183. type="password"
  184. placeholder="Enter your password"
  185. {...field}
  186. />
  187. </FormControl>
  188. <FormMessage />
  189. </FormItem>
  190. )}
  191. />
  192. </div>
  193. </form>
  194. </Form>
  195. )}
  196. {step === 2 && (
  197. <div className="space-y-4">
  198. <p>
  199. Scan this QR code with your authenticator app or
  200. enter the secret key manually:
  201. </p>
  202. <div className="h-[250px] mx-auto flex items-center justify-center">
  203. <QRCodeCanvas value={secretUri} size={200} />
  204. </div>
  205. <CopyTextBox text={secretKey} wrapText={false} />
  206. <Form {...confirmForm}>
  207. <form
  208. onSubmit={confirmForm.handleSubmit(
  209. confirm2fa
  210. )}
  211. className="space-y-4"
  212. id="form"
  213. >
  214. <div className="space-y-4">
  215. <FormField
  216. control={confirmForm.control}
  217. name="code"
  218. render={({ field }) => (
  219. <FormItem>
  220. <FormLabel>
  221. Authenticator Code
  222. </FormLabel>
  223. <FormControl>
  224. <Input
  225. type="code"
  226. placeholder="Enter the 6-digit code from your authenticator app"
  227. {...field}
  228. />
  229. </FormControl>
  230. <FormMessage />
  231. </FormItem>
  232. )}
  233. />
  234. </div>
  235. </form>
  236. </Form>
  237. </div>
  238. )}
  239. {step === 3 && (
  240. <div className="space-y-4 text-center">
  241. <CheckCircle2
  242. className="mx-auto text-green-500"
  243. size={48}
  244. />
  245. <p className="font-semibold text-lg">
  246. Two-Factor Authentication Enabled
  247. </p>
  248. <p>
  249. Your account is now more secure. Don't forget to
  250. save your backup codes.
  251. </p>
  252. <div className="max-w-md mx-auto">
  253. <CopyTextBox text={backupCodes.join("\n")} />
  254. </div>
  255. </div>
  256. )}
  257. </CredenzaBody>
  258. <CredenzaFooter>
  259. {(step === 1 || step === 2) && (
  260. <Button
  261. type="button"
  262. loading={loading}
  263. disabled={loading}
  264. onClick={() => {
  265. if (step === 1) {
  266. enableForm.handleSubmit(request2fa)();
  267. } else {
  268. confirmForm.handleSubmit(confirm2fa)();
  269. }
  270. }}
  271. >
  272. Submit
  273. </Button>
  274. )}
  275. <CredenzaClose asChild>
  276. <Button variant="outline">Close</Button>
  277. </CredenzaClose>
  278. </CredenzaFooter>
  279. </CredenzaContent>
  280. </Credenza>
  281. );
  282. }