website refactor

This commit is contained in:
2026-01-18 23:43:58 +01:00
parent 7c1cf62d4e
commit c0559d8b48
76 changed files with 39 additions and 89 deletions

View File

@@ -1,130 +0,0 @@
/**
* Forgot Password Client Component
*
* Handles client-side forgot password flow.
*/
'use client';
import { useState } from 'react';
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
interface ForgotPasswordClientProps {
viewData: ForgotPasswordViewData;
}
export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) {
// Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<ForgotPasswordViewModel>(() =>
ForgotPasswordViewModelBuilder.build(viewData)
);
// Handle form field changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setViewModel(prev => {
const newFormState = {
...prev.formState,
fields: {
...prev.formState.fields,
[name]: {
...prev.formState.fields[name as keyof typeof prev.formState.fields],
value,
touched: true,
error: undefined,
},
},
};
return prev.withFormState(newFormState);
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = {
email: viewModel.formState.fields.email.value as string,
};
// Validate form
const validationErrors = ForgotPasswordFormValidation.validateForm(formData);
if (validationErrors.length > 0) {
setViewModel(prev => {
const newFormState = {
...prev.formState,
isValid: false,
submitCount: prev.formState.submitCount + 1,
fields: {
...prev.formState.fields,
email: {
...prev.formState.fields.email,
error: validationErrors.find(e => e.field === 'email')?.message,
touched: true,
},
},
};
return prev.withFormState(newFormState);
});
return;
}
// Update submitting state
setViewModel(prev => prev.withMutationState(true, null));
try {
// Execute forgot password mutation
const mutation = new ForgotPasswordMutation();
const result = await mutation.execute(formData);
if (result.isErr()) {
const error = result.getError();
setViewModel(prev => prev.withMutationState(false, error));
return;
}
// Success
const data = result.unwrap();
setViewModel(prev => prev.withSuccess(data.message, data.magicLink || null));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to send reset link';
setViewModel(prev => prev.withMutationState(false, errorMessage));
}
};
// Build viewData for template
const templateViewData: ForgotPasswordViewData = {
...viewData,
showSuccess: viewModel.showSuccess,
successMessage: viewModel.successMessage || undefined,
magicLink: viewModel.magicLink || undefined,
formState: viewModel.formState,
isSubmitting: viewModel.isSubmitting,
submitError: viewModel.submitError,
};
return (
<ForgotPasswordTemplate
viewData={templateViewData}
formActions={{
handleChange,
handleSubmit,
setShowSuccess: (show) => {
if (!show) {
// Reset to initial state
setViewModel(() => ForgotPasswordViewModelBuilder.build(viewData));
}
},
}}
mutationState={{
isPending: viewModel.mutationPending,
error: viewModel.mutationError,
}}
/>
);
}

View File

@@ -8,7 +8,7 @@
import { AuthError } from '@/components/auth/AuthError';
import { ForgotPasswordPageQuery } from '@/lib/page-queries/auth/ForgotPasswordPageQuery';
import { ForgotPasswordClient } from './ForgotPasswordClient';
import { ForgotPasswordClient } from '@/client-wrapper/ForgotPasswordClient';
export default async function ForgotPasswordPage({
searchParams,

View File

@@ -1,267 +0,0 @@
/**
* 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 { useAuth } from '@/components/auth/AuthContext';
import { AuthLoading } from '@/components/auth/AuthLoading';
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
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,
}}
/>
);
}

View File

@@ -8,7 +8,7 @@
import { AuthError } from '@/components/auth/AuthError';
import { LoginPageQuery } from '@/lib/page-queries/auth/LoginPageQuery';
import { LoginClient } from './LoginClient';
import { LoginClient } from '@/client-wrapper/LoginClient';
export default async function LoginPage({
searchParams,

View File

@@ -1,173 +0,0 @@
/**
* Reset Password Client Component
*
* Handles client-side reset password flow.
*/
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
import { routes } from '@/lib/routing/RouteConfig';
interface ResetPasswordClientProps {
viewData: ResetPasswordViewData;
}
export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
// Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<ResetPasswordViewModel>(() =>
ResetPasswordViewModelBuilder.build(viewData)
);
// Handle form field changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setViewModel(prev => {
const newFormState = {
...prev.formState,
fields: {
...prev.formState.fields,
[name as keyof typeof prev.formState.fields]: {
...prev.formState.fields[name as keyof typeof prev.formState.fields],
value,
touched: true,
error: undefined,
},
},
};
return prev.withFormState(newFormState);
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = {
newPassword: viewModel.formState.fields.newPassword.value as string,
confirmPassword: viewModel.formState.fields.confirmPassword.value as string,
};
// Validate form
const validationErrors = ResetPasswordFormValidation.validateForm(formData);
if (validationErrors.length > 0) {
setViewModel(prev => {
const newFormState = {
...prev.formState,
isValid: false,
submitCount: prev.formState.submitCount + 1,
fields: {
...prev.formState.fields,
...validationErrors.reduce((acc, error) => ({
...acc,
[error.field]: {
...prev.formState.fields[error.field as keyof typeof prev.formState.fields],
error: error.message,
touched: true,
},
}), {}),
},
};
return prev.withFormState(newFormState);
});
return;
}
// Update submitting state
setViewModel(prev => prev.withMutationState(true, null));
try {
const token = searchParams.get('token');
if (!token) {
setViewModel(prev => prev.withMutationState(false, 'Invalid reset link'));
return;
}
// Execute reset password mutation
const mutation = new ResetPasswordMutation();
const result = await mutation.execute({
token,
newPassword: formData.newPassword,
});
if (result.isErr()) {
const error = result.getError();
setViewModel(prev => prev.withMutationState(false, error));
return;
}
// Success
const data = result.unwrap();
setViewModel(prev => prev.withSuccess(data.message));
// Redirect to login after a delay
setTimeout(() => {
router.push(routes.auth.login);
}, 3000);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to reset password';
setViewModel(prev => prev.withMutationState(false, errorMessage));
}
};
// Toggle password visibility
const togglePassword = () => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showPassword: !prev.uiState.showPassword,
}));
};
const toggleConfirmPassword = () => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showConfirmPassword: !prev.uiState.showConfirmPassword,
}));
};
// Build viewData for template
const templateViewData: ResetPasswordViewData = {
...viewData,
showSuccess: viewModel.showSuccess,
successMessage: viewModel.successMessage || undefined,
formState: viewModel.formState,
isSubmitting: viewModel.isSubmitting,
submitError: viewModel.submitError,
};
return (
<ResetPasswordTemplate
viewData={templateViewData}
formActions={{
handleChange,
handleSubmit,
setShowSuccess: (show) => {
if (!show) {
// Reset to initial state
setViewModel(() => ResetPasswordViewModelBuilder.build(viewData));
}
},
setShowPassword: togglePassword,
setShowConfirmPassword: toggleConfirmPassword,
}}
uiState={{
showPassword: viewModel.uiState.showPassword,
showConfirmPassword: viewModel.uiState.showConfirmPassword,
}}
mutationState={{
isPending: viewModel.mutationPending,
error: viewModel.mutationError,
}}
/>
);
}

View File

@@ -8,7 +8,7 @@
import { AuthError } from '@/components/auth/AuthError';
import { ResetPasswordPageQuery } from '@/lib/page-queries/auth/ResetPasswordPageQuery';
import { ResetPasswordClient } from './ResetPasswordClient';
import { ResetPasswordClient } from '@/client-wrapper/ResetPasswordClient';
export default async function ResetPasswordPage({
searchParams,

View File

@@ -1,164 +0,0 @@
/**
* Signup Client Component
*
* Handles client-side signup flow.
*/
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthContext';
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
import { SignupTemplate } from '@/templates/auth/SignupTemplate';
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
import { SignupFormValidation } from '@/lib/utilities/authValidation';
interface SignupClientProps {
viewData: SignupViewData;
}
export function SignupClient({ viewData }: SignupClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession } = useAuth();
// Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<SignupViewModel>(() =>
SignupViewModelBuilder.build(viewData)
);
// Handle form field changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setViewModel(prev => {
const newFormState = {
...prev.formState,
fields: {
...prev.formState.fields,
[name]: {
...prev.formState.fields[name as keyof typeof prev.formState.fields],
value,
touched: true,
error: undefined,
},
},
};
return prev.withFormState(newFormState);
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = {
firstName: viewModel.formState.fields.firstName.value as string,
lastName: viewModel.formState.fields.lastName.value as string,
email: viewModel.formState.fields.email.value as string,
password: viewModel.formState.fields.password.value as string,
confirmPassword: viewModel.formState.fields.confirmPassword.value as string,
};
// Validate form
const validationErrors = SignupFormValidation.validateForm(formData);
if (validationErrors.length > 0) {
setViewModel(prev => {
const newFormState = {
...prev.formState,
isValid: false,
submitCount: prev.formState.submitCount + 1,
fields: {
...prev.formState.fields,
...validationErrors.reduce((acc, error) => ({
...acc,
[error.field]: {
...prev.formState.fields[error.field],
error: error.message,
touched: true,
},
}), {}),
},
};
return prev.withFormState(newFormState);
});
return;
}
// Update submitting state
setViewModel(prev => prev.withMutationState(true, null));
try {
// Generate display name
const displayName = SignupFormValidation.generateDisplayName(formData.firstName, formData.lastName);
// Execute signup mutation
const mutation = new SignupMutation();
const result = await mutation.execute({
email: formData.email,
password: formData.password,
displayName,
});
if (result.isErr()) {
const error = result.getError();
setViewModel(prev => prev.withMutationState(false, error));
return;
}
// Success - refresh session and redirect
await refreshSession();
const returnTo = searchParams.get('returnTo') ?? '/onboarding';
router.push(returnTo);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Signup failed';
setViewModel(prev => prev.withMutationState(false, errorMessage));
}
};
// Toggle password visibility
const togglePassword = () => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showPassword: !prev.uiState.showPassword,
}));
};
const toggleConfirmPassword = () => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showConfirmPassword: !prev.uiState.showConfirmPassword,
}));
};
// Build viewData for template
const templateViewData: SignupViewData = {
...viewData,
formState: viewModel.formState,
isSubmitting: viewModel.isSubmitting,
submitError: viewModel.submitError,
};
return (
<SignupTemplate
viewData={templateViewData}
formActions={{
handleChange,
handleSubmit,
setShowPassword: togglePassword,
setShowConfirmPassword: toggleConfirmPassword,
}}
uiState={{
showPassword: viewModel.uiState.showPassword,
showConfirmPassword: viewModel.uiState.showConfirmPassword,
}}
mutationState={{
isPending: viewModel.mutationPending,
error: viewModel.mutationError,
}}
/>
);
}

View File

@@ -8,7 +8,7 @@
import { AuthError } from '@/components/auth/AuthError';
import { SignupPageQuery } from '@/lib/page-queries/auth/SignupPageQuery';
import { SignupClient } from './SignupClient';
import { SignupClient } from '@/client-wrapper/SignupClient';
export default async function SignupPage({
searchParams,