mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
feat(freight:backoffice): added login page and basic authorization
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"libphonenumber-js": "^1.12.24",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
|
|||||||
@@ -1,99 +1,50 @@
|
|||||||
import {
|
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
useNavigate,
|
|
||||||
useLocation,
|
|
||||||
Routes,
|
|
||||||
Route,
|
|
||||||
Navigate,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
||||||
import {
|
import { LayoutDashboard, ShieldCheck, Users, Building2 } from "lucide-react";
|
||||||
LayoutDashboard,
|
|
||||||
Users,
|
|
||||||
CalendarCheck,
|
|
||||||
Package,
|
|
||||||
MapPin,
|
|
||||||
Train,
|
|
||||||
Receipt,
|
|
||||||
FileText,
|
|
||||||
Settings,
|
|
||||||
FileUp,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
IamLoginPage,
|
|
||||||
LoadingScreen,
|
|
||||||
useAuth,
|
|
||||||
useAuthUser,
|
|
||||||
} from "@tria-plc/iamui-common";
|
|
||||||
|
|
||||||
import BookingsPage from "./pages/bookings/BookingsPage";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
|
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||||
import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
|
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
import DepartmentsPage from "./pages/dashboard/user-management/DepartmentsPage";
|
||||||
import BillingPage from "./pages/billing/BillingPage";
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import TrainsPage from "./pages/trains/TrainsPage";
|
|
||||||
import DashboardPage from "./pages/dashboard/DashboardPage";
|
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
|
||||||
import NewCustomerPage from "./pages/customers/NewCustomerPage";
|
|
||||||
import DocumentsPage from "./pages/documents/DocumentsPage";
|
|
||||||
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
|
||||||
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
|
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
|
||||||
{ label: "Customers", href: "/customers", icon: <Users /> },
|
|
||||||
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
|
||||||
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
|
||||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
|
||||||
{ label: "Trains", href: "/trains", icon: <Train /> },
|
|
||||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
|
||||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
|
||||||
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
|
|
||||||
{
|
{
|
||||||
label: "File Upload Settings",
|
label: "Overview",
|
||||||
href: "/admin/file-uploads",
|
href: "/dashboard/overview",
|
||||||
icon: <FileUp />,
|
icon: <LayoutDashboard />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "User management",
|
||||||
|
href: "/dashboard/user-management",
|
||||||
|
icon: <ShieldCheck />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
href: "/dashboard/user-management/users",
|
||||||
|
icon: <Users />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Roles",
|
||||||
|
href: "/dashboard/user-management/roles",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Departments",
|
||||||
|
href: "/dashboard/user-management/departments",
|
||||||
|
icon: <Building2 />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const App = () => {
|
const DashboardShell = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, loading } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { logout } = useAuthUser();
|
|
||||||
|
|
||||||
if (loading) {
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
return <LoadingScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/auth" element={<IamLoginPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayName = user.name?.en || user.username || user.email || "User";
|
|
||||||
const userEmail = user.email;
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout();
|
|
||||||
[
|
|
||||||
"auth-token",
|
|
||||||
"refresh-token",
|
|
||||||
"auth-user",
|
|
||||||
"current-position-id",
|
|
||||||
"selected-position-id",
|
|
||||||
].forEach((name) => {
|
|
||||||
document.cookie = `${name}=; Max-Age=0; path=/`;
|
|
||||||
});
|
|
||||||
localStorage.clear();
|
|
||||||
window.location.replace("/auth");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
@@ -103,36 +54,50 @@ const App = () => {
|
|||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
enableThemeToggle
|
enableThemeToggle
|
||||||
userName={displayName}
|
userName={displayName}
|
||||||
userEmail={userEmail}
|
userEmail={user?.email}
|
||||||
onLogout={handleLogout}
|
onLogout={logout}
|
||||||
>
|
>
|
||||||
<Routes>
|
<Outlet />
|
||||||
<Route path="/" element={<DashboardPage />} />
|
|
||||||
<Route path="/bookings" element={<BookingsPage />} />
|
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
|
||||||
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
|
||||||
<Route path="/new-customer" element={<NewCustomerPage />} />
|
|
||||||
<Route path="/bookings/new" element={<NewBookingPage />} />
|
|
||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
|
||||||
<Route path="/consignments" element={<ConsignmentsPage />} />
|
|
||||||
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
|
||||||
<Route path="/trains" element={<TrainsPage />} />
|
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
|
||||||
<Route
|
|
||||||
path="/admin/dropdowns"
|
|
||||||
element={<DropdownSettingsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/admin/file-uploads"
|
|
||||||
element={<FileUploadSettingsPage />}
|
|
||||||
/>
|
|
||||||
<Route path="/user-management" element={<Navigate to="/" replace />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
|
<Route
|
||||||
|
path="user-management"
|
||||||
|
element={<Navigate to="/dashboard/user-management/users" replace />}
|
||||||
|
/>
|
||||||
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
|
<Route path="user-management/roles" element={<RolesPage />} />
|
||||||
|
<Route
|
||||||
|
path="user-management/departments"
|
||||||
|
element={<DepartmentsPage />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const LoadingScreen = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background px-6">
|
||||||
|
<div className="rounded-2xl border border-border bg-card px-8 py-6 text-center shadow-sm">
|
||||||
|
<p className="text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
EDR Freight Backoffice
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingScreen;
|
||||||
@@ -1,60 +1,33 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
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 "@edr/ui-common/styles.css";
|
||||||
import "../index.css";
|
import "../index.css";
|
||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import {
|
import { AuthProvider } from "./auth/AuthProvider";
|
||||||
AuthProvider,
|
|
||||||
configureIam,
|
|
||||||
UserProvider,
|
|
||||||
axiosInstance,
|
|
||||||
} from "@tria-plc/iamui-common";
|
|
||||||
|
|
||||||
["auth-token", "refresh-token"].forEach((name) => {
|
const THEME_STORAGE_KEY = "edr-theme";
|
||||||
const entry = document.cookie
|
|
||||||
.split("; ")
|
const applyStoredTheme = () => {
|
||||||
.find((cookie) => cookie.startsWith(`${name}=`));
|
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
const value = entry?.split("=").slice(1).join("=");
|
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
||||||
if (value === "undefined" || value === "null" || value === "") {
|
const theme = storedTheme === "dark" || storedTheme === "light"
|
||||||
document.cookie = `${name}=; Max-Age=0; path=/`;
|
? storedTheme
|
||||||
|
: prefersDark
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
|
||||||
|
if (theme === "dark") {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
document.documentElement.classList.remove("dark");
|
||||||
|
|
||||||
window.__IAM_CONFIG__ = {
|
|
||||||
apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
|
||||||
postLoginPath: "/",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
axiosInstance.interceptors.response.use((response) => {
|
applyStoredTheme();
|
||||||
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 Backoffice",
|
|
||||||
moduleBasePath: "/user-management",
|
|
||||||
backToAppPath: "/",
|
|
||||||
backToAppLabel: "Back to dashboard",
|
|
||||||
};
|
|
||||||
|
|
||||||
configureIam({
|
|
||||||
apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
@@ -64,14 +37,10 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<BrowserRouter>
|
||||||
<BrowserRouter>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<App />
|
||||||
<UserProvider>
|
</AuthProvider>
|
||||||
<App />
|
</BrowserRouter>
|
||||||
</UserProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
281
apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx
Normal file
281
apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { parsePhoneNumberFromString } from "libphonenumber-js";
|
||||||
|
import { Eye, EyeOff, Mail, Smartphone, UserRound } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
|
type LoginMode = "email" | "phone" | "username";
|
||||||
|
|
||||||
|
const loginModes: Array<{
|
||||||
|
value: LoginMode;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Mail;
|
||||||
|
placeholder: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
|
||||||
|
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
|
||||||
|
{ value: "username", label: "Username", icon: UserRound, placeholder: "username" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/;
|
||||||
|
|
||||||
|
const normalizeIdentifier = (mode: LoginMode, value: string) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (mode === "email") {
|
||||||
|
if (!emailPattern.test(trimmed.toLowerCase())) {
|
||||||
|
throw new Error("Enter a valid email address.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "phone") {
|
||||||
|
const parsed = parsePhoneNumberFromString(trimmed, "ET");
|
||||||
|
|
||||||
|
if (!parsed?.isValid()) {
|
||||||
|
throw new Error("Enter a valid Ethiopian phone number.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usernamePattern.test(trimmed)) {
|
||||||
|
throw new Error("Username must be 3-32 characters and use letters, numbers, ., _, or -.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login, verifyMfa } = useAuth();
|
||||||
|
const [mode, setMode] = useState<LoginMode>("email");
|
||||||
|
const [identifier, setIdentifier] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
const [needsMfa, setNeedsMfa] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const currentMode = loginModes.find((item) => item.value === mode)!;
|
||||||
|
const ModeIcon = currentMode.icon;
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = normalizeIdentifier(mode, identifier);
|
||||||
|
setNormalizedIdentifier(normalized);
|
||||||
|
|
||||||
|
const result = await login({ email: normalized, password });
|
||||||
|
if (result.mfaRequired) {
|
||||||
|
setNeedsMfa(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate("/dashboard/overview", { replace: true });
|
||||||
|
} catch {
|
||||||
|
setError("Unable to sign in with those credentials.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyMfa = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
|
||||||
|
navigate("/dashboard/overview", { replace: true });
|
||||||
|
} catch {
|
||||||
|
setError("Unable to verify the one-time code.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(15,118,110,0.14),_transparent_44%),linear-gradient(180deg,_var(--background),_color-mix(in_oklab,_var(--background)_92%,_#0f766e_8%))] px-6 py-10">
|
||||||
|
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<section className="flex flex-col justify-between rounded-[2rem] border border-border/60 bg-card/85 p-8 shadow-[0_24px_80px_rgba(15,23,42,0.10)] backdrop-blur md:p-10">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#0f766e] text-lg font-semibold text-white shadow-lg shadow-[#0f766e]/20">
|
||||||
|
EDR
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-sm font-medium uppercase tracking-[0.3em] text-[#0f766e]">
|
||||||
|
Freight operations
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 max-w-xl text-4xl font-semibold tracking-tight text-foreground md:text-5xl">
|
||||||
|
Backoffice access for internal freight administration.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-xl text-base leading-7 text-muted-foreground">
|
||||||
|
Review operational activity, manage access, and coordinate internal railway workflows from a single dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 rounded-3xl border border-border/60 bg-background/80 p-5 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Region</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-foreground">Ethiopia default phone normalization</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Session</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-foreground">7-day persistent token cookie</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Access</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-foreground">Overview and user management</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex items-center">
|
||||||
|
<div className="w-full rounded-[2rem] border border-border/60 bg-card p-8 shadow-[0_20px_60px_rgba(15,23,42,0.12)] md:p-10">
|
||||||
|
{!needsMfa ? (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium uppercase tracking-[0.2em] text-[#0f766e]">
|
||||||
|
Sign in
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold text-foreground">
|
||||||
|
EDR Backoffice
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Use your email, phone number, or username to access the internal freight dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-foreground">
|
||||||
|
Sign in method
|
||||||
|
<select
|
||||||
|
value={mode}
|
||||||
|
onChange={(event) => setMode(event.target.value as LoginMode)}
|
||||||
|
className="h-12 rounded-xl border border-input bg-background px-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
||||||
|
>
|
||||||
|
{loginModes.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-foreground">
|
||||||
|
{currentMode.label}
|
||||||
|
<div className="relative">
|
||||||
|
<ModeIcon className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
value={identifier}
|
||||||
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
|
placeholder={currentMode.placeholder}
|
||||||
|
className="h-12 w-full rounded-xl border border-input bg-background pl-11 pr-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-foreground">
|
||||||
|
Password
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
className="h-12 w-full rounded-xl border border-input bg-background px-4 pr-12 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((current) => !current)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-[#0f766e] px-4 text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? "Signing in..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-6" onSubmit={handleVerifyMfa}>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium uppercase tracking-[0.2em] text-[#0f766e]">
|
||||||
|
Multi-factor verification
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold text-foreground">
|
||||||
|
Confirm one-time code
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
We sent a verification code for {normalizedIdentifier}. Enter it below to complete sign in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-foreground">
|
||||||
|
Verification code
|
||||||
|
<input
|
||||||
|
value={otp}
|
||||||
|
onChange={(event) => setOtp(event.target.value)}
|
||||||
|
placeholder="Enter the code"
|
||||||
|
className="h-12 w-full rounded-xl border border-input bg-background px-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-12 items-center justify-center rounded-xl border border-input bg-background px-4 text-sm font-medium text-foreground transition hover:bg-accent"
|
||||||
|
onClick={() => {
|
||||||
|
setNeedsMfa(false);
|
||||||
|
setOtp("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="inline-flex h-12 items-center justify-center rounded-xl bg-[#0f766e] px-4 text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? "Verifying..." : "Verify code"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
|
||||||
|
|
||||||
const DashboardPage = () => {
|
|
||||||
return (
|
|
||||||
<FeaturePlaceholder
|
|
||||||
title="Operations Dashboard"
|
|
||||||
description="Track internal freight activity, exceptions, and workload from one backoffice workspace."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardPage;
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||||
|
|
||||||
|
const OverviewPage = () => {
|
||||||
|
return (
|
||||||
|
<FeaturePlaceholder
|
||||||
|
title="Overview"
|
||||||
|
description="Track internal freight operations, monitor account administration, and review the latest backoffice activity from a single operational dashboard."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OverviewPage;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||||
|
|
||||||
|
const DepartmentsPage = () => {
|
||||||
|
return (
|
||||||
|
<FeaturePlaceholder
|
||||||
|
title="Departments"
|
||||||
|
description="Organize internal departments and associate user administration with freight business units."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DepartmentsPage;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||||
|
|
||||||
|
const RolesPage = () => {
|
||||||
|
return (
|
||||||
|
<FeaturePlaceholder
|
||||||
|
title="Roles"
|
||||||
|
description="Define backoffice access roles, capability groups, and permission boundaries for freight administration."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RolesPage;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||||
|
|
||||||
|
const UsersPage = () => {
|
||||||
|
return (
|
||||||
|
<FeaturePlaceholder
|
||||||
|
title="Users"
|
||||||
|
description="Manage backoffice user accounts, activation state, and directory records for internal freight teams."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersPage;
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ReactNode } from "react";
|
import { type MouseEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
export interface SidebarItem {
|
export interface SidebarItem {
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
|
children?: SidebarItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
@@ -27,62 +29,153 @@ const Sidebar = ({
|
|||||||
activeHref,
|
activeHref,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
headerExtra,
|
headerExtra,
|
||||||
}: SidebarProps) => (
|
}: SidebarProps) => {
|
||||||
<aside className="flex w-64 flex-col gap-1 border-r border-sidebar-border bg-sidebar px-3 py-5 ">
|
const activePath = activeHref?.toLowerCase() ?? "";
|
||||||
{title ? (
|
const defaultExpanded = useMemo(
|
||||||
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
() =>
|
||||||
<div className="flex items-center gap-2">
|
items.reduce<Record<string, boolean>>((acc, item) => {
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-sidebar-primary text-sm font-bold text-white">
|
if (item.children?.length) {
|
||||||
{title.charAt(0)}
|
acc[item.href] =
|
||||||
</div>
|
activePath === item.href.toLowerCase() ||
|
||||||
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
activePath.startsWith(`${item.href.toLowerCase()}/`) ||
|
||||||
{title}
|
item.children.some((child) =>
|
||||||
|
activePath.startsWith(child.href.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
[activePath, items],
|
||||||
|
);
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>(defaultExpanded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
||||||
|
}, [defaultExpanded]);
|
||||||
|
|
||||||
|
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||||
|
if (onNavigate) {
|
||||||
|
event.preventDefault();
|
||||||
|
onNavigate(href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex w-64 flex-col gap-1 border-r border-sidebar-border bg-sidebar px-3 py-5 ">
|
||||||
|
{title ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-sidebar-primary text-sm font-bold text-white">
|
||||||
|
{title.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{headerExtra}
|
||||||
</div>
|
</div>
|
||||||
{headerExtra}
|
) : null}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<nav className="flex flex-col gap-1">
|
<nav className="flex flex-col gap-1">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isActive = activeHref?.toLowerCase() === item.href.toLowerCase();
|
const hasChildren = Boolean(item.children?.length);
|
||||||
|
const itemHref = item.href.toLowerCase();
|
||||||
|
const childActive =
|
||||||
|
item.children?.some((child) =>
|
||||||
|
activePath.startsWith(child.href.toLowerCase()),
|
||||||
|
) ?? false;
|
||||||
|
const isActive =
|
||||||
|
activePath === itemHref ||
|
||||||
|
activePath.startsWith(`${itemHref}/`) ||
|
||||||
|
childActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<div key={item.href} className="flex flex-col gap-1">
|
||||||
key={item.href}
|
<div
|
||||||
href={item.href}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (onNavigate) {
|
|
||||||
event.preventDefault();
|
|
||||||
onNavigate(item.href);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className={clsx(
|
|
||||||
"group flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition",
|
|
||||||
isActive
|
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm "
|
|
||||||
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon ? (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
"group flex items-center gap-2 rounded-md transition",
|
||||||
isActive
|
isActive
|
||||||
? "text-white"
|
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||||
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
<a
|
||||||
</span>
|
href={item.href}
|
||||||
) : null}
|
onClick={(event) => navigateTo(event, item.href)}
|
||||||
<span>{item.label}</span>
|
aria-current={isActive ? "page" : undefined}
|
||||||
</a>
|
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm font-medium"
|
||||||
);
|
>
|
||||||
})}
|
{item.icon ? (
|
||||||
</nav>
|
<span
|
||||||
</aside>
|
className={clsx(
|
||||||
);
|
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
||||||
|
isActive
|
||||||
|
? "text-white"
|
||||||
|
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Toggle ${item.label}`}
|
||||||
|
aria-expanded={expanded[item.href] ?? false}
|
||||||
|
onClick={() =>
|
||||||
|
setExpanded((current) => ({
|
||||||
|
...current,
|
||||||
|
[item.href]: !current[item.href],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
"mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md transition",
|
||||||
|
isActive
|
||||||
|
? "text-white/90 hover:bg-white/10"
|
||||||
|
: "text-slate-500 hover:bg-sidebar-accent dark:text-slate-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={clsx(
|
||||||
|
"h-4 w-4 transition-transform",
|
||||||
|
expanded[item.href] ? "rotate-180" : "rotate-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren && expanded[item.href] ? (
|
||||||
|
<div className="ml-4 flex flex-col gap-1 border-l border-sidebar-border/60 pl-3">
|
||||||
|
{item.children!.map((child) => {
|
||||||
|
const childActiveHref = activePath === child.href.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={child.href}
|
||||||
|
href={child.href}
|
||||||
|
onClick={(event) => navigateTo(event, child.href)}
|
||||||
|
aria-current={childActiveHref ? "page" : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-md px-3 py-2 text-sm transition",
|
||||||
|
childActiveHref
|
||||||
|
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||||
|
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Sidebar;
|
export default Sidebar;
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -174,6 +174,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
libphonenumber-js:
|
||||||
|
specifier: ^1.12.24
|
||||||
|
version: 1.13.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.14.0
|
specifier: ^1.14.0
|
||||||
version: 1.14.0(react@18.3.1)
|
version: 1.14.0(react@18.3.1)
|
||||||
|
|||||||
Reference in New Issue
Block a user