mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
308 lines
9.6 KiB
TypeScript
308 lines
9.6 KiB
TypeScript
import { api } from "@/services/api";
|
|
import type { ProfileTypeValue } from "@/services/companies.service";
|
|
import { companiesService } from "@/services/companies.service";
|
|
import type {
|
|
LoginPayload,
|
|
LoginResponse,
|
|
OtpResponse,
|
|
SignupPayload,
|
|
SignupResponse,
|
|
} from "@/types/auth";
|
|
import type { Result } from "@/utils/result";
|
|
import { extractApiError } from "@/utils/result";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
function setCookie(name: string, value: string, days: number) {
|
|
const expires = new Date();
|
|
expires.setDate(expires.getDate() + days);
|
|
document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`;
|
|
}
|
|
|
|
function getCookie(name: string): string | undefined {
|
|
return document.cookie
|
|
.split("; ")
|
|
.find((row) => row.startsWith(`${name}=`))
|
|
?.split("=")[1];
|
|
}
|
|
|
|
const useAuth = () => {
|
|
const queryClient = useQueryClient();
|
|
const hasToken = !!getCookie("auth-token");
|
|
|
|
const authQuery = useQuery(
|
|
api.auth.getMyInfo.queryOptions({
|
|
enabled: hasToken,
|
|
retry: false,
|
|
refetchOnMount: false,
|
|
refetchOnReconnect: false,
|
|
staleTime: 10 * 60 * 1000,
|
|
refetchOnWindowFocus: false,
|
|
}),
|
|
);
|
|
|
|
const companyQuery = useQuery(
|
|
api.companies.getInfo.queryOptions({
|
|
enabled: !!authQuery.data?.id,
|
|
retry: false,
|
|
|
|
staleTime(query) {
|
|
// Fast-poll while anything is awaiting a backoffice decision: an
|
|
// unapproved role, or a pending profile-edit review. This surfaces
|
|
// approvals/rejections to the portal within a minute.
|
|
if (
|
|
query.state.data?.review?.status === "pending" ||
|
|
query.state.data?.company?.companyProfiles?.find(
|
|
(p) => p.status !== "active",
|
|
)
|
|
)
|
|
return 60;
|
|
|
|
return 10 * 60 * 1000;
|
|
},
|
|
refetchOnWindowFocus: false,
|
|
}),
|
|
);
|
|
|
|
const isPending = authQuery.isPending && hasToken;
|
|
const isAuthenticated = hasToken && !!authQuery.data && !authQuery.isError;
|
|
|
|
const login = async (
|
|
payload: LoginPayload,
|
|
): Promise<Result<LoginResponse>> => {
|
|
try {
|
|
const res = await api.auth.login.call(payload);
|
|
setCookie("auth-token", res.token, 7);
|
|
setCookie("refresh-token", res.refreshToken, 7);
|
|
await authQuery.refetch();
|
|
return { success: true, data: res };
|
|
} catch (err) {
|
|
return { success: false, error: extractApiError(err) };
|
|
}
|
|
};
|
|
|
|
const signup = async (
|
|
payload: SignupPayload,
|
|
): Promise<Result<SignupResponse>> => {
|
|
try {
|
|
const res = await api.auth.createUser.call(payload);
|
|
setCookie("auth-token", res.token, 7);
|
|
setCookie("refresh-token", res.refreshToken, 7);
|
|
await authQuery.refetch();
|
|
return { success: true, data: res };
|
|
} catch (err) {
|
|
return { success: false, error: extractApiError(err) };
|
|
}
|
|
};
|
|
|
|
const setPassword = async (data: {
|
|
newPassword: string;
|
|
confirmPassword: string;
|
|
}): Promise<Result<void>> => {
|
|
try {
|
|
const userId = authQuery.data?.id ?? "";
|
|
const email = localStorage.getItem("otp-email") ?? "";
|
|
const verificationCode = localStorage.getItem("otp") ?? "";
|
|
await api.auth.setPassword.call({
|
|
newPassword: data.newPassword,
|
|
confirmPassword: data.confirmPassword,
|
|
userId,
|
|
email,
|
|
verificationCode,
|
|
});
|
|
["userId", "otp", "otp-phone", "otp-email"].forEach((k) =>
|
|
localStorage.removeItem(k),
|
|
);
|
|
await queryClient.invalidateQueries({
|
|
queryKey: api.auth.getMyInfo.queryKey(),
|
|
});
|
|
return { success: true, data: undefined };
|
|
} catch (err) {
|
|
return { success: false, error: extractApiError(err) };
|
|
}
|
|
};
|
|
|
|
const verifyOTP = async (otp: string): Promise<Result<OtpResponse>> => {
|
|
try {
|
|
const phone = localStorage.getItem("otp-phone") ?? "";
|
|
const res = await api.auth.verifyOTP.call({ phone, otp });
|
|
return { success: true, data: res };
|
|
} catch (err) {
|
|
return { success: false, error: extractApiError(err) };
|
|
}
|
|
};
|
|
|
|
const sendOTP = async (otp: string): Promise<Result<OtpResponse>> => {
|
|
try {
|
|
const phone = localStorage.getItem("otp-phone") ?? "";
|
|
const res = await api.auth.sendOTP.call({ phone, otp });
|
|
return { success: true, data: res };
|
|
} catch (err) {
|
|
return { success: false, error: extractApiError(err) };
|
|
}
|
|
};
|
|
|
|
const generateVerificationCode = async (
|
|
type: string,
|
|
): Promise<Result<string>> => {
|
|
try {
|
|
const email = localStorage.getItem("otp-email") ?? "";
|
|
const phoneNumber = localStorage.getItem("otp-phone") ?? "";
|
|
const res = await api.auth.generateVerificationCode.call({
|
|
email,
|
|
phoneNumber,
|
|
type,
|
|
});
|
|
return { success: true, data: res };
|
|
} catch (err) {
|
|
return { success: false, error: extractApiError(err) };
|
|
}
|
|
};
|
|
|
|
// Active-mode (importer/exporter) state, sourced from the persisted profile.
|
|
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
|
const activeProfileType = companyInfo?.profile?.activeProfileType ?? null;
|
|
const activeCompanyProfileId =
|
|
companyInfo?.profile?.activeCompanyProfileId ?? null;
|
|
const companyType = companyInfo?.company?.type ?? null;
|
|
const companyStatus = companyInfo?.company?.status ?? null;
|
|
// A company can create bookings only once an admin has approved it (active).
|
|
const isCompanyApproved = companyStatus === "active";
|
|
const onboardingCompleted =
|
|
companyInfo?.profile?.onboardingCompleted ?? false;
|
|
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
|
|
|
// Booking is gated on backoffice approval of the active operational profile:
|
|
// a customer can only book under a profile once its status is "active".
|
|
const activeProfile =
|
|
companyInfo?.company?.companyProfiles?.find(
|
|
(p) => p.id === activeCompanyProfileId,
|
|
) ?? null;
|
|
const activeProfileStatus = activeProfile?.status ?? null;
|
|
const canBook = activeProfileStatus === "active";
|
|
|
|
// Profile-edit review: while a change request is pending the customer is
|
|
// locked out of editing and of creating new contracts/bookings; a rejected
|
|
// request surfaces the reviewer note so they can amend and resubmit.
|
|
const review = companyInfo?.review ?? null;
|
|
const reviewStatus = review?.status ?? null;
|
|
const reviewNote = review?.note ?? null;
|
|
const isUnderReview = reviewStatus === "pending";
|
|
|
|
/** Refetch everything scoped to the active operational profile. */
|
|
const invalidateScopedData = async () => {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({
|
|
queryKey: api.companies.getInfo.queryKey(),
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
queryKey: api.companies.getDashboard.queryKey(),
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: ["bookings"] }),
|
|
]);
|
|
};
|
|
|
|
const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
|
|
try {
|
|
await api.companies.setActiveMode.call({ type });
|
|
await invalidateScopedData();
|
|
return { success: true, data: undefined };
|
|
} catch (err) {
|
|
return { success: false, error: extractApiError(err) };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Add an operational role. The new role starts pending review, so the active
|
|
* mode is left untouched — the user keeps working under their approved role.
|
|
*/
|
|
const createProfile = async (
|
|
type: ProfileTypeValue,
|
|
licenseFiles: File[],
|
|
): Promise<Result<void>> => {
|
|
try {
|
|
const created = await api.companies.createCompanyProfile.call({ type });
|
|
if (licenseFiles.length > 0) {
|
|
await companiesService.uploadProfileLicense(created.id, licenseFiles);
|
|
}
|
|
await invalidateScopedData();
|
|
return { success: true, data: undefined };
|
|
} catch (err) {
|
|
return { success: false, error: extractApiError(err) };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Resubmit a rejected operational role for approval — optionally replacing its
|
|
* business license first (the common reason a role is rejected) — then refresh.
|
|
*/
|
|
const reapplyProfile = async (
|
|
profileId: string,
|
|
licenseFiles: File[] = [],
|
|
): Promise<Result<void>> => {
|
|
try {
|
|
if (licenseFiles.length > 0) {
|
|
await companiesService.uploadProfileLicense(profileId, licenseFiles);
|
|
}
|
|
await api.companies.reapplyProfile.call({ profileId });
|
|
await invalidateScopedData();
|
|
return { success: true, data: undefined };
|
|
} catch (err) {
|
|
return { success: false, error: extractApiError(err) };
|
|
}
|
|
};
|
|
|
|
const logout = async () => {
|
|
try {
|
|
await api.auth.logout.call();
|
|
} catch {
|
|
// proceed with client-side cleanup even if server call fails
|
|
}
|
|
[
|
|
"auth-token",
|
|
"refresh-token",
|
|
"auth-user",
|
|
"current-position-id",
|
|
"selected-position-id",
|
|
].forEach((name) => {
|
|
document.cookie = `${name}=; Max-Age=0; path=/`;
|
|
});
|
|
localStorage.clear();
|
|
queryClient.clear();
|
|
};
|
|
|
|
return {
|
|
isPending,
|
|
isAuthenticated,
|
|
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
|
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
|
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
|
activeProfileType,
|
|
activeCompanyProfileId,
|
|
activeProfileStatus,
|
|
canBook,
|
|
companyType,
|
|
companyStatus,
|
|
isCompanyApproved,
|
|
reviewStatus,
|
|
reviewNote,
|
|
isUnderReview,
|
|
onboardingCompleted,
|
|
onboardingStep,
|
|
switchMode,
|
|
createProfile,
|
|
reapplyProfile,
|
|
login,
|
|
signup,
|
|
setPassword,
|
|
verifyOTP,
|
|
sendOTP,
|
|
generateVerificationCode,
|
|
logout,
|
|
authQuery,
|
|
companyQuery,
|
|
customerQuery: companyQuery,
|
|
};
|
|
};
|
|
|
|
export default useAuth;
|