Files
loyaly-merchant/src/features/auth/hooks/useLoginForm.ts

149 lines
5.0 KiB
TypeScript

'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<Record<LoginError['field'], string>>;
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<LoginFormState['errors']>(() => {
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,
};
}