diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index d4f4d41c5..7ae00f78b 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a7eaff0fc..ce4d05489 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: }, - { label: "Customers", href: "/customers", icon: }, - { label: "Bookings", href: "/bookings", icon: }, - { label: "Consignments", href: "/consignments", icon: }, - { label: "Tracking", href: "/tracking", icon: }, - { label: "Trains", href: "/trains", icon: }, - { label: "Billing", href: "/billing", icon: }, - { label: "Documents", href: "/documents", icon: }, - { label: "Dropdown Settings", href: "/admin/dropdowns", icon: }, { - label: "File Upload Settings", - href: "/admin/file-uploads", - icon: , + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "User management", + href: "/dashboard/user-management", + icon: , + children: [ + { + label: "Users", + href: "/dashboard/user-management/users", + icon: , + }, + { + label: "Roles", + href: "/dashboard/user-management/roles", + }, + { + label: "Departments", + href: "/dashboard/user-management/departments", + icon: , + }, + ], }, ]; -const App = () => { +const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, loading } = useAuth(); - const { logout } = useAuthUser(); + const { user, logout } = useAuth(); - if (loading) { - return ; - } - - if (!user) { - return ( - - } /> - } /> - - ); - } - - 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 ( { onNavigate={navigate} enableThemeToggle userName={displayName} - userEmail={userEmail} - onLogout={handleLogout} + userEmail={user?.email} + onLogout={logout} > - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } - /> - } /> - } /> - + ); }; +const App = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ( + + } /> + } /> + + ); + } + + return ( + + } /> + } /> + }> + } /> + } + /> + } /> + } /> + } + /> + + } /> + + ); +}; + export default App; diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx new file mode 100644 index 000000000..d49a0e9d9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -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; + logout: () => void; +} + +export const AuthContext = createContext(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(() => bootstrapCachedUser()); + const [loading, setLoading] = useState(true); + const mfaEmailRef = useRef(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( + () => ({ + 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 {children}; +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/api.ts b/apps/edr-freight-web/backoffice/src/auth/api.ts new file mode 100644 index 000000000..8c5b11b54 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/api.ts @@ -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("/auth/login", payload); + return response.data; +}; + +export const verifyMfaRequest = async (payload: { + email: string; + otp: string; +}) => { + const response = await api.post("/auth/mfa-verify", payload); + return response.data; +}; + +export const getMeRequest = async () => { + const response = await api.get("/auth/me"); + return response.data; +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/cookies.ts b/apps/edr-freight-web/backoffice/src/auth/cookies.ts new file mode 100644 index 000000000..0292f7f38 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/cookies.ts @@ -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); +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts new file mode 100644 index 000000000..115a0d9d1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -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; + url?: string; +}; + +const api = axios.create({ + baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`, + withCredentials: true, +}); + +let refreshPromise: Promise | 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("/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 }; diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts new file mode 100644 index 000000000..736fd9a36 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -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 { + mfaRequired?: boolean; +} diff --git a/apps/edr-freight-web/backoffice/src/auth/useAuth.ts b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts new file mode 100644 index 000000000..e454bc509 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts @@ -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; +}; diff --git a/apps/edr-freight-web/backoffice/src/components/LoadingScreen.tsx b/apps/edr-freight-web/backoffice/src/components/LoadingScreen.tsx new file mode 100644 index 000000000..aedae2f7c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/LoadingScreen.tsx @@ -0,0 +1,14 @@ +const LoadingScreen = () => { + return ( +
+
+

+ EDR Freight Backoffice +

+

Loading...

+
+
+ ); +}; + +export default LoadingScreen; diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index bfcba7cf6..819cbfcec 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -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( - - - - - - - - - + + + + + , ); diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx new file mode 100644 index 000000000..82e5757a8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -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("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(null); + + const currentMode = loginModes.find((item) => item.value === mode)!; + const ModeIcon = currentMode.icon; + + const handleSubmit = async (event: FormEvent) => { + 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) => { + 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 ( +
+
+
+
+
+ EDR +
+

+ Freight operations +

+

+ Backoffice access for internal freight administration. +

+

+ Review operational activity, manage access, and coordinate internal railway workflows from a single dashboard. +

+
+ +
+
+

Region

+

Ethiopia default phone normalization

+
+
+

Session

+

7-day persistent token cookie

+
+
+

Access

+

Overview and user management

+
+
+
+ +
+
+ {!needsMfa ? ( +
+
+

+ Sign in +

+

+ EDR Backoffice +

+

+ Use your email, phone number, or username to access the internal freight dashboard. +

+
+ +
+ + + + + +
+ + {error ? ( +
+ {error} +
+ ) : null} + + +
+ ) : ( +
+
+

+ Multi-factor verification +

+

+ Confirm one-time code +

+

+ We sent a verification code for {normalizedIdentifier}. Enter it below to complete sign in. +

+
+ + + + {error ? ( +
+ {error} +
+ ) : null} + +
+ + +
+
+ )} +
+
+
+
+ ); +}; + +export default LoginPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/DashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/DashboardPage.tsx deleted file mode 100644 index 235c803b0..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/DashboardPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; - -const DashboardPage = () => { - return ( - - ); -}; - -export default DashboardPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx new file mode 100644 index 000000000..da8146f8d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const OverviewPage = () => { + return ( + + ); +}; + +export default OverviewPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx new file mode 100644 index 000000000..4d2e50920 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const DepartmentsPage = () => { + return ( + + ); +}; + +export default DepartmentsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx new file mode 100644 index 000000000..fd02787cf --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const RolesPage = () => { + return ( + + ); +}; + +export default RolesPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx new file mode 100644 index 000000000..0fcfc8d65 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const UsersPage = () => { + return ( + + ); +}; + +export default UsersPage; diff --git a/packages/ui-common/src/components/Layout/Sidebar.tsx b/packages/ui-common/src/components/Layout/Sidebar.tsx index 186f8bfd4..f8dd1f4d1 100644 --- a/packages/ui-common/src/components/Layout/Sidebar.tsx +++ b/packages/ui-common/src/components/Layout/Sidebar.tsx @@ -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) => ( - -); + 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 ? ( + + {item.icon} + + ) : null} + {item.label} + + {hasChildren ? ( + + ) : null} + + + {hasChildren && expanded[item.href] ? ( + + ) : null} + + ); + })} + + + ); +}; export default Sidebar; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85d4be164..e225c84e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)