feat(freight:backoffice): added login page and basic authorization

This commit is contained in:
Michael Abebe
2026-05-22 17:55:20 +03:00
parent 2c9ca6f16b
commit 27e2cfcf6b
18 changed files with 918 additions and 227 deletions

View 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>;
};

View 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;
};

View 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);
};

View 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 };

View 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;
}

View 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;
};