Files
edr-platform/apps/edr-freight-web/backoffice/src/shared/hooks/useAuthUser.ts
Nathnael 7fd5616188 fix(backoffice): stop sharing the position cookie with Smart Office
Both apps wrote a cookie named `current-position-id` but stored
different ids in it — freight the `employeePositionId`, Smart Office the
`position.id`. On a shared domain each login overwrote the other's desk
selection, and the loser silently fell back to the first position.

Freight now uses `freight-current-position-id` through a small helper
that reads the old name once, so a session live across the deploy keeps
its desk, and clears it on every write.

Also mounts the position switcher in the freight dashboard header. It
only existed under /performance-management, so on every other page a
two-desk user had no way to switch and was stuck on whatever
`useAuthUser` defaulted to. It now hides below two positions rather than
showing a one-option dropdown to the single-desk majority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:06:05 +00:00

550 lines
15 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
EOtpType,
getProfile,
login,
logout,
OtpVerificationPayload,
refreshAuthToken,
resendOtpCode,
resendVerificationCode,
setFayidaPass,
SetFayidaPasswordPayload,
setPassword,
SetPasswordPayload,
verifyMFAUser,
verifyOTPCode,
} from "@/shared/services/authService";
import Cookies from "js-cookie";
import { MeDto } from "@/shared/dto/user/meDto";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/shared/context/AuthContext";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { VerifyUserPayload } from "@/record-management/services/api/authService";
import {
clearRememberMePreference,
getAuthCookieOptions,
persistRememberMePreference,
setAuthCookies,
} from "@/shared/utils/authPersistence";
import {
getPositionCookie,
setPositionCookie,
} from "@/shared/utils/positionCookie";
import { clearComplaintVerification } from "@/complaints/utils/complaintVerificationStorage";
interface LoginPayload {
email?: string;
phoneNumber?: string;
userName?: string;
password: string;
}
type LoginResponse = { mfaRequired: true } | MeDto; // full user details
export const useAuthUser = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const delegatedPositionId = Cookies.get("delegatedPositionId");
const currentPositionId = getPositionCookie();
const {
setUser,
user: contextUser,
selectedPositionId,
setSelectedPositionId,
showOtpModal,
setShowOtpModal,
rememberMePreference,
setRememberMePreference,
} = useAuth();
const finalizeAuthenticatedSession = async ({
token,
refreshToken,
rememberMe,
}: {
token: string;
refreshToken: string;
rememberMe: boolean;
}) => {
const cookieOptions = getAuthCookieOptions(rememberMe);
persistRememberMePreference(rememberMe);
setAuthCookies({ token, refreshToken, rememberMe });
Cookies.set("i18nextLng", "am", cookieOptions);
const { data } = await getProfile();
const userDetails = data as MeDto;
Cookies.set(
"auth-user",
JSON.stringify({
...userDetails,
id: userDetails.id,
name: userDetails.name,
permissions: userDetails.permissions,
roles: userDetails.roles,
}),
cookieOptions,
);
setUser(userDetails);
if ((userDetails.employee ?? []).length > 0) {
const firstPositionId =
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) {
setSelectedPositionId(firstPositionId);
setPositionCookie(firstPositionId, cookieOptions);
}
}
queryClient.invalidateQueries({ queryKey: ["authUser"] });
return userDetails;
};
const {
data: userDetails,
isLoading,
isFetching,
isError,
refetch,
} = useQuery<MeDto>({
queryKey: [
"authUser",
selectedPositionId,
delegatedPositionId,
currentPositionId,
],
queryFn: async () => {
const { data } = await getProfile();
return data as MeDto;
},
placeholderData: (previousData) => previousData,
staleTime: 5 * 60 * 1000,
retry: false,
enabled: true,
});
useEffect(() => {
if (userDetails) {
setUser(userDetails);
const allPositions = (userDetails.employee ?? []).flatMap(
(emp) => emp.positions ?? [],
);
if (!selectedPositionId && allPositions.length > 0) {
const fallbackId = allPositions[0]?.employeePositionId;
if (fallbackId) {
setSelectedPositionId(fallbackId);
setPositionCookie(fallbackId);
}
} else if (selectedPositionId) {
// Self-heal stale cookies that were set to position.id instead of
// employeePositionId before this distinction was fixed.
const matchesEmployeePositionId = allPositions.some(
(pos) => pos.employeePositionId === selectedPositionId,
);
if (!matchesEmployeePositionId) {
const matchingPosition = allPositions.find(
(pos) => pos.id === selectedPositionId,
);
if (matchingPosition?.employeePositionId) {
setSelectedPositionId(matchingPosition.employeePositionId);
setPositionCookie(matchingPosition.employeePositionId);
}
}
}
}
}, [selectedPositionId, setSelectedPositionId, setUser, userDetails]);
const activePositionLookupId = delegatedPositionId
? currentPositionId || selectedPositionId
: selectedPositionId;
const filteredUserDetailsCandidate: MeDto | undefined = useMemo(
() =>
userDetails
? {
...userDetails,
employee: (userDetails.employee ?? []).map((emp) => ({
...emp,
positions: emp.positions.filter(
(p) =>
p.employeePositionId === activePositionLookupId ||
p.id === activePositionLookupId,
),
})),
}
: undefined,
[activePositionLookupId, userDetails],
);
const hasResolvedActivePosition = useMemo(
() =>
!!filteredUserDetailsCandidate?.employee?.some(
(employee) => employee.positions.length > 0,
),
[filteredUserDetailsCandidate],
);
const [lastResolvedUserDetails, setLastResolvedUserDetails] =
useState<MeDto>();
useEffect(() => {
if (filteredUserDetailsCandidate && hasResolvedActivePosition) {
setLastResolvedUserDetails(filteredUserDetailsCandidate);
}
}, [filteredUserDetailsCandidate, hasResolvedActivePosition]);
const filteredUserDetails =
hasResolvedActivePosition || !isFetching
? filteredUserDetailsCandidate
: (lastResolvedUserDetails ?? filteredUserDetailsCandidate);
const selectedPosition = useMemo(
() =>
(filteredUserDetails?.employee ?? userDetails?.employee ?? [])
.flatMap((employee) => employee.positions)
.find(
(position) =>
position.employeePositionId === activePositionLookupId ||
position.id === activePositionLookupId ||
position.employeePositionId === selectedPositionId ||
position.id === selectedPositionId,
),
[
activePositionLookupId,
filteredUserDetails?.employee,
selectedPositionId,
userDetails?.employee,
],
);
const selectedPositionPermissionKeys = useMemo(
() =>
(filteredUserDetails?.employee ?? [])
.flatMap((employee) => employee.positions)
.flatMap((position) =>
position.permissions.map((permission) => permission.key),
),
[filteredUserDetails],
);
const {
mutate: logoutMutate,
isError: logoutError,
isSuccess: logoutSuccess,
} = useMutation({
mutationFn: async (redirectPath?: string) => {
await logout();
return redirectPath;
},
onSuccess: (redirectPath) => {
// Clear user state
setUser(null);
setSelectedPositionId(null);
// Clear all cookies
Object.keys(Cookies.get()).forEach((cookieName) => {
Cookies.remove(cookieName);
});
// Clear only auth-related localStorage keys
clearRememberMePreference();
setRememberMePreference(false);
clearComplaintVerification();
// Clear all React Query cache to prevent stale data from leaking between users
queryClient.clear();
// Show success message
toast.success("Logged out successfully");
window.location.replace(redirectPath || "/");
},
onError: (error, redirectPath) => {
// Even if API call fails, still clear local data
setUser(null);
setSelectedPositionId(null);
Object.keys(Cookies.get()).forEach((cookieName) => {
Cookies.remove(cookieName);
});
clearRememberMePreference();
setRememberMePreference(false);
clearComplaintVerification();
queryClient.clear();
handleError(error);
window.location.replace(redirectPath || "/");
},
});
const {
mutate: loginMutate,
isPending: isLoggingInMutating,
isError: loginError,
isSuccess: loginSuccess,
} = useMutation({
mutationFn: async ({
payload,
rememberMeValue,
}: {
payload: LoginPayload;
rememberMeValue: boolean;
}) => {
const response = await login(payload);
if (response.data.mfaRequired) {
return { mfaRequired: true, rememberMeValue }; // no "data" at all
}
return { data: response.data, rememberMeValue };
},
onSuccess: async (response) => {
if ("mfaRequired" in response && response.mfaRequired) {
setRememberMePreference(response.rememberMeValue);
setShowOtpModal(true);
return;
}
setRememberMePreference(response.rememberMeValue);
await finalizeAuthenticatedSession({
token: response.data.token,
refreshToken: response.data.refreshToken,
rememberMe: response.rememberMeValue,
});
toast.success("Login Successful", {
description: "Redirecting...",
});
navigate("/homepage");
},
onError: (error) => {
handleError(error);
},
});
const {
mutate: setPasswordMutate,
isPending: isSettingPassword,
isError: setPasswordError,
isSuccess: setPasswordSuccess,
error: setPassError,
} = useMutation({
mutationFn: async (payload: SetPasswordPayload) => {
const response = await setPassword(payload);
return response.data;
},
onSuccess: () => {
toast.success("Password updated successfully", {
description: "You can now log in with your new password.",
});
},
onError: (error) => {
handleError(error);
},
});
const {
mutate: setFayidaPasswordMutate,
isPending: isSettingFayidaPassword,
isError: setFayidaPasswordError,
isSuccess: setFayidaPasswordSuccess,
error: setFayidaPassError,
} = useMutation({
mutationFn: async (payload: SetFayidaPasswordPayload) => {
const response = await setFayidaPass(payload);
return response.data;
},
onSuccess: () => {
toast.success("Password updated successfully", {
description: "You can now log in with your new password.",
});
},
onError: (error) => {
handleError(error);
},
});
const {
mutateAsync: verifyOtpMutate,
isPending: isVerifyingOtp,
isError: isVerifyOtpError,
isSuccess: isVerifyOtpSuccess,
error: setVerifyOtpError,
} = useMutation({
mutationFn: async (payload: OtpVerificationPayload) => {
const response = await verifyOTPCode(payload);
return response; // return full response (including status, headers, data, etc)
},
onSuccess: () => {
toast.success("Phone Number Verified successfully");
},
onError: (error) => {
handleError(error);
},
});
const {
mutate: resendVerificationMutate,
isPending: isResendingCode,
isError: resendError,
isSuccess: resendSuccess,
data: resendData,
error: resendErrorData,
} = useMutation({
mutationFn: async ({
phoneNumber,
email,
}: {
phoneNumber: string;
email?: string;
}) => {
const response = await resendVerificationCode({ phoneNumber, email });
return response;
},
onSuccess: () => {
toast.success("Verification code resent successfully!");
},
onError: (error) => {
handleError(error);
},
});
const {
mutate: resendVerificationCode,
isPending: isResendingOtpCode,
isError: resendOtpError,
isSuccess: resendOtpSuccess,
data: resendOtpData,
error: resendOtpErrorData,
} = useMutation({
mutationFn: async ({
email,
phoneNumber,
}: {
phoneNumber: string;
email?: string;
}) => {
const response = await resendOtpCode({ phoneNumber, email });
return response;
},
onSuccess: () => {
toast.success("OTP code resent successfully!");
},
onError: (error) => {
handleError(error);
},
});
const { mutateAsync: refreshTokenMutate } = useMutation({
mutationFn: async (refreshToken: string) => {
const response = await refreshAuthToken({ refreshToken });
const { token, refreshToken: newRefreshToken } = response.data;
setAuthCookies({
token,
refreshToken: newRefreshToken,
rememberMe: rememberMePreference,
});
return token;
},
});
const {
mutate: verifyMFAMutate,
isPending: isMFAPending,
isError: mfaError,
isSuccess: mfaSuccess,
} = useMutation({
mutationFn: async (payload: VerifyUserPayload) => {
const response = await verifyMFAUser(payload);
return response.data;
},
onSuccess: async (response) => {
await finalizeAuthenticatedSession({
token: response.token,
refreshToken: response.refreshToken,
rememberMe: rememberMePreference,
});
toast.success("Two Factor Authentication Successful", {
description: "Redirecting...",
});
navigate("/homepage");
},
onError: (error) => {
handleError(error);
},
});
return {
userDetails: filteredUserDetails,
unFilteredUserDetails: userDetails,
selectedPosition,
selectedPositionPermissionKeys,
activePositionLookupId,
selectedPositionId,
setSelectedPositionId,
isLoading,
isFetching,
isError,
refetch,
login: loginMutate,
isLoggingIn: isLoggingInMutating,
logout: logoutMutate,
logoutError,
logoutSuccess,
loginSuccess,
loginError,
setPassword: setPasswordMutate,
verifyOtpMutate,
isVerifyingOtp,
isVerifyOtpError,
isVerifyOtpSuccess,
setVerifyOtpError,
isSettingPassword,
setPasswordError: {
message: setPassError?.message,
},
setPasswordSuccess,
resendVerificationCode: resendVerificationMutate,
isResendingCode,
resendError: resendErrorData ? { message: resendErrorData?.message } : null,
resendSuccess,
refreshToken: refreshTokenMutate,
resendOtpCode: resendVerificationCode,
isResendingOtpCode,
resendOtpError: resendOtpErrorData
? { message: resendOtpErrorData?.message }
: null,
resendOtpSuccess,
resendOtpData,
resendData,
setFayidaPassword: setFayidaPasswordMutate,
setFayidaPassError,
setFayidaPasswordSuccess,
setFayidaPasswordError,
isSettingFayidaPassword,
verifyMFA: verifyMFAMutate,
isMFAPending,
mfaError,
mfaSuccess,
};
};