149 lines
5.0 KiB
TypeScript
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,
|
|
};
|
|
}
|