"use client"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@app/components/ui/card"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { LoginResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; import { formatAxiosError } from "@app/lib/api"; import { LockIcon, FingerprintIcon } from "lucide-react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "./ui/input-otp"; import Link from "next/link"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import Image from "next/image"; import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { Separator } from "./ui/separator"; import { useTranslations } from "next-intl"; import { startAuthentication } from "@simplewebauthn/browser"; export type LoginFormIDP = { idpId: number; name: string; }; type LoginFormProps = { redirect?: string; onLogin?: () => void | Promise; idps?: LoginFormIDP[]; }; export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); const api = createApiClient({ env }); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [securityKeyLoading, setSecurityKeyLoading] = useState(false); const hasIdp = idps && idps.length > 0; const [mfaRequested, setMfaRequested] = useState(false); const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false); const t = useTranslations(); const formSchema = z.object({ email: z.string().email({ message: t('emailInvalid') }), password: z .string() .min(8, { message: t('passwordRequirementsChars') }) }); const mfaSchema = z.object({ code: z.string().length(6, { message: t('pincodeInvalid') }) }); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { email: "", password: "" } }); const mfaForm = useForm>({ resolver: zodResolver(mfaSchema), defaultValues: { code: "" } }); async function initiateSecurityKeyAuth() { setShowSecurityKeyPrompt(true); setSecurityKeyLoading(true); setError(null); try { // Start WebAuthn authentication without email const startRes = await api.post("/auth/security-key/authenticate/start", {}); if (!startRes) { setError(t('securityKeyAuthError', { defaultValue: "Failed to start security key authentication" })); return; } const { tempSessionId, ...options } = startRes.data.data; // Perform WebAuthn authentication try { const credential = await startAuthentication(options); // Verify authentication const verifyRes = await api.post( "/auth/security-key/authenticate/verify", { credential }, { headers: { 'X-Temp-Session-Id': tempSessionId } } ); if (verifyRes) { if (onLogin) { await onLogin(); } } } catch (error: any) { if (error.name === 'NotAllowedError') { if (error.message.includes('denied permission')) { setError(t('securityKeyPermissionDenied', { defaultValue: "Please allow access to your security key to continue signing in." })); } else { setError(t('securityKeyRemovedTooQuickly', { defaultValue: "Please keep your security key connected until the sign-in process completes." })); } } else if (error.name === 'NotSupportedError') { setError(t('securityKeyNotSupported', { defaultValue: "Your security key may not be compatible. Please try a different security key." })); } else { setError(t('securityKeyUnknownError', { defaultValue: "There was a problem using your security key. Please try again." })); } } } catch (e: any) { if (e.isAxiosError) { setError(formatAxiosError(e, t('securityKeyAuthError', { defaultValue: "Failed to authenticate with security key" }))); } else { console.error(e); setError(e.message || t('securityKeyAuthError', { defaultValue: "Failed to authenticate with security key" })); } } finally { setSecurityKeyLoading(false); setShowSecurityKeyPrompt(false); } } async function onSubmit(values: any) { const { email, password } = form.getValues(); const { code } = mfaForm.getValues(); setLoading(true); setError(null); setShowSecurityKeyPrompt(false); try { const res = await api.post>("/auth/login", { email, password, code }); const data = res.data.data; if (data?.useSecurityKey) { await initiateSecurityKeyAuth(); return; } if (data?.codeRequested) { setMfaRequested(true); setLoading(false); mfaForm.reset(); return; } if (data?.emailVerificationRequired) { if (redirect) { router.push(`/auth/verify-email?redirect=${redirect}`); } else { router.push("/auth/verify-email"); } return; } if (data?.twoFactorSetupRequired) { const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`; router.push(setupUrl); return; } if (onLogin) { await onLogin(); } } catch (e: any) { if (e.isAxiosError) { const errorMessage = formatAxiosError(e, t('loginError', { defaultValue: "Failed to log in" })); setError(errorMessage); return; } else { console.error(e); setError(e.message || t('loginError', { defaultValue: "Failed to log in" })); return; } } finally { setLoading(false); } } async function loginWithIdp(idpId: number) { try { const res = await api.post>( `/auth/idp/${idpId}/oidc/generate-url`, { redirectUrl: redirect || "/" } ); console.log(res); if (!res) { setError(t('loginError')); return; } const data = res.data.data; window.location.href = data.redirectUrl; } catch (e) { console.error(formatAxiosError(e)); } } return (
{showSecurityKeyPrompt && ( {t('securityKeyPrompt', { defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready." })} )} {!mfaRequested && ( <>
( {t('email')} )} />
( {t('password')} )} />
{t('passwordForgot')}
)} {mfaRequested && ( <>

{t('otpAuth')}

{t('otpAuthDescription')}

(
{ field.onChange(value); if (value.length === 6) { mfaForm.handleSubmit(onSubmit)(); } }} >
)} /> )} {error && ( {error} )}
{mfaRequested && ( )} {!mfaRequested && ( <> {hasIdp && ( <>
{t('idpContinue')}
{idps.map((idp) => ( ))} )} )} {mfaRequested && ( )}
); }