import { zodResolver } from "@hookform/resolvers/zod"; import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { AuthLayout } from "~/client/components/auth-layout"; import { Button } from "~/client/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp"; import { Label } from "~/client/components/ui/label"; import { authClient } from "~/client/lib/auth-client"; import { logger } from "~/client/lib/logger"; import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip"; import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors"; import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors"; import { ResetPasswordDialog } from "../components/reset-password-dialog"; import { useNavigate } from "@tanstack/react-router"; import { normalizeUsername } from "~/lib/username"; import { cn } from "~/client/lib/utils"; import { AlternativeSignInSection } from "../components/alternative-sign-in-section"; import { z } from "zod"; const loginSchema = z.object({ username: z .string() .min(2, "Username must be at least 2 characters") .max(50, "Username must be at most 50 characters"), password: z.string().min(1, "Password is required"), }); type LoginFormValues = z.input; type LoginPageProps = { error?: string; }; type PasskeySignInError = { code?: string; message?: string; status?: number; statusText?: string; }; function isPasskeyVerificationFailure(error: PasskeySignInError | null) { return error?.code === "AUTHENTICATION_FAILED" || error?.code === "UNAUTHORIZED"; } function hasSkippedRecoveryKeyDownload(userId: string) { return document.cookie .split(";") .some((cookie) => cookie.trim() === `${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME}=${userId}`); } export function LoginPage({ error }: LoginPageProps = {}) { const navigate = useNavigate(); const [showResetDialog, setShowResetDialog] = useState(false); const [isLoggingIn, setIsLoggingIn] = useState(false); const [requires2FA, setRequires2FA] = useState(false); const [totpCode, setTotpCode] = useState(""); const [isVerifying2FA, setIsVerifying2FA] = useState(false); const [trustDevice, setTrustDevice] = useState(false); const errorCode = decodeLoginError(error); const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null; const navigateAfterLogin = useCallback(async () => { const session = await authClient.getSession(); if ( session.data?.user && !session.data.user.hasDownloadedResticPassword && !hasSkippedRecoveryKeyDownload(session.data.user.id) ) { void navigate({ to: "/download-recovery-key" }); } else { void navigate({ to: "/volumes" }); } }, [navigate]); useEffect(() => { const autoSignIn = async () => { if ( typeof PublicKeyCredential === "undefined" || !PublicKeyCredential.isConditionalMediationAvailable || !(await PublicKeyCredential.isConditionalMediationAvailable()) ) { return; } const { data, error } = await authClient.signIn.passkey({ autoFill: true, }); if (isPasskeyVerificationFailure(error)) { void navigate({ to: "/login", search: { error: PASSKEY_LOGIN_FAILED_ERROR, }, }); return; } if (data) { await navigateAfterLogin(); } }; void autoSignIn(); }, [navigate, navigateAfterLogin]); const form = useForm({ resolver: zodResolver(loginSchema), defaultValues: { username: "", password: "", }, }); const onSubmit = async (values: LoginFormValues) => { const { data, error } = await authClient.signIn.username({ username: normalizeUsername(values.username), password: values.password, fetchOptions: { onRequest: () => { setIsLoggingIn(true); }, onResponse: () => { setIsLoggingIn(false); }, }, }); if (error) { logger.error(error); toast.error("Login failed", { description: error.message }); return; } if ("twoFactorRedirect" in data && data.twoFactorRedirect) { setRequires2FA(true); return; } await navigateAfterLogin(); }; const handleVerify2FA = async () => { if (totpCode.length !== 6) { toast.error("Please enter a 6-digit code"); return; } const { data, error } = await authClient.twoFactor.verifyTotp({ code: totpCode, trustDevice, fetchOptions: { onRequest: () => { setIsVerifying2FA(true); }, onResponse: () => { setIsVerifying2FA(false); }, }, }); if (error) { logger.error(error); toast.error("Verification failed", { description: error.message }); setTotpCode(""); return; } if (data) { toast.success("Login successful"); const session = await authClient.getSession(); if ( session.data?.user && !session.data.user.hasDownloadedResticPassword && !hasSkippedRecoveryKeyDownload(session.data.user.id) ) { void navigate({ to: "/download-recovery-key" }); } else { void navigate({ to: "/volumes" }); } } }; const handleBackToLogin = () => { setRequires2FA(false); setTotpCode(""); setTrustDevice(false); form.reset(); }; if (requires2FA) { return (
setTrustDevice(e.target.checked)} className="h-4 w-4" />
); } return (
{errorDescription}
( Username )} /> (
Password
)} />
); }