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

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

View File

@@ -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",

View File

@@ -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;

View File

@@ -0,0 +1,138 @@
import {
createContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { getMeRequest, loginRequest, verifyMfaRequest } from "./api";
import {
AUTH_TOKEN_COOKIE,
AUTH_USER_COOKIE,
clearSessionCookies,
getCookie,
setCookie,
} from "./cookies";
import { applyTokens } from "./http";
import type { AuthTokens, AuthUser } from "./types";
interface LoginPayload {
email: string;
password: string;
}
interface VerifyMfaPayload {
email: string;
otp: string;
}
export interface AuthContextValue {
user: AuthUser | null;
loading: boolean;
login: (payload: LoginPayload) => Promise<{ mfaRequired: boolean }>;
verifyMfa: (payload: VerifyMfaPayload) => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
const persistUser = (user: AuthUser | null) => {
if (user) {
setCookie(AUTH_USER_COOKIE, JSON.stringify(user));
return;
}
clearSessionCookies();
};
const bootstrapCachedUser = (): AuthUser | null => {
const serialized = getCookie(AUTH_USER_COOKIE);
if (!serialized) {
return null;
}
try {
return JSON.parse(serialized) as AuthUser;
} catch {
return null;
}
};
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<AuthUser | null>(() => bootstrapCachedUser());
const [loading, setLoading] = useState(true);
const mfaEmailRef = useRef<string | null>(null);
const loadCurrentUser = async () => {
const currentUser = await getMeRequest();
setUser(currentUser);
persistUser(currentUser);
};
useEffect(() => {
["auth-token", "refresh-token"].forEach((name) => {
const value = getCookie(name);
if (value === "undefined" || value === "null" || value === "") {
clearSessionCookies();
}
});
const bootstrap = async () => {
const token = getCookie(AUTH_TOKEN_COOKIE);
if (!token) {
setLoading(false);
return;
}
try {
await loadCurrentUser();
} catch {
clearSessionCookies();
setUser(null);
} finally {
setLoading(false);
}
};
void bootstrap();
}, []);
const value = useMemo<AuthContextValue>(
() => ({
user,
loading,
login: async ({ email, password }) => {
const result = await loginRequest({ email, password });
if (result.mfaRequired) {
mfaEmailRef.current = email;
return { mfaRequired: true };
}
const tokens = result as AuthTokens;
applyTokens(tokens);
await loadCurrentUser();
mfaEmailRef.current = null;
return { mfaRequired: false };
},
verifyMfa: async ({ email, otp }) => {
const identifier = mfaEmailRef.current ?? email;
const tokens = await verifyMfaRequest({ email: identifier, otp });
applyTokens(tokens);
await loadCurrentUser();
mfaEmailRef.current = null;
},
logout: () => {
clearSessionCookies();
localStorage.clear();
setUser(null);
window.location.replace("/auth");
},
}),
[loading, user],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View File

@@ -0,0 +1,23 @@
import { api } from "./http";
import type { AuthTokens, AuthUser, LoginResponse } from "./types";
export const loginRequest = async (payload: {
email: string;
password: string;
}) => {
const response = await api.post<LoginResponse>("/auth/login", payload);
return response.data;
};
export const verifyMfaRequest = async (payload: {
email: string;
otp: string;
}) => {
const response = await api.post<AuthTokens>("/auth/mfa-verify", payload);
return response.data;
};
export const getMeRequest = async () => {
const response = await api.get<AuthUser>("/auth/me");
return response.data;
};

View File

@@ -0,0 +1,38 @@
const DEFAULT_PATH = "/";
const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7;
export const AUTH_TOKEN_COOKIE = "auth-token";
export const REFRESH_TOKEN_COOKIE = "refresh-token";
export const AUTH_USER_COOKIE = "auth-user";
export const AUTH_COOKIE_MAX_AGE = SEVEN_DAYS_IN_SECONDS;
export const getCookie = (name: string) => {
const match = document.cookie
.split("; ")
.find((entry) => entry.startsWith(`${name}=`));
return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null;
};
export const setCookie = (
name: string,
value: string,
maxAge = AUTH_COOKIE_MAX_AGE,
) => {
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; path=${DEFAULT_PATH}; SameSite=Lax`;
};
export const clearCookie = (name: string) => {
document.cookie = `${name}=; Max-Age=0; path=${DEFAULT_PATH}`;
};
export const clearSessionCookies = () => {
[
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
AUTH_USER_COOKIE,
"current-position-id",
"selected-position-id",
].forEach(clearCookie);
};

View File

@@ -0,0 +1,99 @@
import axios from "axios";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
clearSessionCookies,
getCookie,
setCookie,
} from "./cookies";
import type { AuthTokens } from "./types";
type RetriableRequest = {
_retry?: boolean;
headers?: Record<string, string>;
url?: string;
};
const api = axios.create({
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`,
withCredentials: true,
});
let refreshPromise: Promise<AuthTokens> | null = null;
const applyTokens = ({ token, refreshToken }: AuthTokens) => {
setCookie(AUTH_TOKEN_COOKIE, token);
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
};
api.interceptors.request.use((config) => {
const token = getCookie(AUTH_TOKEN_COOKIE);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => {
if (
response.data &&
typeof response.data === "object" &&
"success" in response.data &&
"data" in response.data
) {
response.data = response.data.data;
}
return response;
},
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
if (
error.response?.status !== 401 ||
!originalRequest ||
originalRequest._retry ||
originalRequest.url?.includes("/auth/login") ||
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
return Promise.reject(error);
}
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
if (!refreshToken) {
clearSessionCookies();
return Promise.reject(error);
}
originalRequest._retry = true;
try {
refreshPromise ??= api
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
.then((response) => response.data)
.finally(() => {
refreshPromise = null;
});
const tokens = await refreshPromise;
applyTokens(tokens);
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${tokens.token}`,
};
return api(originalRequest);
} catch (refreshError) {
clearSessionCookies();
window.location.replace("/auth");
return Promise.reject(refreshError);
}
},
);
export { api, applyTokens };

View File

@@ -0,0 +1,18 @@
export interface AuthUser {
id?: string;
email?: string;
username?: string;
name?: {
en?: string;
am?: string;
};
}
export interface AuthTokens {
token: string;
refreshToken: string;
}
export interface LoginResponse extends Partial<AuthTokens> {
mfaRequired?: boolean;
}

View File

@@ -0,0 +1,13 @@
import { useContext } from "react";
import { AuthContext } from "./AuthProvider";
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
};

View File

@@ -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;

View File

@@ -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>,
);

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
View File

@@ -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)