diff --git a/apps/dashboard/src/components/dashboard-account-settings/email-and-auth/mfa-section.tsx b/apps/dashboard/src/components/dashboard-account-settings/email-and-auth/mfa-section.tsx new file mode 100644 index 000000000..bd30adb59 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/email-and-auth/mfa-section.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { createTOTPKeyURI, verifyTOTP } from "@oslojs/otp"; +import { useAsyncCallback } from '@stackframe/stack-shared/dist/hooks/use-async-callback'; +import { generateRandomValues } from '@stackframe/stack-shared/dist/utils/crypto'; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import * as QRCode from 'qrcode'; +import { useEffect, useState } from "react"; +import { CurrentUser, Project } from '@stackframe/stack'; +import { useStackApp, useUser } from "@stackframe/stack"; +import { Section } from "../section"; + +export function MfaSection(props?: { + mockMode?: boolean, +}) { + const project = useStackApp().useProject(); + const isInMockMode = !!props?.mockMode; + const user = useUser({ or: isInMockMode ? 'return-null' : 'redirect' }); + + // In mock mode, show a placeholder message + if (isInMockMode && !user) { + return ( +
+ MFA management is not available in demo mode. +
+ ); + } + + if (!user) { + return null; + } + + return ; +} + +function MfaSectionInner({ user, project }: { user: CurrentUser, project: Project }) { + const [generatedSecret, setGeneratedSecret] = useState(null); + const [qrCodeUrl, setQrCodeUrl] = useState(null); + const [mfaCode, setMfaCode] = useState(""); + const [isMaybeWrong, setIsMaybeWrong] = useState(false); + const isEnabled = user.isMultiFactorRequired; + + const [handleSubmit, isLoading] = useAsyncCallback(async () => { + await user.update({ + totpMultiFactorSecret: generatedSecret, + }); + setGeneratedSecret(null); + setQrCodeUrl(null); + setMfaCode(""); + }, [generatedSecret, user]); + + useEffect(() => { + setIsMaybeWrong(false); + runAsynchronouslyWithAlert(async () => { + if (generatedSecret && verifyTOTP(generatedSecret, 30, 6, mfaCode)) { + await handleSubmit(); + } + setIsMaybeWrong(true); + }); + }, [mfaCode, generatedSecret, handleSubmit]); + + return ( +
+
+ {!isEnabled && generatedSecret && ( +
+ Scan this QR code with your authenticator app: +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + TOTP multi-factor authentication QR code +
+ Then, enter your six-digit MFA code: + { + setIsMaybeWrong(false); + setMfaCode(e.target.value); + }} + placeholder="123456" + maxLength={6} + disabled={isLoading} + className="bg-white dark:bg-zinc-900 border-black/[0.08] dark:border-white/[0.08] rounded-xl px-3 py-2 shadow-sm focus-visible:ring-black/[0.06] dark:focus-visible:ring-white/[0.06] tracking-[0.2em] font-mono text-center text-lg" + /> + {isMaybeWrong && mfaCode.length === 6 && ( + Incorrect code. Please try again. + )} +
+ +
+
+ )} +
+ {isEnabled ? ( + + ) : !generatedSecret && ( + + )} +
+
+
+ ); +} + +async function generateTotpQrCode(project: Project, user: CurrentUser, secret: Uint8Array) { + const uri = createTOTPKeyURI(project.displayName, user.primaryEmail ?? user.id, secret, 30, 6); + return await QRCode.toDataURL(uri) as any; +}