mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(freight:backoffice): added login page and basic authorization
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"libphonenumber-js": "^1.12.24",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.6",
|
||||
|
||||
@@ -1,99 +1,50 @@
|
||||
import {
|
||||
useNavigate,
|
||||
useLocation,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
CalendarCheck,
|
||||
Package,
|
||||
MapPin,
|
||||
Train,
|
||||
Receipt,
|
||||
FileText,
|
||||
Settings,
|
||||
FileUp,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
IamLoginPage,
|
||||
LoadingScreen,
|
||||
useAuth,
|
||||
useAuthUser,
|
||||
} from "@tria-plc/iamui-common";
|
||||
import { LayoutDashboard, ShieldCheck, Users, Building2 } from "lucide-react";
|
||||
|
||||
import BookingsPage from "./pages/bookings/BookingsPage";
|
||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
|
||||
import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
import BillingPage from "./pages/billing/BillingPage";
|
||||
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";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import LoginPage from "./pages/auth/LoginPage";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||
import DepartmentsPage from "./pages/dashboard/user-management/DepartmentsPage";
|
||||
import LoadingScreen from "./components/LoadingScreen";
|
||||
|
||||
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",
|
||||
href: "/admin/file-uploads",
|
||||
icon: <FileUp />,
|
||||
label: "Overview",
|
||||
href: "/dashboard/overview",
|
||||
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 location = useLocation();
|
||||
const { user, loading } = useAuth();
|
||||
const { logout } = useAuthUser();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
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");
|
||||
};
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
@@ -103,36 +54,50 @@ const App = () => {
|
||||
onNavigate={navigate}
|
||||
enableThemeToggle
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
onLogout={handleLogout}
|
||||
userEmail={user?.email}
|
||||
onLogout={logout}
|
||||
>
|
||||
<Routes>
|
||||
<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>
|
||||
<Outlet />
|
||||
</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;
|
||||
|
||||
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 { 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";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
|
||||
["auth-token", "refresh-token"].forEach((name) => {
|
||||
const entry = document.cookie
|
||||
.split("; ")
|
||||
.find((cookie) => cookie.startsWith(`${name}=`));
|
||||
const value = entry?.split("=").slice(1).join("=");
|
||||
if (value === "undefined" || value === "null" || value === "") {
|
||||
document.cookie = `${name}=; Max-Age=0; path=/`;
|
||||
const THEME_STORAGE_KEY = "edr-theme";
|
||||
|
||||
const applyStoredTheme = () => {
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
||||
const theme = storedTheme === "dark" || storedTheme === "light"
|
||||
? storedTheme
|
||||
: prefersDark
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
window.__IAM_CONFIG__ = {
|
||||
apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
||||
postLoginPath: "/",
|
||||
document.documentElement.classList.remove("dark");
|
||||
};
|
||||
|
||||
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 Backoffice",
|
||||
moduleBasePath: "/user-management",
|
||||
backToAppPath: "/",
|
||||
backToAppLabel: "Back to dashboard",
|
||||
};
|
||||
|
||||
configureIam({
|
||||
apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
||||
});
|
||||
applyStoredTheme();
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
@@ -64,14 +37,10 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<UserProvider>
|
||||
<App />
|
||||
</UserProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</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 { ChevronDown } from "lucide-react";
|
||||
|
||||
export interface SidebarItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: ReactNode;
|
||||
children?: SidebarItem[];
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
@@ -27,62 +29,153 @@ const Sidebar = ({
|
||||
activeHref,
|
||||
onNavigate,
|
||||
headerExtra,
|
||||
}: SidebarProps) => (
|
||||
<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}
|
||||
}: SidebarProps) => {
|
||||
const activePath = activeHref?.toLowerCase() ?? "";
|
||||
const defaultExpanded = useMemo(
|
||||
() =>
|
||||
items.reduce<Record<string, boolean>>((acc, item) => {
|
||||
if (item.children?.length) {
|
||||
acc[item.href] =
|
||||
activePath === item.href.toLowerCase() ||
|
||||
activePath.startsWith(`${item.href.toLowerCase()}/`) ||
|
||||
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>
|
||||
{headerExtra}
|
||||
</div>
|
||||
{headerExtra}
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const isActive = activeHref?.toLowerCase() === item.href.toLowerCase();
|
||||
<nav className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
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 (
|
||||
<a
|
||||
key={item.href}
|
||||
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
|
||||
return (
|
||||
<div key={item.href} className="flex flex-col gap-1">
|
||||
<div
|
||||
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
|
||||
? "text-white"
|
||||
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(event) => navigateTo(event, item.href)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm font-medium"
|
||||
>
|
||||
{item.icon ? (
|
||||
<span
|
||||
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;
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -174,6 +174,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
libphonenumber-js:
|
||||
specifier: ^1.12.24
|
||||
version: 1.13.1
|
||||
lucide-react:
|
||||
specifier: ^1.14.0
|
||||
version: 1.14.0(react@18.3.1)
|
||||
|
||||
Reference in New Issue
Block a user