mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 10:52:53 +00:00
refactor(auth): Remove IAM library, implement custom cookie and local storage auth
This commit is contained in:
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user