'use client'; import {useCallback, useState} from 'react'; import {useRouter, useSearchParams} from 'next/navigation'; import {useToast} from '@astryxdesign/core/Toast'; import {useSession} from '@/features/auth/providers/SessionProvider'; import { LOGIN_ERROR_PARAM, loginErrorFromCode, } from '@/features/auth/services/loginErrorCodes'; import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget'; import type {LoginError} from '@/features/auth/types/auth'; /** * Where credentials are POSTed. Exported because the form element itself needs * it for `action` — the no-JavaScript path submits straight here, bypassing * this hook, authService and the repository entirely. */ export const LOGIN_ENDPOINT = '/api/auth/login'; /** * Every behaviour of the sign-in form, with no markup attached. * * Pulled out of the component for the reason the brief asks for: the form was * a 314-line file where a fake `setTimeout` login sat in the middle of layout * markup. Now the view renders state and the rules live here — which also * makes the flow testable without mounting a page, and means a redesign of the * screen cannot silently change what "signed in" means. * * The chain below this hook is: SessionProvider → authService (validation and * domain rules) → authRepository (transport) → POST /api/auth/login. This hook * knows about none of it beyond the first link. */ export interface LoginFormState { email: string; password: string; rememberMe: boolean; /** Field-addressed, so a message renders under the input that caused it. */ errors: Partial>; isSubmitting: boolean; } export function useLoginForm() { const router = useRouter(); const searchParams = useSearchParams(); const toast = useToast(); const {login} = useSession(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [rememberMe, setRememberMe] = useState(false); /** * Seeded from `?error=` so a sign-in that failed on the no-JavaScript path * still shows its message once the page hydrates. The code is looked up in a * fixed table — an unrecognised one yields nothing, so the URL cannot be * used to write arbitrary text onto the sign-in screen. * * Seeding in useState rather than an effect keeps the message present in the * first render, server and client alike, so there is no flash and no * hydration mismatch to patch up. */ const [errors, setErrors] = useState(() => { const seeded = loginErrorFromCode(searchParams.get(LOGIN_ERROR_PARAM)); return seeded ? {[seeded.field]: seeded.message} : {}; }); const [isSubmitting, setIsSubmitting] = useState(false); // Raw, for the hidden field the native form path posts back. Unvalidated // here on purpose: the server validates it through resolveRedirectTarget, // which is the only side that can be trusted to. const next = searchParams.get('next'); // Shared with GuestGuard, which redirects on the same event — see // resolveRedirectTarget for why that matters and how `next` is validated. const destination = resolveRedirectTarget(next); const submit = useCallback( async (event: React.FormEvent) => { event.preventDefault(); if (isSubmitting) return; setErrors({}); setIsSubmitting(true); const result = await login({email, password, rememberMe}); if (!result.ok) { setErrors({[result.error.field]: result.error.message}); toast({ type: 'error', body: result.error.message, isAutoHide: true, autoHideDuration: 4000, uniqueID: 'login-error', collisionBehavior: 'overwrite', }); setIsSubmitting(false); return; } // Deliberately NOT clearing isSubmitting on success: the button stays in // its loading state until the navigation commits, so the form cannot be // submitted twice while the route transition is in flight. router.replace(destination); // The session cookie changed, so any server-rendered layout above this // route is stale. Without this, the workspace can paint its signed-out // seed until something else happens to refetch. router.refresh(); }, [ isSubmitting, login, email, password, rememberMe, toast, router, destination, ], ); /** Clearing on edit: an error that outlives the typo it describes is noise. */ const updateEmail = useCallback((value: string) => { setEmail(value); setErrors((prev) => (prev.email ? {...prev, email: undefined} : prev)); }, []); const updatePassword = useCallback((value: string) => { setPassword(value); setErrors((prev) => prev.password ? {...prev, password: undefined} : prev, ); }, []); return { email, password, rememberMe, errors, isSubmitting, next, setEmail: updateEmail, setPassword: updatePassword, setRememberMe, submit, }; }