import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from '@/components/ui/card'; import { LanguageSelector } from '@/components/ui/language-selector'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { useCallback, useEffect, useRef, useState } from 'react'; import { beginAuthenticatedSession, bootstrapWorkspaceSession, clearPendingInvitationToken, getPendingInvitationToken, httpClient, } from '@/app/infra/http'; import { useNavigate } from 'react-router-dom'; import { Mail, Lock, Loader2, AlertCircle, RefreshCw, Layers, Fingerprint, } from 'lucide-react'; import { startAuthentication } from '@simplewebauthn/browser'; import langbotIcon from '@/app/assets/langbot-logo.webp'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ThemeToggle } from '@/components/ui/theme-toggle'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; const formSchema = (t: (key: string) => string) => z.object({ email: z.string().email(t('common.invalidEmail')), password: z.string().min(1, t('common.emptyPassword')), }); const TERMINAL_INVITATION_ERROR_CODES = new Set([ 'invitation_invalid', 'invitation_expired', 'invitation_revoked', 'invitation_used', 'invitation_email_mismatch', ]); export default function Login() { const navigate = useNavigate(); const { t } = useTranslation(); const [spaceLoading, setSpaceLoading] = useState(false); const [showLocalLogin, setShowLocalLogin] = useState(false); const [showSpaceLogin, setShowSpaceLogin] = useState(false); const [showPasskeyLogin, setShowPasskeyLogin] = useState(false); const [passkeyLoading, setPasskeyLoading] = useState(false); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [retrying, setRetrying] = useState(false); const autoSpaceLoginStarted = useRef(false); const form = useForm>>({ resolver: zodResolver(formSchema(t)), defaultValues: { email: '', password: '', }, }); useEffect(() => { checkAccountInfo(); }, []); async function checkAccountInfo() { try { setLoadError(null); const res = await httpClient.getAccountInfo(); if (!res.initialized) { navigate('/register'); return; } setShowLocalLogin(res.password_login_enabled !== false); setShowSpaceLogin(res.space_login_enabled !== false); setShowPasskeyLogin( res.passkey_login_enabled !== false || Boolean(res.passkey_supported), ); setLoading(false); // Also check if already logged in checkIfAlreadyLoggedIn(); } catch (err) { let detail = ''; if (err instanceof Error) { detail = err.message; } else if ( err && typeof err === 'object' && 'msg' in err && typeof (err as Record).msg === 'string' ) { detail = (err as Record).msg as string; } setLoadError(detail || t('common.loginLoadError')); setLoading(false); } } async function handleRetry() { setRetrying(true); setLoading(true); setLoadError(null); await checkAccountInfo(); setRetrying(false); } function checkIfAlreadyLoggedIn() { httpClient .checkUserToken() .then(async (res) => { if (res.token) { await finishLogin(res.token); } }) .catch(() => {}); } async function finishLogin( token: string, username?: string, ): Promise { beginAuthenticatedSession(token, username); const invitationToken = getPendingInvitationToken(); let preferredWorkspaceUuid: string | undefined; if (invitationToken) { try { const response = await httpClient.acceptWorkspaceInvitation(invitationToken); beginAuthenticatedSession(response.token, username); preferredWorkspaceUuid = response.workspace_uuid; clearPendingInvitationToken(); } catch (error) { const apiError = error as { code?: string }; const errorCode = typeof apiError.code === 'string' ? apiError.code : 'invitation_accept_failed'; const invitationPath = TERMINAL_INVITATION_ERROR_CODES.has(errorCode) ? `/invitations/accept?error=${encodeURIComponent(errorCode)}` : '/invitations/accept'; navigate(invitationPath, { replace: true }); toast.error( t( errorCode === 'invitation_email_mismatch' ? 'workspace.invitationEmailMismatch' : 'workspace.invitationAcceptFailed', ), ); return false; } } const result = await bootstrapWorkspaceSession({ preferredWorkspaceUuid, }); if (result.status === 'selection-required') { navigate('/workspaces/select?returnTo=%2Fhome', { replace: true }); return true; } if (result.status === 'unavailable') { throw new Error('No Workspace is available for this Account'); } navigate('/home'); return true; } function onSubmit(values: z.infer>) { handleLogin(values.email, values.password); } async function handlePasskeyLogin() { setPasskeyLoading(true); try { const { options, challenge_token } = await httpClient.getPasskeyAuthOptions( undefined, window.location.origin, ); const authResp = await startAuthentication({ optionsJSON: options }); const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp); if (await finishLogin(res.token, res.user)) { toast.success(t('common.passkeyLoginSuccess')); } } catch (error: any) { if (error?.name === 'NotAllowedError') { // User cancelled the biometric prompt } else { toast.error(error?.message || t('common.passkeyLoginFailed')); } } finally { setPasskeyLoading(false); } } function handleLogin(username: string, password: string) { httpClient .authUser(username, password) .then(async (res) => { if (await finishLogin(res.token, username)) { toast.success(t('common.loginSuccess')); } }) .catch(() => { toast.error(t('common.loginFailed')); }); } const handleSpaceLoginClick = useCallback(async () => { setSpaceLoading(true); try { const currentOrigin = window.location.origin; const redirectUri = `${currentOrigin}/auth/space/callback`; const response = await httpClient.getSpaceAuthorizeUrl(redirectUri); window.location.href = response.authorize_url; } catch { toast.error(t('common.spaceLoginFailed')); setSpaceLoading(false); } }, [t]); useEffect(() => { if ( loading || !showSpaceLogin || autoSpaceLoginStarted.current || new URLSearchParams(window.location.search).get('auto') !== 'space' ) { return; } autoSpaceLoginStarted.current = true; void handleSpaceLoginClick(); }, [handleSpaceLoginClick, loading, showSpaceLogin]); if (loading) { return (
); } // Show error state when account info failed to load if (loadError) { return (
LangBot {t('common.welcome')}
{t('common.loginLoadError')}

{t('common.loginLoadErrorDesc')}

{loadError}
); } return (
LangBot {t('common.welcome')} {t('common.continueToLogin')}
{/* Space and password login are per-account capabilities. */} {showSpaceLogin && (
)} {showPasskeyLogin && (
)} {/* Divider - only show if both login methods are available */} {(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
{t('common.or')}
)} {/* Password login remains available to every account with a password. */} {showLocalLogin && (
( {t('common.email')}
)} /> (
{t('common.password')} {t('common.forgotPassword')}
)} /> )}

{t('common.agreementNotice')}{' '} {t('common.termsOfService')} {'、'} {t('common.privacyPolicy')} {' '} {t('common.and')}{' '} {t('common.dataCollectionPolicy')}

); }