267 lines
8.0 KiB
TypeScript
267 lines
8.0 KiB
TypeScript
/**
|
|
* Login Client Component
|
|
*
|
|
* Handles client-side login flow using the LoginFlowController.
|
|
* Deterministic state machine per docs/architecture/website/LOGIN_FLOW_STATE_MACHINE.md
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { useAuth } from '@/lib/auth/AuthContext';
|
|
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
|
|
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
|
|
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
|
|
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
|
|
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
|
|
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
|
|
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
|
import { AuthLoading } from '@/ui/AuthLoading';
|
|
|
|
interface LoginClientProps {
|
|
viewData: LoginViewData;
|
|
}
|
|
|
|
export function LoginClient({ viewData }: LoginClientProps) {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { refreshSession, session } = useAuth();
|
|
|
|
// Build ViewModel from ViewData
|
|
const [viewModel, setViewModel] = useState<LoginViewModel>(() =>
|
|
LoginViewModelBuilder.build(viewData)
|
|
);
|
|
|
|
// Login flow controller
|
|
const controller = useMemo(() => {
|
|
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
|
|
return new LoginFlowController(session, returnTo);
|
|
}, [session, searchParams]);
|
|
|
|
// Check controller state on mount and session changes
|
|
useEffect(() => {
|
|
const action = controller.getNextAction();
|
|
|
|
if (action.type === 'REDIRECT') {
|
|
router.replace(action.path);
|
|
}
|
|
}, [controller, router]);
|
|
|
|
// Handle form field changes
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { name, value, type } = e.target;
|
|
const checked = 'checked' in e.target ? e.target.checked : false;
|
|
const fieldValue = type === 'checkbox' ? checked : value;
|
|
|
|
setViewModel(prev => {
|
|
const newFormState = {
|
|
...prev.formState,
|
|
fields: {
|
|
...prev.formState.fields,
|
|
[name]: {
|
|
...prev.formState.fields[name as keyof typeof prev.formState.fields],
|
|
value: fieldValue,
|
|
touched: true,
|
|
error: undefined,
|
|
},
|
|
},
|
|
};
|
|
return prev.withFormState(newFormState);
|
|
});
|
|
};
|
|
|
|
// Handle form submission
|
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
|
|
const values: LoginFormValues = {
|
|
email: viewModel.formState.fields.email.value as string,
|
|
password: viewModel.formState.fields.password.value as string,
|
|
rememberMe: viewModel.formState.fields.rememberMe.value as boolean,
|
|
};
|
|
|
|
// Validate form
|
|
const errors = validateLoginForm(values);
|
|
const hasErrors = Object.keys(errors).length > 0;
|
|
|
|
if (hasErrors) {
|
|
setViewModel(prev => {
|
|
const newFormState = {
|
|
...prev.formState,
|
|
isValid: false,
|
|
submitCount: prev.formState.submitCount + 1,
|
|
fields: {
|
|
...prev.formState.fields,
|
|
email: {
|
|
...prev.formState.fields.email,
|
|
error: errors.email,
|
|
touched: true,
|
|
},
|
|
password: {
|
|
...prev.formState.fields.password,
|
|
error: errors.password,
|
|
touched: true,
|
|
},
|
|
},
|
|
};
|
|
return prev.withFormState(newFormState);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Update submitting state
|
|
setViewModel(prev => prev.withMutationState(true, null));
|
|
|
|
try {
|
|
// Execute login mutation
|
|
const mutation = new LoginMutation();
|
|
const result = await mutation.execute({
|
|
email: values.email,
|
|
password: values.password,
|
|
rememberMe: values.rememberMe,
|
|
});
|
|
|
|
if (result.isErr()) {
|
|
const error = result.getError();
|
|
setViewModel(prev => {
|
|
const newFormState = {
|
|
...prev.formState,
|
|
isSubmitting: false,
|
|
submitError: error,
|
|
};
|
|
return prev.withFormState(newFormState).withMutationState(false, error);
|
|
});
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
setViewModel(prev => prev.withUIState({
|
|
...prev.uiState,
|
|
showErrorDetails: true,
|
|
}));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Success - refresh session and transition
|
|
await refreshSession();
|
|
|
|
// Transition to post-auth state
|
|
controller.transitionToPostAuth();
|
|
const action = controller.getNextAction();
|
|
|
|
if (action.type === 'REDIRECT') {
|
|
router.push(action.path);
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Login failed';
|
|
setViewModel(prev => {
|
|
const newFormState = {
|
|
...prev.formState,
|
|
isSubmitting: false,
|
|
submitError: errorMessage,
|
|
};
|
|
return prev.withFormState(newFormState).withMutationState(false, errorMessage);
|
|
});
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
setViewModel(prev => prev.withUIState({
|
|
...prev.uiState,
|
|
showErrorDetails: true,
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Toggle password visibility
|
|
const togglePassword = () => {
|
|
setViewModel(prev => prev.withUIState({
|
|
...prev.uiState,
|
|
showPassword: !prev.uiState.showPassword,
|
|
}));
|
|
};
|
|
|
|
// Get current state from controller
|
|
const state = controller.getState();
|
|
|
|
// If user is authenticated with permissions, show loading
|
|
if (state === LoginState.AUTHENTICATED_WITH_PERMISSIONS) {
|
|
return <AuthLoading />;
|
|
}
|
|
|
|
// If user has insufficient permissions, show permission error
|
|
if (state === LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS) {
|
|
return (
|
|
<LoginTemplate
|
|
viewData={{
|
|
...viewData,
|
|
hasInsufficientPermissions: true,
|
|
showPassword: viewModel.showPassword,
|
|
showErrorDetails: viewModel.showErrorDetails,
|
|
formState: viewModel.formState,
|
|
isSubmitting: viewModel.isSubmitting,
|
|
submitError: viewModel.submitError,
|
|
}}
|
|
formActions={{
|
|
handleChange,
|
|
handleSubmit,
|
|
setFormState: (state) => {
|
|
if (typeof state === 'function') {
|
|
const newState = state(viewModel.formState);
|
|
setViewModel(prev => prev.withFormState(newState));
|
|
} else {
|
|
setViewModel(prev => prev.withFormState(state));
|
|
}
|
|
},
|
|
setShowPassword: togglePassword,
|
|
setShowErrorDetails: (show) => {
|
|
setViewModel(prev => prev.withUIState({
|
|
...prev.uiState,
|
|
showErrorDetails: show,
|
|
}));
|
|
},
|
|
}}
|
|
mutationState={{
|
|
isPending: viewModel.mutationPending,
|
|
error: viewModel.mutationError,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Show login form
|
|
return (
|
|
<LoginTemplate
|
|
viewData={{
|
|
...viewData,
|
|
showPassword: viewModel.showPassword,
|
|
showErrorDetails: viewModel.showErrorDetails,
|
|
formState: viewModel.formState,
|
|
isSubmitting: viewModel.isSubmitting,
|
|
submitError: viewModel.submitError,
|
|
}}
|
|
formActions={{
|
|
handleChange,
|
|
handleSubmit,
|
|
setFormState: (state) => {
|
|
if (typeof state === 'function') {
|
|
const newState = state(viewModel.formState);
|
|
setViewModel(prev => prev.withFormState(newState));
|
|
} else {
|
|
setViewModel(prev => prev.withFormState(state));
|
|
}
|
|
},
|
|
setShowPassword: togglePassword,
|
|
setShowErrorDetails: (show) => {
|
|
setViewModel(prev => prev.withUIState({
|
|
...prev.uiState,
|
|
showErrorDetails: show,
|
|
}));
|
|
},
|
|
}}
|
|
mutationState={{
|
|
isPending: viewModel.mutationPending,
|
|
error: viewModel.mutationError,
|
|
}}
|
|
/>
|
|
);
|
|
} |