312 lines
8.3 KiB
TypeScript
312 lines
8.3 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { getCurrentUser, signIn, signOut as signOutRequest } from '@/shared/api/auth';
|
|
import { AuthExpiredError } from '@/shared/api/httpClient';
|
|
import { AUTH_EXPIRED_EVENT } from '@/shared/constants/auth';
|
|
import {
|
|
AUTH_MODAL_SIGNIN_CLOSE_DELAY_MS,
|
|
AUTH_MODAL_SIGNUP_CLOSE_DELAY_MS,
|
|
} from '@/shared/constants/auth';
|
|
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
|
|
import { CurrentUser } from '@/shared/types/auth';
|
|
import { UserProfile } from '@/shared/types/app';
|
|
import { toUserProfile } from '@/business/auth/mappers';
|
|
import { useCampusCatalog } from '@/business/campuses/hooks';
|
|
import { AuthActionResult, AuthSessionState } from '@/business/auth/types';
|
|
import { getErrorMessage, getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
|
import type {
|
|
AuthModalDraft,
|
|
AuthModalMode,
|
|
AuthModalWorkflowInput,
|
|
AuthSignupStep,
|
|
} from '@/business/auth/types';
|
|
import {
|
|
getNextSignupStep,
|
|
getPreviousSignupStep,
|
|
getSignupCampusName,
|
|
validateSignupStepOne,
|
|
} from '@/business/auth/selectors';
|
|
import type { CampusId, UserRole } from '@/shared/types/app';
|
|
|
|
export function useAuthSession(): AuthSessionState {
|
|
const navigate = useNavigate();
|
|
const [user, setUser] = useState<CurrentUser | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const clearSession = useCallback(() => {
|
|
setUser(null);
|
|
}, []);
|
|
|
|
const redirectToLogin = useCallback(() => {
|
|
navigate(APP_ROUTE_PATHS.login, { replace: true });
|
|
}, [navigate]);
|
|
|
|
const expireSession = useCallback(() => {
|
|
clearSession();
|
|
redirectToLogin();
|
|
}, [clearSession, redirectToLogin]);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener(AUTH_EXPIRED_EVENT, expireSession);
|
|
|
|
return () => {
|
|
window.removeEventListener(AUTH_EXPIRED_EVENT, expireSession);
|
|
};
|
|
}, [expireSession]);
|
|
|
|
useEffect(() => {
|
|
let isActive = true;
|
|
|
|
void getCurrentUser()
|
|
.then((currentUser) => {
|
|
if (isActive) {
|
|
setUser(currentUser);
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (isActive) {
|
|
clearSession();
|
|
if (error instanceof AuthExpiredError) {
|
|
expireSession();
|
|
}
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (isActive) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
isActive = false;
|
|
};
|
|
}, [clearSession, expireSession]);
|
|
|
|
const handleSignIn = useCallback(async (email: string, password: string): Promise<AuthActionResult> => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
const currentUser = await signIn({ email, password });
|
|
setUser(currentUser);
|
|
return { error: null };
|
|
} catch (error) {
|
|
clearSession();
|
|
if (error instanceof AuthExpiredError) {
|
|
expireSession();
|
|
}
|
|
return { error: getErrorMessage(error, 'Sign in failed') };
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [clearSession, expireSession]);
|
|
|
|
const signUp = useCallback(async (): Promise<AuthActionResult> => ({
|
|
error:
|
|
'Account creation is not available until backend product roles and campus assignment are implemented.',
|
|
}), []);
|
|
|
|
const signOut = useCallback(async (): Promise<AuthActionResult> => {
|
|
try {
|
|
await signOutRequest();
|
|
clearSession();
|
|
return { error: null };
|
|
} catch (error) {
|
|
if (error instanceof AuthExpiredError) {
|
|
expireSession();
|
|
return { error: null };
|
|
}
|
|
return { error: getErrorMessage(error, 'Sign out failed') };
|
|
}
|
|
}, [clearSession, expireSession]);
|
|
|
|
const updateProfile = useCallback(async (): Promise<AuthActionResult> => ({
|
|
error: 'Profile updates are handled by the profile page.',
|
|
}), []);
|
|
|
|
const refreshUser = useCallback(async (): Promise<void> => {
|
|
try {
|
|
const currentUser = await getCurrentUser();
|
|
setUser(currentUser);
|
|
} catch (error) {
|
|
if (error instanceof AuthExpiredError) {
|
|
expireSession();
|
|
}
|
|
}
|
|
}, [expireSession]);
|
|
|
|
const profile: UserProfile | null = useMemo(() => (user ? toUserProfile(user) : null), [user]);
|
|
|
|
return {
|
|
user,
|
|
profile,
|
|
loading,
|
|
signIn: handleSignIn,
|
|
signUp,
|
|
signOut,
|
|
updateProfile,
|
|
refreshUser,
|
|
isAuthenticated: Boolean(user),
|
|
};
|
|
}
|
|
|
|
const initialAuthModalDraft: AuthModalDraft = {
|
|
email: '',
|
|
password: '',
|
|
confirmPassword: '',
|
|
fullName: '',
|
|
role: 'teacher',
|
|
campus: '',
|
|
showPassword: false,
|
|
};
|
|
|
|
export function useAuthModalWorkflow({
|
|
signIn: signInAction,
|
|
signUp: signUpAction,
|
|
onClose,
|
|
}: AuthModalWorkflowInput) {
|
|
const campusCatalog = useCampusCatalog();
|
|
const [mode, setMode] = useState<AuthModalMode>('signin');
|
|
const [signupStep, setSignupStep] = useState<AuthSignupStep>(1);
|
|
const [draft, setDraft] = useState<AuthModalDraft>(initialAuthModalDraft);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
|
|
const resetForm = useCallback(() => {
|
|
setDraft(initialAuthModalDraft);
|
|
setError(null);
|
|
setSuccess(null);
|
|
setMode('signin');
|
|
setSignupStep(1);
|
|
}, []);
|
|
|
|
const closeAfterSuccess = useCallback((delayMs: number) => {
|
|
window.setTimeout(() => {
|
|
onClose();
|
|
resetForm();
|
|
}, delayMs);
|
|
}, [onClose, resetForm]);
|
|
|
|
const updateDraft = useCallback((patch: Partial<AuthModalDraft>) => {
|
|
setDraft((currentDraft) => ({ ...currentDraft, ...patch }));
|
|
}, []);
|
|
|
|
const handleSignIn = useCallback(async () => {
|
|
setError(null);
|
|
setSuccess(null);
|
|
setLoading(true);
|
|
|
|
const result = await signInAction(draft.email, draft.password);
|
|
setLoading(false);
|
|
|
|
if (result.error) {
|
|
setError(result.error);
|
|
return;
|
|
}
|
|
|
|
setSuccess('Signed in successfully!');
|
|
closeAfterSuccess(AUTH_MODAL_SIGNIN_CLOSE_DELAY_MS);
|
|
}, [closeAfterSuccess, draft.email, draft.password, signInAction]);
|
|
|
|
const handleSignUp = useCallback(async () => {
|
|
setError(null);
|
|
setSuccess(null);
|
|
|
|
const campusName = getSignupCampusName(draft.campus, campusCatalog.campuses);
|
|
|
|
if (!campusName) {
|
|
setError(campusCatalog.error ? 'Campus list is unavailable. Please try again.' : 'Please select your campus mascot');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
const result = await signUpAction(draft.email, draft.password, draft.fullName, draft.role, campusName);
|
|
setLoading(false);
|
|
|
|
if (result.error) {
|
|
setError(result.error);
|
|
return;
|
|
}
|
|
|
|
setSuccess('Account created! You are now signed in.');
|
|
closeAfterSuccess(AUTH_MODAL_SIGNUP_CLOSE_DELAY_MS);
|
|
}, [
|
|
campusCatalog.campuses,
|
|
campusCatalog.error,
|
|
closeAfterSuccess,
|
|
draft.campus,
|
|
draft.email,
|
|
draft.fullName,
|
|
draft.password,
|
|
draft.role,
|
|
signUpAction,
|
|
]);
|
|
|
|
const goToNextStep = useCallback(() => {
|
|
setError(null);
|
|
|
|
if (signupStep === 1) {
|
|
const validationError = validateSignupStepOne(draft);
|
|
if (validationError) {
|
|
setError(validationError);
|
|
return;
|
|
}
|
|
setSignupStep(2);
|
|
return;
|
|
}
|
|
|
|
if (signupStep === 2) {
|
|
setSignupStep(3);
|
|
return;
|
|
}
|
|
|
|
void handleSignUp();
|
|
}, [draft, handleSignUp, signupStep]);
|
|
|
|
const goToPreviousStep = useCallback(() => {
|
|
setError(null);
|
|
setSignupStep((currentStep) => getPreviousSignupStep(currentStep));
|
|
}, []);
|
|
|
|
const handleClose = useCallback(() => {
|
|
resetForm();
|
|
onClose();
|
|
}, [onClose, resetForm]);
|
|
|
|
const switchMode = useCallback((nextMode: AuthModalMode) => {
|
|
setMode(nextMode);
|
|
setError(null);
|
|
setSuccess(null);
|
|
setSignupStep(1);
|
|
}, []);
|
|
|
|
const selectedCampusId = draft.campus;
|
|
|
|
return {
|
|
state: {
|
|
mode,
|
|
signupStep,
|
|
draft,
|
|
loading,
|
|
error,
|
|
success,
|
|
selectedCampusId,
|
|
campuses: campusCatalog.campuses,
|
|
campusesLoading: campusCatalog.isLoading,
|
|
campusesError: getOptionalErrorMessage(campusCatalog.error),
|
|
},
|
|
actions: {
|
|
updateDraft,
|
|
setRole: (role: UserRole) => updateDraft({ role }),
|
|
setCampus: (campus: CampusId | '') => updateDraft({ campus }),
|
|
setShowPassword: (showPassword: boolean) => updateDraft({ showPassword }),
|
|
handleSignIn,
|
|
goToNextStep,
|
|
goToPreviousStep,
|
|
handleClose,
|
|
switchMode,
|
|
getNextSignupStep,
|
|
},
|
|
};
|
|
}
|