mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #28 from Tria-plc/freight/feature/bootstrap_backoffice
Freight/feature/bootstrap backoffice
This commit is contained in:
@@ -1 +1,2 @@
|
||||
VITE_API_URL=http://localhost:3001
|
||||
VITE_BASE_API_URL=http://localhost:3001
|
||||
|
||||
2
apps/edr-freight-web/backoffice/index.css
Normal file
2
apps/edr-freight-web/backoffice/index.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "@edr/ui-common/theme.css" layer(theme);
|
||||
@@ -12,27 +12,34 @@
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tria-plc/iamui-common": "1.0.3",
|
||||
"@edr/types": "workspace:*",
|
||||
"@edr/ui-common": "workspace:*",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tria-plc/iamui-common": "1.1.1",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"libphonenumber-js": "^1.12.24",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.2"
|
||||
|
||||
10
apps/edr-freight-web/backoffice/public/assets/logo.svg
Normal file
10
apps/edr-freight-web/backoffice/public/assets/logo.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="96" height="96" rx="20" fill="#0F766E"/>
|
||||
<path d="M24 66H72" stroke="#F8FAFC" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M30 66V38C30 34.6863 32.6863 32 36 32H46C49.3137 32 52 34.6863 52 38V66" stroke="#F8FAFC" stroke-width="5" stroke-linejoin="round"/>
|
||||
<path d="M52 66V46C52 42.6863 54.6863 40 58 40H66C69.3137 40 72 42.6863 72 46V66" stroke="#F8FAFC" stroke-width="5" stroke-linejoin="round"/>
|
||||
<path d="M38 42H44" stroke="#F8FAFC" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M38 50H44" stroke="#F8FAFC" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M59 50H65" stroke="#F8FAFC" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M59 58H65" stroke="#F8FAFC" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 852 B |
@@ -0,0 +1,20 @@
|
||||
<svg width="186" height="48" viewBox="0 0 186 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="6" fill="#0F766E"/>
|
||||
<path d="M12 36H36" stroke="#F8FAFC" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M31.2 20.4H26.4C23.4217 20.4 22.8 21.0217 22.8 24V36H34.8V24C34.8 21.0217 34.1783 20.4 31.2 20.4Z" stroke="#F8FAFC" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M27.6 36H13.2V15.6C13.2 12.6217 13.8217 12 16.8 12H24C26.9783 12 27.6 12.6217 27.6 15.6V20.4" stroke="#F8FAFC" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M13.2 16.8H16.8M13.2 21.6H16.8M13.2 26.4H16.8" stroke="#F8FAFC" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M27.6 25.2H30M27.6 28.8H30" stroke="#F8FAFC" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M28.8 36V32.4" stroke="#F8FAFC" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M64.8 17.5V5.3H69.4C70.4711 5.3 71.3873 5.47167 72.1486 5.815C72.9098 6.15833 73.4929 6.64833 73.8977 7.285C74.3024 7.915 74.5048 8.66967 74.5048 9.549C74.5048 10.4283 74.3024 11.1865 73.8977 11.8232C73.4929 12.4532 72.9098 12.9398 72.1486 13.2832C71.3873 13.6265 70.4711 13.7982 69.4 13.7982H66.2537V17.5H64.8ZM66.2537 12.472H69.2754C70.2629 12.472 71.0209 12.2207 71.5494 11.7182C72.0778 11.2157 72.342 10.4927 72.342 9.549C72.342 8.60533 72.0778 7.88233 71.5494 7.38C71.0209 6.87767 70.2629 6.6265 69.2754 6.6265H66.2537V12.472Z" fill="#0F172A"/>
|
||||
<path d="M82.4998 17.7515C81.5936 17.7515 80.7729 17.543 80.0378 17.126C79.3091 16.7023 78.7293 16.1242 78.2984 15.3915C77.8743 14.6522 77.6623 13.8093 77.6623 12.863C77.6623 11.9102 77.8777 11.0673 78.3084 10.3345C78.7452 9.60183 79.331 9.027 80.0661 8.61C80.8078 8.193 81.6351 7.9845 82.5481 7.9845C83.4544 7.9845 84.2684 8.193 84.9904 8.61C85.7191 9.027 86.2923 9.60183 86.7101 10.3345C87.1342 11.0673 87.3462 11.9102 87.3462 12.863C87.3462 13.8093 87.1308 14.6522 86.6999 15.3915C86.2758 16.1242 85.696 16.7023 84.9609 17.126C84.2322 17.543 83.4114 17.7515 82.4998 17.7515ZM82.4998 16.4545C83.0873 16.4545 83.6158 16.3147 84.0852 16.035C84.5611 15.7487 84.9367 15.3545 85.2122 14.8525C85.4877 14.3505 85.6254 13.7878 85.6254 13.1645V12.5715C85.6254 11.9482 85.4877 11.3855 85.2122 10.8835C84.9367 10.3815 84.5644 9.99083 84.0952 9.7115C83.6258 9.42517 83.1004 9.282 82.5198 9.282C81.9392 9.282 81.4107 9.42517 80.9348 9.7115C80.4589 9.99083 80.0833 10.3815 79.8078 10.8835C79.5323 11.3855 79.3946 11.9482 79.3946 12.5715V13.1645C79.3946 13.7878 79.5323 14.3505 79.8078 14.8525C80.0899 15.3545 80.4689 15.7487 80.9448 16.035C81.4207 16.3147 81.939 16.4545 82.4998 16.4545Z" fill="#0F172A"/>
|
||||
<path d="M90.5106 17.5V8.235H92.1549V9.816C92.4241 9.27067 92.8198 8.847 93.3421 8.545C93.8644 8.23633 94.4909 8.082 95.2216 8.082H95.7396V9.564H94.9599C94.1986 9.564 93.5753 9.80367 93.09 10.283C92.6112 10.7557 92.3718 11.4692 92.3718 12.4235V17.5H90.5106Z" fill="#0F172A"/>
|
||||
<path d="M101.872 17.7515C101.029 17.7515 100.271 17.5465 99.5979 17.1365C98.9314 16.7195 98.4029 16.1447 98.0124 15.412C97.6285 14.6727 97.4367 13.8265 97.4367 12.8735C97.4367 11.9207 97.6285 11.0778 98.0124 10.345C98.4029 9.61233 98.9314 9.04083 99.5979 8.6305C100.271 8.2135 101.029 8.005 101.872 8.005C102.602 8.005 103.245 8.16283 103.801 8.4785C104.356 8.78717 104.782 9.21083 105.079 9.7495V8.235H106.93V17.5H105.266V15.885C104.984 16.437 104.574 16.8747 104.038 17.198C103.503 17.514 102.78 17.6755 101.872 17.7515Z" fill="#0F172A"/>
|
||||
<path d="M113.792 17.7515C112.905 17.7515 112.094 17.543 111.357 17.126C110.627 16.709 110.044 16.1342 109.607 15.4015C109.177 14.6622 108.962 13.8193 108.962 12.8735C108.962 11.9207 109.18 11.0778 109.617 10.345C110.054 9.61233 110.64 9.04083 111.376 8.6305C112.113 8.2135 112.928 8.005 113.821 8.005C114.644 8.005 115.372 8.19433 116.007 8.573C116.649 8.94517 117.152 9.47083 117.517 10.15L116.19 10.8735C115.942 10.4198 115.611 10.0622 115.196 9.8005C114.785 9.53217 114.326 9.398 113.821 9.398C113.249 9.398 112.727 9.53767 112.256 9.817C111.787 10.0963 111.411 10.487 111.129 10.989C110.854 11.491 110.716 12.0537 110.716 12.677V13.27C110.716 13.8933 110.854 14.456 111.129 14.958C111.411 15.4535 111.79 15.8442 112.266 16.13C112.742 16.4163 113.271 16.5595 113.851 16.5595C114.354 16.5595 114.819 16.4288 115.245 16.167C115.672 15.8987 116.01 15.5377 116.26 15.084L117.595 15.827C117.236 16.499 116.73 17.0247 116.076 17.404C115.428 17.7833 114.667 17.7515 113.792 17.7515Z" fill="#0F172A"/>
|
||||
<path d="M123.928 17.7515C123.042 17.7515 122.234 17.5465 121.507 17.1365C120.778 16.7195 120.202 16.1413 119.778 15.4015C119.354 14.6622 119.142 13.816 119.142 12.863C119.142 11.9038 119.35 11.0577 119.765 10.3245C120.189 9.5845 120.765 9.00633 121.493 8.59C122.221 8.173 123.036 7.9645 123.938 7.9645C124.838 7.9645 125.629 8.16967 126.313 8.58C126.999 8.98317 127.533 9.546 127.918 10.2685C128.302 10.9843 128.494 11.8075 128.494 12.738V13.4155H120.919C120.956 13.9287 121.109 14.3915 121.376 14.804C121.651 15.2098 122.012 15.5313 122.46 15.7685C122.91 16.0057 123.413 16.1245 123.967 16.1245C124.591 16.1245 125.145 15.9867 125.631 15.7115C126.116 15.4297 126.476 15.0615 126.71 14.6075L128.07 15.341C127.752 16.0487 127.231 16.6235 126.506 17.0655C125.783 17.5078 124.923 17.7515 123.928 17.7515ZM120.967 11.968H126.649C126.576 11.1727 126.267 10.5365 125.721 10.0595C125.176 9.5825 124.455 9.344 123.558 9.344C122.722 9.344 122.018 9.5825 121.445 10.0595C120.881 10.5292 120.555 11.1655 120.467 11.968H120.967Z" fill="#0F172A"/>
|
||||
<path d="M64.8 42.5V30.3H66.2537V42.5H64.8ZM72.5302 42.7515C71.6872 42.7515 70.9292 42.5465 70.256 42.1365C69.5895 41.7195 69.0611 41.1447 68.6706 40.412C68.2866 39.6727 68.0948 38.8265 68.0948 37.8735C68.0948 36.9207 68.2866 36.0778 68.6706 35.345C69.0611 34.6123 69.5895 34.0408 70.256 33.6305C70.9292 33.2135 71.6872 33.005 72.5302 33.005C73.2609 33.005 73.9042 33.1628 74.4597 33.4785C75.0152 33.7872 75.4418 34.2108 75.7385 34.7495V33.235H77.5897V42.5H75.9255V40.885C75.6434 41.437 75.2335 41.8747 74.6981 42.198C74.1628 42.514 73.4398 42.6755 72.5302 42.7515Z" fill="#0F172A"/>
|
||||
<path d="M84.5774 42.7515C83.6914 42.7515 82.8834 42.5465 82.1554 42.1365C81.4267 41.7195 80.8507 41.1413 80.4266 40.4015C80.0025 39.6622 79.7904 38.816 79.7904 37.863C79.7904 36.9038 79.9989 36.0577 80.4167 35.3245C80.8344 34.5845 81.4105 34.0063 82.1445 33.59C82.8795 33.173 83.6902 32.9645 84.5774 32.9645C85.5708 32.9645 86.4239 33.2082 87.1367 33.6955C87.8559 34.1762 88.3642 34.8342 88.661 35.6695L87.0916 36.0725C86.9117 35.5308 86.608 35.1053 86.1806 34.796C85.7596 34.4797 85.2277 34.3215 84.5858 34.3215C84.0235 34.3215 83.5044 34.4647 83.0286 34.751C82.5527 35.0303 82.1737 35.421 81.8916 35.923C81.6159 36.425 81.478 36.9877 81.478 37.611V38.204C81.478 38.8273 81.6159 39.39 81.8916 39.892C82.1737 40.394 82.5527 40.7882 83.0286 41.0745C83.5044 41.3538 84.0269 41.4935 84.5972 41.4935C85.2461 41.4935 85.7902 41.3388 86.229 41.03C86.6676 40.714 86.9877 40.2815 87.1889 39.7325L88.7186 40.1845C88.4378 41.0005 87.9294 41.652 87.1939 42.1395C86.4646 42.6208 85.5924 42.8248 84.5774 42.7515Z" fill="#0F172A"/>
|
||||
<path d="M94.9155 42.7515C94.0284 42.7515 93.2203 42.5465 92.4916 42.1365C91.7636 41.7195 91.1875 41.1413 90.7634 40.4015C90.3392 39.6622 90.1271 38.816 90.1271 37.863C90.1271 36.9038 90.3359 36.0577 90.7534 35.3245C91.1711 34.5845 91.7472 34.0063 92.4814 33.59C93.2165 33.173 94.0272 32.9645 94.9155 32.9645C95.9085 32.9645 96.7617 33.2082 97.4747 33.6955C98.1937 34.1762 98.702 34.8342 98.9985 35.6695L97.4297 36.0725C97.2495 35.5308 96.946 35.1053 96.5185 34.796C96.0975 34.4797 95.5652 34.3215 94.9238 34.3215C94.3607 34.3215 93.8417 34.4647 93.3659 34.751C92.89 35.0303 92.511 35.421 92.2285 35.923C91.953 36.425 91.8152 36.9877 91.8152 37.611V38.204C91.8152 38.8273 91.953 39.39 92.2285 39.892C92.511 40.394 92.89 40.7882 93.3659 41.0745C93.8417 41.3538 94.3644 41.4935 94.9347 41.4935C95.5832 41.4935 96.1275 41.3388 96.5665 41.03C97.0047 40.714 97.325 40.2815 97.5262 39.7325L99.0555 40.1845C98.775 41.0005 98.2665 41.652 97.5307 42.1395C96.8022 42.6208 95.9302 42.8248 94.9155 42.7515Z" fill="#0F172A"/>
|
||||
<path d="M101.329 42.5V29.7H103.19V42.5H101.329ZM108.995 42.7515C108.108 42.7515 107.301 42.5465 106.572 42.1365C105.843 41.7195 105.267 41.1413 104.843 40.4015C104.419 39.6622 104.207 38.816 104.207 37.863C104.207 36.9038 104.416 36.0577 104.833 35.3245C105.251 34.5845 105.827 34.0063 106.562 33.59C107.297 33.173 108.108 32.9645 108.995 32.9645C109.989 32.9645 110.842 33.2082 111.555 33.6955C112.274 34.1762 112.782 34.8342 113.079 35.6695L111.51 36.0725C111.33 35.5308 111.026 35.1053 110.599 34.796C110.178 34.4797 109.646 34.3215 109.004 34.3215C108.441 34.3215 107.922 34.4647 107.446 34.751C106.97 35.0303 106.591 35.421 106.309 35.923C106.033 36.425 105.895 36.9877 105.895 37.611V38.204C105.895 38.8273 106.033 39.39 106.309 39.892C106.591 40.394 106.97 40.7882 107.446 41.0745C107.922 41.3538 108.444 41.4935 109.015 41.4935C109.663 41.4935 110.208 41.3388 110.646 41.03C111.085 40.714 111.405 40.2815 111.606 39.7325L113.136 40.1845C112.855 41.0005 112.347 41.652 111.611 42.1395C110.882 42.6208 110.01 42.8248 108.995 42.7515Z" fill="#0F172A"/>
|
||||
<path d="M115.507 42.5V33.235H117.151V34.816C117.42 34.2707 117.816 33.847 118.338 33.545C118.86 33.2363 119.487 33.082 120.217 33.082H120.736V34.564H119.955C119.194 34.564 118.571 34.8037 118.086 35.283C117.607 35.7557 117.368 36.4692 117.368 37.4235V42.5H115.507Z" fill="#0F172A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -1,19 +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, ShieldCheck, Users, Building2 } from "lucide-react";
|
||||
|
||||
import DashboardPage from "./pages/dashboard/DashboardPage";
|
||||
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: "/" }];
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{
|
||||
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, logout } = useAuth();
|
||||
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
@@ -21,13 +52,52 @@ const App = () => {
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
enableThemeToggle
|
||||
userName={displayName}
|
||||
userEmail={user?.email}
|
||||
onLogout={logout}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<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,25 @@
|
||||
interface FeaturePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FeaturePlaceholder = ({
|
||||
title,
|
||||
description,
|
||||
}: FeaturePlaceholderProps) => {
|
||||
return (
|
||||
<section className="p-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-8 shadow-sm">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
EDR Freight Backoffice
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-foreground">{title}</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturePlaceholder;
|
||||
@@ -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,18 +1,46 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import "@edr/ui-common/styles.css";
|
||||
import "../index.css";
|
||||
import "@edr/ui-common/theme.css";
|
||||
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const THEME_STORAGE_KEY = "edr-theme";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
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;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove("dark");
|
||||
};
|
||||
|
||||
applyStoredTheme();
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const DropdownSettingsPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Dropdown Settings"
|
||||
description="Configure controlled dropdown options used across backoffice workflows."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownSettingsPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const FileUploadSettingsPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="File Upload Settings"
|
||||
description="Define the document fields and upload rules used by freight backoffice teams."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadSettingsPage;
|
||||
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;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const BillingPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Billing"
|
||||
description="Review invoice activity, billing exceptions, and reconciliation tasks for freight operations."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const BookingDetailPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Booking Detail"
|
||||
description="Inspect booking metadata, operational notes, and fulfillment progress for internal teams."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingDetailPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const BookingsPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Bookings"
|
||||
description="Manage freight bookings, review operational status, and coordinate internal processing flows."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingsPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const NewBookingPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Create Booking"
|
||||
description="Capture and validate new freight bookings from the backoffice workflow."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewBookingPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const ConsignmentDetailPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Consignment Detail"
|
||||
description="Review cargo information, routing context, and operational actions for a consignment."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsignmentDetailPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const ConsignmentsPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Consignments"
|
||||
description="Manage consignment handling, milestones, and internal exception resolution."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsignmentsPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const CustomerDetailPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Customer Detail"
|
||||
description="View customer profile details, active shipments, and internal account notes."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerDetailPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const CustomersPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Customers"
|
||||
description="Review and maintain customer records, service status, and operational context."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomersPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const NewCustomerPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Create Customer"
|
||||
description="Register a new freight customer and prepare the account for operational use."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewCustomerPage;
|
||||
@@ -1,10 +0,0 @@
|
||||
const DashboardPage = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-semibold">EDR Freight Backoffice</h1>
|
||||
<p className="mt-2 text-gray-600">Backoffice — coming soon.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const DocumentsPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Documents"
|
||||
description="Manage internal freight documents, approvals, and operational record keeping."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const TrackingPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Tracking"
|
||||
description="Monitor shipment movement, operational events, and delays across the rail network."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackingPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const TrainsPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Trains"
|
||||
description="Coordinate train assignments, scheduling visibility, and operational readiness."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrainsPage;
|
||||
@@ -1 +1,24 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
__IAM_CONFIG__?: {
|
||||
apiUrl: string;
|
||||
postLoginPath?: string;
|
||||
};
|
||||
__USER_MANAGEMENT_BRANDING__?: {
|
||||
organizationName: string;
|
||||
appName: string;
|
||||
moduleBasePath: string;
|
||||
backToAppPath?: string;
|
||||
backToAppLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_BASE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"useDefineForClassFields": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5183,
|
||||
host: "0.0.0.0",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Table as TanstackTable } from "@tanstack/react-table";
|
||||
|
||||
import { TableCell, TableRow } from "#components/table";
|
||||
import { Button } from "#components/button.tsx";
|
||||
import { TableCell, TableRow } from "../table";
|
||||
import { Button } from "../button";
|
||||
|
||||
export function DataTableError({
|
||||
table,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Table as TanstackTable } from "@tanstack/react-table";
|
||||
|
||||
import { Skeleton } from "../skeleton";
|
||||
import { TableCell, TableRow } from "#components/table";
|
||||
import { TableCell, TableRow } from "../table";
|
||||
|
||||
export function DataTableSkeleton({ table }: { table: TanstackTable<any> }) {
|
||||
return Array.from({ length: 10 }).map((_, i) => (
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "#components/table";
|
||||
} from "../table";
|
||||
import { DataTableProps } from "./types";
|
||||
import { DataTableSkeleton } from "./skeleton";
|
||||
import { DataTableError } from "./error";
|
||||
|
||||
3360
pnpm-lock.yaml
generated
3360
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user