/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { zodResolver } from '@hookform/resolvers/zod' import { Link } from '@tanstack/react-router' import { Loader2, LogIn, KeyRound } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import type { z } from 'zod' import { Dialog } from '@/components/dialog' import { PasswordInput } from '@/components/password-input' import { Turnstile } from '@/components/turnstile' import { Button } from '@/components/ui/button' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { login, wechatLoginByCode } from '@/features/auth/api' import { LegalConsent } from '@/features/auth/components/legal-consent' import { OAuthProviders } from '@/features/auth/components/oauth-providers' import { loginFormSchema } from '@/features/auth/constants' import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect' import { useTurnstile } from '@/features/auth/hooks/use-turnstile' import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey' import { requestPasskeyAssertion, rememberPasskeyRPID, type PasskeyDomains, } from '@/features/auth/passkey/assertion' import { PasskeyDomainSelector } from '@/features/auth/passkey/components/passkey-domain-selector' import type { AuthFormProps } from '@/features/auth/types' import { useStatus } from '@/hooks/use-status' import { handleServerError } from '@/lib/handle-server-error' import { isPasskeySupported as detectPasskeySupport } from '@/lib/passkey' import { AuthOperationError } from '@/lib/secure-verification' import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' export function UserAuthForm({ className, redirectTo, ...props }: AuthFormProps) { const { t } = useTranslation() const [isLoading, setIsLoading] = useState(false) const [wechatCode, setWeChatCode] = useState('') const [agreedToLegal, setAgreedToLegal] = useState(false) const [passkeySupported, setPasskeySupported] = useState(false) const [isPasskeyLoading, setIsPasskeyLoading] = useState(false) const [passkeyDomains, setPasskeyDomains] = useState( null ) const [passkeyRPID, setPasskeyRPID] = useState() const passkeyOperation = useRef(null) useEffect(() => () => passkeyOperation.current?.abort(), []) const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false) const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false) const [turnstileWidgetKey, setTurnstileWidgetKey] = useState(0) const legalConsentErrorMessage = t('Please agree to the legal terms first') const loginFailedMessage = t('Login failed') const { status } = useStatus() const passkeyLoginEnabled = Boolean( status?.passkey_login ?? status?.data?.passkey_login ) const passwordLoginEnabled = (status?.password_login_enabled ?? status?.data?.password_login_enabled ?? true) !== false const passwordLoginEncryptionEnabled = (status?.password_login_encryption_enabled ?? status?.data?.password_login_encryption_enabled ?? false) === true const { isTurnstileEnabled, turnstileSiteKey, turnstileToken, setTurnstileToken, validateTurnstile, } = useTurnstile() const { handleLoginResult } = useAuthRedirect() const hasUserAgreement = Boolean(status?.user_agreement_enabled) const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled) const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy const passkeyButtonDisabled = isPasskeyLoading || !passkeySupported || (requiresLegalConsent && !agreedToLegal) const hasWeChatLogin = Boolean(status?.wechat_login) const hasOAuthLogin = Boolean( status?.github_oauth || status?.discord_oauth || status?.oidc_enabled || status?.linuxdo_oauth || status?.telegram_oauth || (status?.custom_oauth_providers?.length ?? 0) > 0 ) const hasAlternativeLogin = passkeyLoginEnabled || hasWeChatLogin || hasOAuthLogin useEffect(() => { if (requiresLegalConsent) { setAgreedToLegal(false) } else { setAgreedToLegal(true) } }, [requiresLegalConsent]) useEffect(() => { detectPasskeySupport() .then(setPasskeySupported) .catch(() => setPasskeySupported(false)) }, []) const form = useForm>({ resolver: zodResolver(loginFormSchema), defaultValues: { username: '', password: '', }, }) const wechatQrCodeUrl = useMemo(() => { return ( status?.wechat_qrcode || status?.wechat_qr_code || status?.wechat_qrcode_image_url || status?.wechat_qr_code_image_url || status?.wechat_account_qrcode_image_url || status?.WeChatAccountQRCodeImageURL || status?.data?.wechat_qrcode || status?.data?.WeChatAccountQRCodeImageURL || '' ) }, [status]) async function onSubmit(data: z.infer) { if (requiresLegalConsent && !agreedToLegal) { toast.error(legalConsentErrorMessage) return } if (!validateTurnstile()) return const submittedTurnstileToken = turnstileToken if (isTurnstileEnabled) { setTurnstileToken('') setTurnstileWidgetKey((current) => current + 1) } setIsLoading(true) try { const res = await login({ username: data.username, password: data.password, turnstile: submittedTurnstileToken, passwordEncryptionEnabled: passwordLoginEncryptionEnabled, }) if (res.success) { form.setValue('password', '') if (await handleLoginResult(res.data, redirectTo)) { toast.success(t('Welcome back!')) } } else { handleServerError(createServerError(res, loginFailedMessage)) } } catch (error: unknown) { handleServerError(AuthOperationError.from(error, loginFailedMessage)) } finally { setIsLoading(false) } } const handleOpenWeChatDialog = () => { if (requiresLegalConsent && !agreedToLegal) { toast.error(legalConsentErrorMessage) return } setIsWeChatDialogOpen(true) } const handleWeChatDialogChange = (open: boolean) => { setIsWeChatDialogOpen(open) if (!open) { setWeChatCode('') setIsWeChatSubmitting(false) } } async function handleWeChatLogin() { if (!wechatCode.trim()) { toast.error(t('Please enter the verification code')) return } setIsWeChatSubmitting(true) try { const res = await wechatLoginByCode(wechatCode) if (res?.success) { handleWeChatDialogChange(false) if (await handleLoginResult(res.data, redirectTo)) { toast.success(t('Signed in via WeChat')) } } else { handleServerError(createServerError(res, loginFailedMessage)) } } catch (error: unknown) { handleServerError( new AuthOperationError(loginFailedMessage, undefined, { cause: error }) ) } finally { setIsWeChatSubmitting(false) } } async function handlePasskeyLogin() { if (requiresLegalConsent && !agreedToLegal) { toast.error(legalConsentErrorMessage) return } if (!passkeySupported) { toast.error(t('Passkey is not supported on this device')) return } if (!navigator?.credentials) { toast.error(t('Passkey is not available in this browser')) return } if (passkeyOperation.current) return const controller = new AbortController() passkeyOperation.current = controller setIsPasskeyLoading(true) try { const passkey = await requestPasskeyAssertion( (rpID) => beginPasskeyLogin(rpID, controller.signal), controller.signal, { rpID: passkeyRPID, onDomains: setPasskeyDomains } ) const finish = await finishPasskeyLogin( passkey.flowToken, passkey.assertion, controller.signal ) controller.signal.throwIfAborted() if (!finish.success) { throw createServerError(finish, t('Failed to complete Passkey login')) } rememberPasskeyRPID(passkey.rpID) if (await handleLoginResult(finish.data, redirectTo)) { toast.success(t('Signed in with Passkey')) } } catch (error: unknown) { if (controller.signal.aborted) return if (error instanceof DOMException && error.name === 'NotAllowedError') { toast.info(t('Passkey login was cancelled or timed out')) } else if (error instanceof Error) { handleServerError(AuthOperationError.from(error)) } else { handleServerError( AuthOperationError.from(error, t('Passkey login failed')) ) } } finally { if (passkeyOperation.current === controller) { passkeyOperation.current = null } setIsPasskeyLoading(false) } } const alternativeLoginMethods = ( <> {passkeyLoginEnabled && (
{!passkeySupported && (

{t('Passkey is not supported on this device.')}

)}
)} {/* OAuth Providers */} ) return (
{hasAlternativeLogin && alternativeLoginMethods} {passwordLoginEnabled && ( <> {/* Username Field */} ( {t('Username or Email')} )} /> {/* Password Field */} ( {t('Password')} {t('Forgot password?')} )} /> {/* Submit Button */} {/* Turnstile */} {isTurnstileEnabled && (
setTurnstileToken('')} />
)} )} {!hasAlternativeLogin && alternativeLoginMethods} {hasWeChatLogin && ( } > {wechatQrCodeUrl ? (
{t('WeChat
) : (

{t('QR code is not configured. Please contact support.')}

)}
setWeChatCode(event.target.value)} autoComplete='one-time-code' />
)} ) }