From be5f3e1811faa833143f61ec5a985ba567e619ea Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Fri, 29 May 2026 11:21:04 +0300 Subject: [PATCH 1/2] feat(freight:backoffice): demo iam --- apps/edr-freight-api/src/app.module.ts | 7 +- .../demo-permissions.controller.ts | 22 ++ .../demo-permissions.module.ts | 8 + .../src/seed/demo-users.seeder.ts | 195 ++++++++++++++++++ apps/edr-freight-web/backoffice/src/App.tsx | 89 +++++--- .../pages/dashboard/demo/DemoUser1Page.tsx | 67 ++++++ .../pages/dashboard/demo/DemoUser2Page.tsx | 67 ++++++ 7 files changed, 427 insertions(+), 28 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts create mode 100644 apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts create mode 100644 apps/edr-freight-api/src/seed/demo-users.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f2ca61569..b4d8cda6b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -21,7 +21,9 @@ import { OtpModule } from './modules/otp/otp.module'; import { ServiceTypesModule } from "./modules/service-types/service-types.module"; import { CargoTypesModule } from "./modules/cargo-types/cargo-types.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; +import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; +import { DemoUsersSeeder } from "./seed/demo-users.seeder"; @Module({ imports: [ @@ -50,17 +52,20 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder"; ServiceTypesModule, CargoTypesModule, BackofficeModule, + DemoPermissionsModule, ], - providers: [EdrOrgSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, + private readonly demoUsersSeeder: DemoUsersSeeder, ) { } async onApplicationBootstrap() { await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.demoUsersSeeder.run(); } } diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts new file mode 100644 index 000000000..6f425e1bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard"; + +@ApiTags("demo-permissions") +@Controller() +export class DemoPermissionsController { + @Get("test_user1") + @ApiOperation({ summary: "Permission demo (can:demo:user1)" }) + @UseGuards(PermissionGuard(["can:demo:user1"])) + testUser1() { + return { ok: true, permission: "can:demo:user1" }; + } + + @Get("test_user2") + @ApiOperation({ summary: "Permission demo (can:demo:user2)" }) + @UseGuards(PermissionGuard(["can:demo:user2"])) + testUser2() { + return { ok: true, permission: "can:demo:user2" }; + } +} diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts new file mode 100644 index 000000000..db73ed728 --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; + +import { DemoPermissionsController } from "./demo-permissions.controller"; + +@Module({ + controllers: [DemoPermissionsController], +}) +export class DemoPermissionsModule {} diff --git a/apps/edr-freight-api/src/seed/demo-users.seeder.ts b/apps/edr-freight-api/src/seed/demo-users.seeder.ts new file mode 100644 index 000000000..7638b2205 --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-users.seeder.ts @@ -0,0 +1,195 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { + Employee, + Organization, + Permission, + Role, + RolePermission, + User, + UserCredential, + UserRole, +} from "@tria-plc/iamapi-common"; +import { DataSource } from "typeorm"; + +const SEED_FLAG = "SEED_DEMO_USERS"; + +const DEMO_ORG_KEY = "demo_iam"; +const DEMO_ORG_NAME = { en: "Demo IAM" }; + +const DEMO_PERMISSIONS = [ + { key: "can:demo:user1", name: { en: "Can access demo user1" } }, + { key: "can:demo:user2", name: { en: "Can access demo user2" } }, +]; + +const DEMO_ROLES = [ + { key: "demo_user1", name: { en: "Demo User1" } }, + { key: "demo_user2", name: { en: "Demo User2" } }, +]; + +const DEMO_USERS = [ + { + email: "user@gmail.com", + username: "user", + name: { en: "Demo User 1" }, + roleKey: "demo_user1", + }, + { + email: "user2@gmail.com", + username: "user2", + name: { en: "Demo User 2" }, + roleKey: "demo_user2", + }, +]; + +@Injectable() +export class DemoUsersSeeder { + private readonly logger = new Logger(DemoUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + if (!shouldSeed) { + this.logger.log(`Skipping demo user seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + const organizationRepository = manager.getRepository(Organization); + const employeeRepository = manager.getRepository(Employee); + const permissionRepository = manager.getRepository(Permission); + const roleRepository = manager.getRepository(Role); + const rolePermissionRepository = manager.getRepository(RolePermission); + const userRepository = manager.getRepository(User); + const userCredentialRepository = manager.getRepository(UserCredential); + const userRoleRepository = manager.getRepository(UserRole); + + await organizationRepository.upsert( + { + key: DEMO_ORG_KEY, + name: DEMO_ORG_NAME, + // status defaults to ACTIVE in IAM entity + isGovernmentOrganization: true, + }, + { conflictPaths: { key: true } }, + ); + + const organization = await organizationRepository.findOne({ + where: { key: DEMO_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + throw new Error("demo_org_seed_failed"); + } + + await permissionRepository.upsert(DEMO_PERMISSIONS, { + conflictPaths: { key: true }, + }); + + await roleRepository.upsert(DEMO_ROLES, { + conflictPaths: { key: true }, + }); + + const roles = await roleRepository.find({ where: DEMO_ROLES.map((r) => ({ key: r.key })) }); + const permissions = await permissionRepository.find({ + where: DEMO_PERMISSIONS.map((p) => ({ key: p.key })), + }); + + const roleByKey = new Map(roles.map((r) => [r.key, r])); + const permissionByKey = new Map(permissions.map((p) => [p.key, p])); + + const rolePermissionsToUpsert = [ + { + roleId: roleByKey.get("demo_user1")!.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: roleByKey.get("demo_user2")!.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ]; + + await rolePermissionRepository.upsert(rolePermissionsToUpsert, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + const hashedPassword = await hashPassword("12345678"); + + for (const demoUser of DEMO_USERS) { + const existingUser = await userRepository.findOne({ + where: { email: demoUser.email }, + select: { id: true, email: true }, + }); + + let user = existingUser; + if (!user) { + user = await userRepository.save( + userRepository.create({ + email: demoUser.email, + username: demoUser.username, + name: demoUser.name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } + + // Ensure an active credential exists for login. + const activeCredentialExists = await userCredentialRepository.exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await userCredentialRepository.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + // Login query requires a current employee in an ACTIVE organization. + const employeeExists = await employeeRepository.exists({ + where: { + userId: user.id, + organizationId: organization.id, + isCurrent: true, + }, + }); + + if (!employeeExists) { + await employeeRepository.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: demoUser.name, + }); + } + + const role = roleByKey.get(demoUser.roleKey); + if (!role) { + throw new Error(`missing_role:${demoUser.roleKey}`); + } + + await userRoleRepository.upsert( + { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + } + }); + + this.logger.log( + "Seeded demo users + permissions (user@gmail.com, user2@gmail.com; permissions can:demo:user1/can:demo:user2)", + ); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 7947060ef..8edcaf72f 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,6 +1,6 @@ import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; -import { LayoutDashboard, Network } from "lucide-react"; +import { LayoutDashboard, Network, Settings } from "lucide-react"; import { useAuth } from "./auth/useAuth"; import LoginPage from "./pages/auth/LoginPage"; @@ -10,39 +10,72 @@ import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; import LoadingScreen from "./components/LoadingScreen"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -const sidebarItems: SidebarItem[] = [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - }, - { - label: "User management", - href: "/dashboard/user-management", - icon: , - children: [ - { - label: "Employees", - href: "/dashboard/user-management/employees", - }, - { - label: "Permissions", - href: "/dashboard/user-management/permissions", - }, - { - label: "Roles", - href: "/dashboard/user-management/roles", - }, - ], - }, -]; +const hasPermission = ( + user: ReturnType["user"], + key: string, +) => { + if (!user) return false; + if (user.permissions?.some((p) => p.key === key)) return true; + return (user.employee ?? []).some((emp) => + (emp.positions ?? []).some((pos) => + (pos.permissions ?? []).some((p) => p.key === key), + ), + ); +}; const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); const { user, logout } = useAuth(); + const sidebarItems: SidebarItem[] = [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "User management", + href: "/dashboard/user-management", + icon: , + children: [ + { + label: "Employees", + href: "/dashboard/user-management/employees", + }, + { + label: "Permissions", + href: "/dashboard/user-management/permissions", + }, + { + label: "Roles", + href: "/dashboard/user-management/roles", + }, + ], + }, + ...(hasPermission(user, "can:demo:user1") + ? ([ + { + label: "User1", + href: "/dashboard/user1", + icon: , + }, + ] as SidebarItem[]) + : []), + ...(hasPermission(user, "can:demo:user2") + ? ([ + { + label: "User2", + href: "/dashboard/user2", + icon: , + }, + ] as SidebarItem[]) + : []), + ]; + const displayName = user?.name?.en || user?.username || user?.email || "User"; return ( @@ -87,6 +120,8 @@ const App = () => { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx new file mode 100644 index 000000000..e47cb95bd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; + +import { api } from "@/auth/http"; + +const DemoUser1Page = () => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + setLoading(true); + setError(null); + + try { + const response = await api.get("/test_user1"); + if (cancelled) return; + setData(response.data); + } catch (e: any) { + if (cancelled) return; + const message = + e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + "Request failed"; + setError(String(message)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

User1 Demo

+

+ Calls GET /api/test_user1 (requires{' '} + can:demo:user1). +

+ +
+ {loading ?

Loading...

: null} + {error ? ( +
+ {error} +
+ ) : null} + {!loading && !error ? ( +
+              {JSON.stringify(data, null, 2)}
+            
+ ) : null} +
+
+
+ ); +}; + +export default DemoUser1Page; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx new file mode 100644 index 000000000..5ef7ad172 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; + +import { api } from "@/auth/http"; + +const DemoUser2Page = () => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + setLoading(true); + setError(null); + + try { + const response = await api.get("/test_user2"); + if (cancelled) return; + setData(response.data); + } catch (e: any) { + if (cancelled) return; + const message = + e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + "Request failed"; + setError(String(message)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

User2 Demo

+

+ Calls GET /api/test_user2 (requires{' '} + can:demo:user2). +

+ +
+ {loading ?

Loading...

: null} + {error ? ( +
+ {error} +
+ ) : null} + {!loading && !error ? ( +
+              {JSON.stringify(data, null, 2)}
+            
+ ) : null} +
+
+
+ ); +}; + +export default DemoUser2Page; From 426019ebf79d012507d8581b4499983f824bfa90 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Fri, 29 May 2026 11:45:44 +0300 Subject: [PATCH 2/2] feat(iam:demo): added demo permissions for superadmin --- .../src/seed/demo-users.seeder.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/edr-freight-api/src/seed/demo-users.seeder.ts b/apps/edr-freight-api/src/seed/demo-users.seeder.ts index 7638b2205..8d886e3aa 100644 --- a/apps/edr-freight-api/src/seed/demo-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-users.seeder.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { hashPassword } from "@tria-plc/api-common/utils/argon"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; import { Employee, Organization, @@ -101,6 +102,11 @@ export class DemoUsersSeeder { const roleByKey = new Map(roles.map((r) => [r.key, r])); const permissionByKey = new Map(permissions.map((p) => [p.key, p])); + const superAdminRole = await roleRepository.findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + const rolePermissionsToUpsert = [ { roleId: roleByKey.get("demo_user1")!.id, @@ -110,6 +116,18 @@ export class DemoUsersSeeder { roleId: roleByKey.get("demo_user2")!.id, permissionId: permissionByKey.get("can:demo:user2")!.id, }, + ...(superAdminRole + ? ([ + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ] as Array<{ roleId: string; permissionId: string }>) + : []), ]; await rolePermissionRepository.upsert(rolePermissionsToUpsert, {