From 53e8b40123a0f55b7c0fc18c47789e4789b0fcab Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 11:38:22 +0300 Subject: [PATCH] refactor(auth): Remove IAM library, implement custom cookie and local storage auth --- .../portal/src/constants/URLS.ts | 2 +- .../portal/src/hooks/useAuth.ts | 90 ++++++++---- apps/edr-freight-web/portal/src/main.tsx | 43 +----- .../portal/src/services/auth.service.ts | 12 ++ apps/edr-freight-web/portal/src/utils/api.ts | 129 +++++++++++++++--- 5 files changed, 190 insertions(+), 86 deletions(-) diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 6bce94b12..84db5e2c2 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -2,7 +2,7 @@ export const URL_CONSTANTS = { AUTH: { LOGIN: "/api/auth/login", REGISTER: "/api/auth/register", - REFRESH_TOKEN: "/auth/refresh-token", + REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", }, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 2bd7b6383..5b626721c 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,22 +1,37 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/services/api"; import type { - GenerateVerificationCodePayload, LoginPayload, LoginResponse, - OtpPayload, - OtpResponse, - SetPasswordPayload, SignupPayload, SignupResponse, + OtpResponse, } from "@/types/auth"; import type { Result } from "@/utils/result"; import { extractApiError } from "@/utils/result"; +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 authQuery = useQuery(api.auth.getMyInfo.queryOptions()); + const authQuery = useQuery( + api.auth.getMyInfo.queryOptions({ + enabled: !!getCookie("auth-token"), + retry: false, + }), + ); const customerQuery = useQuery( api.customers.getByUserId.queryOptions({ @@ -26,16 +41,17 @@ const useAuth = () => { }), ); - const isPending = authQuery.isPending; + const hasToken = !!getCookie("auth-token"); + const isPending = authQuery.isPending && hasToken; const login = async ( payload: LoginPayload, ): Promise> => { try { const res = await api.auth.login.call(payload); - await queryClient.invalidateQueries({ - queryKey: api.auth.getMyInfo.queryKey(), - }); + 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) }; @@ -47,39 +63,59 @@ const useAuth = () => { ): Promise> => { try { const res = await api.auth.createUser.call(payload); + localStorage.setItem("auth-token", `auth-token=${res.token}; path=/`); + localStorage.setItem("userId", res.userId); + const otpCode = res.otp?.split(" ")?.[6] ?? ""; + localStorage.setItem("otp", otpCode); + localStorage.setItem("otp-phone", payload.phoneNumber); + localStorage.setItem("otp-email", payload.email); + api.auth.sendOTP + .call({ phone: payload.phoneNumber, otp: otpCode }) + .catch(() => { }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; } }; - const setPassword = async ( - payload: SetPasswordPayload, - ): Promise> => { + const setPassword = async (data: { + newPassword: string; + confirmPassword: string; + }): Promise> => { try { - await api.auth.setPassword.call(payload); + const userId = localStorage.getItem("userId") ?? ""; + 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), + ); return { success: true, data: undefined }; } catch (err) { return { success: false, error: extractApiError(err) }; } }; - const verifyOTP = async ( - payload: OtpPayload, - ): Promise> => { + const verifyOTP = async (otp: string): Promise> => { try { - const res = await api.auth.verifyOTP.call(payload); + 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 ( - payload: OtpPayload, - ): Promise> => { + const sendOTP = async (otp: string): Promise> => { try { - const res = await api.auth.sendOTP.call(payload); + 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) }; @@ -87,10 +123,16 @@ const useAuth = () => { }; const generateVerificationCode = async ( - payload: GenerateVerificationCodePayload, + type: string, ): Promise> => { try { - const res = await api.auth.generateVerificationCode.call(payload); + 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) }; @@ -114,7 +156,7 @@ const useAuth = () => { }); localStorage.clear(); queryClient.clear(); - window.location.href = "/auth"; + window.location.href = "/login"; }; const invalidate = async () => { diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 45014a8d5..8c4fbeb96 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -2,18 +2,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import "@tria-plc/iamui-common/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; import App from "./App"; -import { - AuthProvider, - configureIam, - UserProvider, - axiosInstance, -} from "@tria-plc/iamui-common"; // Purge cookies that were stored as the literal string "undefined" before the // envelope interceptor fix. Without this, stale sessions would keep sending @@ -29,36 +22,6 @@ import { }); const queryClient = new QueryClient(); -window.__IAM_CONFIG__ = { - apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`, - postLoginPath: "/", -}; - -// Unwrap the StandardResponse envelope ({ success, data, timestamp }) that the -// freight API's ResponseTransformInterceptor adds to every response, so that -// iamui-common can read response.data.token / response.data fields as expected. -axiosInstance.interceptors.response.use((response) => { - if ( - response.data && - typeof response.data === "object" && - "success" in response.data && - "data" in response.data - ) { - response.data = response.data.data; - } - return response; -}); -window.__USER_MANAGEMENT_BRANDING__ = { - organizationName: "EDR Platform", - appName: "EDR Portal", - moduleBasePath: "/user-management", - backToAppPath: "/", - backToAppLabel: "Back to dashboard", -}; - -configureIam({ - apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`, -}); const rootElement = document.getElementById("root"); @@ -70,11 +33,7 @@ createRoot(document.getElementById("root")!).render( - - - - - + , diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index bb162fbea..856d417cc 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -69,6 +69,18 @@ export const authService = { return res.data.data; }, + refreshToken: async () => { + const refreshTokenCookie = document.cookie + .split("; ") + .find((row) => row.startsWith("refresh-token=")) + ?.split("=")[1]; + const res = await client.post>( + URL_CONSTANTS.AUTH.REFRESH_TOKEN, + { refreshToken: refreshTokenCookie }, + ); + return res.data.data; + }, + logout: async () => { const res = await client.patch>( URL_CONSTANTS.AUTH.LOGOUT, diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 5fe426a0c..d152e6aad 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -2,38 +2,129 @@ import { UseQueryOptions, QueryObserverOptions, } from "@tanstack/react-query"; -import axios from "axios"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import { URL_CONSTANTS } from "@/constants/URLS"; -// --------------------------------------------------------------------------- -// Axios client -// --------------------------------------------------------------------------- - -export const client = axios.create({ +const client = axios.create({ baseURL: import.meta.env.VITE_API_URL, }); +function getCookie(name: string): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; +} + +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 clearAuthCookies() { + ["auth-token", "refresh-token", "auth-user", "current-position-id", "selected-position-id"].forEach( + (name) => { + document.cookie = `${name}=; Max-Age=0; path=/`; + }, + ); +} + // Attach auth token to every request client.interceptors.request.use((config) => { - // TODO: replace with secure storage (cookie/localStorage/auth provider) - const token = document.cookie - .split("; ") - .find((row) => row.startsWith("auth-token=")) - ?.split("=")[1]; - + const token = getCookie("auth-token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } - return config; }); -// Handle auth errors globally +// Token refresh state +let isRefreshing = false; +let failedQueue: { + resolve: (token: string) => void; + reject: (error: unknown) => void; +}[] = []; + +function processQueue(error: unknown, token?: string) { + failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error); + } else { + resolve(token!); + } + }); + failedQueue = []; +} + +// Handle auth errors globally with token refresh client.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { - window.location.href = "/auth"; + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + // Don't intercept if: + // - no response (network error) + // - status is not 401 + // - already retried + // - it's the refresh endpoint itself + if ( + !error.response || + error.response.status !== 401 || + originalRequest._retry || + originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN + ) { + return Promise.reject(error); } - return Promise.reject(error); - } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return client(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = getCookie("refresh-token"); + + if (!refreshToken) { + isRefreshing = false; + clearAuthCookies(); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + return Promise.reject(error); + } + + try { + const { data } = await client.post<{ data: { token: string; refreshToken: string } }>( + URL_CONSTANTS.AUTH.REFRESH_TOKEN, + { refreshToken }, + ); + const { token, refreshToken: newRefreshToken } = data.data; + setCookie("auth-token", token, 7); + setCookie("refresh-token", newRefreshToken, 7); + originalRequest.headers.Authorization = `Bearer ${token}`; + processQueue(null, token); + return client(originalRequest); + } catch (refreshError) { + processQueue(refreshError, undefined); + clearAuthCookies(); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + }, ); + +export { client }; +export type { UseQueryOptions, QueryObserverOptions };