mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
feat(freight:backoffice): added login page and basic authorization
This commit is contained in:
138
apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx
Normal file
138
apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { getMeRequest, loginRequest, verifyMfaRequest } from "./api";
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
AUTH_USER_COOKIE,
|
||||
clearSessionCookies,
|
||||
getCookie,
|
||||
setCookie,
|
||||
} from "./cookies";
|
||||
import { applyTokens } from "./http";
|
||||
import type { AuthTokens, AuthUser } from "./types";
|
||||
|
||||
interface LoginPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface VerifyMfaPayload {
|
||||
email: string;
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
login: (payload: LoginPayload) => Promise<{ mfaRequired: boolean }>;
|
||||
verifyMfa: (payload: VerifyMfaPayload) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
const persistUser = (user: AuthUser | null) => {
|
||||
if (user) {
|
||||
setCookie(AUTH_USER_COOKIE, JSON.stringify(user));
|
||||
return;
|
||||
}
|
||||
|
||||
clearSessionCookies();
|
||||
};
|
||||
|
||||
const bootstrapCachedUser = (): AuthUser | null => {
|
||||
const serialized = getCookie(AUTH_USER_COOKIE);
|
||||
if (!serialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(serialized) as AuthUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(() => bootstrapCachedUser());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const mfaEmailRef = useRef<string | null>(null);
|
||||
|
||||
const loadCurrentUser = async () => {
|
||||
const currentUser = await getMeRequest();
|
||||
setUser(currentUser);
|
||||
persistUser(currentUser);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
["auth-token", "refresh-token"].forEach((name) => {
|
||||
const value = getCookie(name);
|
||||
if (value === "undefined" || value === "null" || value === "") {
|
||||
clearSessionCookies();
|
||||
}
|
||||
});
|
||||
|
||||
const bootstrap = async () => {
|
||||
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadCurrentUser();
|
||||
} catch {
|
||||
clearSessionCookies();
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void bootstrap();
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
loading,
|
||||
login: async ({ email, password }) => {
|
||||
const result = await loginRequest({ email, password });
|
||||
|
||||
if (result.mfaRequired) {
|
||||
mfaEmailRef.current = email;
|
||||
return { mfaRequired: true };
|
||||
}
|
||||
|
||||
const tokens = result as AuthTokens;
|
||||
applyTokens(tokens);
|
||||
await loadCurrentUser();
|
||||
mfaEmailRef.current = null;
|
||||
return { mfaRequired: false };
|
||||
},
|
||||
verifyMfa: async ({ email, otp }) => {
|
||||
const identifier = mfaEmailRef.current ?? email;
|
||||
const tokens = await verifyMfaRequest({ email: identifier, otp });
|
||||
applyTokens(tokens);
|
||||
await loadCurrentUser();
|
||||
mfaEmailRef.current = null;
|
||||
},
|
||||
logout: () => {
|
||||
clearSessionCookies();
|
||||
localStorage.clear();
|
||||
setUser(null);
|
||||
window.location.replace("/auth");
|
||||
},
|
||||
}),
|
||||
[loading, user],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
23
apps/edr-freight-web/backoffice/src/auth/api.ts
Normal file
23
apps/edr-freight-web/backoffice/src/auth/api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { api } from "./http";
|
||||
import type { AuthTokens, AuthUser, LoginResponse } from "./types";
|
||||
|
||||
export const loginRequest = async (payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) => {
|
||||
const response = await api.post<LoginResponse>("/auth/login", payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const verifyMfaRequest = async (payload: {
|
||||
email: string;
|
||||
otp: string;
|
||||
}) => {
|
||||
const response = await api.post<AuthTokens>("/auth/mfa-verify", payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getMeRequest = async () => {
|
||||
const response = await api.get<AuthUser>("/auth/me");
|
||||
return response.data;
|
||||
};
|
||||
38
apps/edr-freight-web/backoffice/src/auth/cookies.ts
Normal file
38
apps/edr-freight-web/backoffice/src/auth/cookies.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const DEFAULT_PATH = "/";
|
||||
const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7;
|
||||
|
||||
export const AUTH_TOKEN_COOKIE = "auth-token";
|
||||
export const REFRESH_TOKEN_COOKIE = "refresh-token";
|
||||
export const AUTH_USER_COOKIE = "auth-user";
|
||||
|
||||
export const AUTH_COOKIE_MAX_AGE = SEVEN_DAYS_IN_SECONDS;
|
||||
|
||||
export const getCookie = (name: string) => {
|
||||
const match = document.cookie
|
||||
.split("; ")
|
||||
.find((entry) => entry.startsWith(`${name}=`));
|
||||
|
||||
return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null;
|
||||
};
|
||||
|
||||
export const setCookie = (
|
||||
name: string,
|
||||
value: string,
|
||||
maxAge = AUTH_COOKIE_MAX_AGE,
|
||||
) => {
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; path=${DEFAULT_PATH}; SameSite=Lax`;
|
||||
};
|
||||
|
||||
export const clearCookie = (name: string) => {
|
||||
document.cookie = `${name}=; Max-Age=0; path=${DEFAULT_PATH}`;
|
||||
};
|
||||
|
||||
export const clearSessionCookies = () => {
|
||||
[
|
||||
AUTH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
AUTH_USER_COOKIE,
|
||||
"current-position-id",
|
||||
"selected-position-id",
|
||||
].forEach(clearCookie);
|
||||
};
|
||||
99
apps/edr-freight-web/backoffice/src/auth/http.ts
Normal file
99
apps/edr-freight-web/backoffice/src/auth/http.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import axios from "axios";
|
||||
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
clearSessionCookies,
|
||||
getCookie,
|
||||
setCookie,
|
||||
} from "./cookies";
|
||||
import type { AuthTokens } from "./types";
|
||||
|
||||
type RetriableRequest = {
|
||||
_retry?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
let refreshPromise: Promise<AuthTokens> | null = null;
|
||||
|
||||
const applyTokens = ({ token, refreshToken }: AuthTokens) => {
|
||||
setCookie(AUTH_TOKEN_COOKIE, token);
|
||||
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
|
||||
};
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
api.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;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config as RetriableRequest | undefined;
|
||||
|
||||
if (
|
||||
error.response?.status !== 401 ||
|
||||
!originalRequest ||
|
||||
originalRequest._retry ||
|
||||
originalRequest.url?.includes("/auth/login") ||
|
||||
originalRequest.url?.includes("/auth/mfa-verify") ||
|
||||
originalRequest.url?.includes("/auth/refresh-token")
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
||||
if (!refreshToken) {
|
||||
clearSessionCookies();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
refreshPromise ??= api
|
||||
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
||||
.then((response) => response.data)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
const tokens = await refreshPromise;
|
||||
applyTokens(tokens);
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${tokens.token}`,
|
||||
};
|
||||
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
clearSessionCookies();
|
||||
window.location.replace("/auth");
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export { api, applyTokens };
|
||||
18
apps/edr-freight-web/backoffice/src/auth/types.ts
Normal file
18
apps/edr-freight-web/backoffice/src/auth/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface AuthUser {
|
||||
id?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
name?: {
|
||||
en?: string;
|
||||
am?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse extends Partial<AuthTokens> {
|
||||
mfaRequired?: boolean;
|
||||
}
|
||||
13
apps/edr-freight-web/backoffice/src/auth/useAuth.ts
Normal file
13
apps/edr-freight-web/backoffice/src/auth/useAuth.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { AuthContext } from "./AuthProvider";
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
Reference in New Issue
Block a user