refactor(auth): Remove IAM library, implement custom cookie and local storage auth

This commit is contained in:
ghost2023
2026-05-28 11:38:22 +03:00
parent 64356a20e6
commit 53e8b40123
5 changed files with 190 additions and 86 deletions

View File

@@ -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",
},

View File

@@ -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<Result<LoginResponse>> => {
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<Result<SignupResponse>> => {
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<Result<void>> => {
const setPassword = async (data: {
newPassword: string;
confirmPassword: string;
}): Promise<Result<void>> => {
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<Result<OtpResponse>> => {
const verifyOTP = async (otp: string): Promise<Result<OtpResponse>> => {
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<Result<OtpResponse>> => {
const sendOTP = async (otp: string): Promise<Result<OtpResponse>> => {
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<Result<string>> => {
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 () => {

View File

@@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<UserProvider>
<App />
</UserProvider>
</AuthProvider>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,

View File

@@ -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<ApiResponse<LoginResponse>>(
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
{ refreshToken: refreshTokenCookie },
);
return res.data.data;
},
logout: async () => {
const res = await client.patch<ApiResponse<void>>(
URL_CONSTANTS.AUTH.LOGOUT,

View File

@@ -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<string>((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 };