diff --git a/.gitignore b/.gitignore index 132db3f2a..13865633d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,9 @@ coverage/ # OS/editor .DS_Store .idea/ -.vscode/ \ No newline at end of file +.vscode/ + +# emacs cache files +*~ +\#*\# +.\#* diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index 3459de0f9..41e303985 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -4,11 +4,13 @@ import { Get, Param, ParseUUIDPipe, + Post, Put, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { BackofficeService } from "./backoffice.service"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; @ApiTags("backoffice") @@ -16,6 +18,15 @@ import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto export class BackofficeController { constructor(private readonly backofficeService: BackofficeService) {} + @Post("organizations/:orgId/users") + @ApiOperation({ summary: "Create an organization user without assigning positions" }) + createOrganizationUser( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Body() dto: CreateOrganizationUserDto, + ) { + return this.backofficeService.createOrganizationUser(organizationId, dto); + } + @Get("organizations/:orgId/employee-users/:userId/roles") @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) getEmployeeUserRoles( diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts index 18b4e6947..90c1a7c79 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts @@ -1,5 +1,10 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { + Employee, + Organization, + UserCredential, +} from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; @@ -9,7 +14,16 @@ import { BackofficeController } from "./backoffice.controller"; import { BackofficeService } from "./backoffice.service"; @Module({ - imports: [TypeOrmModule.forFeature([Role, UserRole, User])], + imports: [ + TypeOrmModule.forFeature([ + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, + ]), + ], controllers: [BackofficeController], providers: [BackofficeService], exports: [BackofficeService], diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index b5206be7d..d6b68982f 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -4,21 +4,29 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { DataSource, In, IsNull, Repository } from "typeorm"; +import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; + const RESERVED_ROLE_KEYS = new Set([ "super_admin", "organization_admin", "unit_admin", ]); +const DEFAULT_USER_PASSWORD = "12345678"; @Injectable() export class BackofficeService { constructor( + @InjectRepository(Organization) + private readonly organizationRepository: Repository, @InjectRepository(Role) private readonly roleRepository: Repository, @InjectRepository(UserRole) @@ -28,6 +36,142 @@ export class BackofficeService { private readonly dataSource: DataSource, ) {} + async createOrganizationUser( + organizationId: string, + dto: CreateOrganizationUserDto, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const email = dto.email.trim().toLowerCase(); + const username = dto.username.trim().toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + const name = { + en: dto.name.en.trim(), + ...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}), + }; + + const existingUsers = await this.userRepository.find({ + where: [{ email }, { username }], + select: { id: true, email: true, username: true }, + }); + + const emailUser = existingUsers.find((user) => user.email === email); + const usernameUser = existingUsers.find((user) => user.username === username); + + if (emailUser && usernameUser && emailUser.id !== usernameUser.id) { + throw new BadRequestException("email_or_username_already_in_use"); + } + + const existingUser = emailUser ?? usernameUser; + const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD); + + return this.dataSource.transaction(async (manager) => { + let user = existingUser; + + if (!user) { + user = await manager.getRepository(User).save( + manager.getRepository(User).create({ + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } else { + await manager.getRepository(User).update( + { id: user.id }, + { + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }, + ); + } + + const activeCredentialExists = await manager.getRepository(UserCredential).exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await manager.getRepository(UserCredential).insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + let employee = await manager.getRepository(Employee).findOne({ + where: { + userId: user.id, + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + + if (!employee) { + const insertResult = await manager.getRepository(Employee).insert({ + userId: user.id, + organizationId, + isCurrent: true, + name, + }); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: insertResult.identifiers[0]?.id as string }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } else { + await manager.getRepository(Employee).update( + { id: employee.id }, + { name }, + ); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: employee.id }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } + + if (!employee) { + throw new NotFoundException("employee_create_failed"); + } + + return employee; + }); + } + async getEmployeeUserRoles(organizationId: string, userId: string) { await this.assertUserBelongsToOrganization(organizationId, userId); diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts new file mode 100644 index 000000000..1623ac075 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; + +class CreateOrganizationUserNameDto { + @ApiProperty() + @IsString() + @MinLength(1) + en!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + am?: string; +} + +export class CreateOrganizationUserDto { + @ApiProperty() + @IsEmail() + email!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + username!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + phoneNumber?: string; + + @ApiProperty({ type: CreateOrganizationUserNameDto }) + @IsObject() + name!: CreateOrganizationUserNameDto; +} diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 63b464090..d03b9ef7f 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -2,21 +2,48 @@ import { Injectable, Logger } from "@nestjs/common"; import { Organization, OrganizationConfiguration, + Permission, Role, + RolePermission, } from "@tria-plc/iamapi-common"; -import { DataSource } from "typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; const SEED_FLAG = "SEED_EDR_ORG"; -const EDR_ROLES = [ + +type SeedPermission = { + key: string; + name: { en: string }; +}; + +type SeedRole = { + key: string; + name: { en: string }; + permissions: SeedPermission[]; +}; + +type SeedOrganization = { + id: string; + key: string; +}; + +const SEED_ROLES: SeedRole[] = [ { key: "edr_employee", name: { en: "EDR Employee" }, + permissions: [ + // { key: "permission:key", name: { en: "Permission Name" } }, + { key: "permission:key", name: { en: "Permission Name" } }, + ], }, { key: "edr_customer", name: { en: "EDR Customer" }, + permissions: [ + // { key: "permission:key", name: { en: "Permission Name" } }, + { key: "permission:key", name: { en: "Permission Name" } }, + ], }, ]; @@ -27,24 +54,31 @@ export class EdrOrgSeeder { constructor(private readonly dataSource: DataSource) {} async run() { - const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; - - if (!shouldSeed) { + if (!this.shouldSeed()) { this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`); return; } - const roleRepository = this.dataSource.getRepository(Role); - const organizationRepository = this.dataSource.getRepository(Organization); - const organizationConfigurationRepository = - this.dataSource.getRepository(OrganizationConfiguration); + await this.dataSource.transaction(async (manager) => { + const organization = await this.ensureOrganization(manager); - await roleRepository.upsert(EDR_ROLES, { - conflictPaths: { key: true }, + await this.ensureOrganizationConfiguration(manager, organization.id); + await this.ensurePermissions(manager, SEED_ROLES); + await this.ensureRoles(manager, SEED_ROLES); + await this.ensureRolePermissions(manager, SEED_ROLES); }); - this.logger.log("Ensured EDR roles 'edr_employee' and 'edr_customer'"); + this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); + } + private shouldSeed() { + return process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + } + + private async ensureOrganization( + manager: EntityManager, + ): Promise { + const organizationRepository = manager.getRepository(Organization); let organization = await organizationRepository.findOne({ where: { key: EDR_ORG_KEY }, select: { id: true, key: true }, @@ -57,18 +91,31 @@ export class EdrOrgSeeder { isGovernmentOrganization: true, }); - organization = { + this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); + + return { id: insertResult.identifiers[0]?.id as string, key: EDR_ORG_KEY, - } as Organization; - - this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); - } else { - this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`); + }; } + this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`); + + return { + id: organization.id as string, + key: EDR_ORG_KEY, + }; + } + + private async ensureOrganizationConfiguration( + manager: EntityManager, + organizationId: string, + ) { + const organizationConfigurationRepository = + manager.getRepository(OrganizationConfiguration); + await organizationConfigurationRepository.upsert({ - organizationId: organization.id, + organizationId, canCreateBranchByItself: true, canStartReceivingRecord: true, }, { @@ -79,4 +126,100 @@ export class EdrOrgSeeder { `Ensured organization configuration for '${EDR_ORG_KEY}'`, ); } + + private collectPermissions(seedRoles: SeedRole[]) { + const permissionByKey = new Map(); + + for (const role of seedRoles) { + for (const permission of role.permissions) { + permissionByKey.set(permission.key, permission); + } + } + + return [...permissionByKey.values()]; + } + + private async ensurePermissions(manager: EntityManager, seedRoles: SeedRole[]) { + const permissions = this.collectPermissions(seedRoles); + + if (!permissions.length) { + this.logger.log("No EDR role permissions configured; skipping permission seed"); + return; + } + + await manager.getRepository(Permission).upsert(permissions, { + conflictPaths: { key: true }, + }); + + this.logger.log(`Ensured ${permissions.length} EDR permissions`); + } + + private async ensureRoles(manager: EntityManager, seedRoles: SeedRole[]) { + await manager.getRepository(Role).upsert( + seedRoles.map(({ key, name }) => ({ key, name })), + { + conflictPaths: { key: true }, + }, + ); + + this.logger.log( + `Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`, + ); + } + + private async ensureRolePermissions( + manager: EntityManager, + seedRoles: SeedRole[], + ) { + const permissions = this.collectPermissions(seedRoles); + + if (!permissions.length) { + return; + } + + const roleRepository = manager.getRepository(Role); + const permissionRepository = manager.getRepository(Permission); + const rolePermissionRepository = manager.getRepository(RolePermission); + + const roles = await roleRepository.find({ + where: { key: In(seedRoles.map((role) => role.key)) }, + select: { id: true, key: true }, + }); + const seededPermissions = await permissionRepository.find({ + where: { key: In(permissions.map((permission) => permission.key)) }, + select: { id: true, key: true }, + }); + + const roleByKey = new Map(roles.map((role) => [role.key, role])); + const permissionByKey = new Map( + seededPermissions.map((permission) => [permission.key, permission]), + ); + + const rolePermissions = seedRoles.flatMap((role) => { + const seededRole = roleByKey.get(role.key); + + if (!seededRole) { + throw new Error(`missing_role:${role.key}`); + } + + return role.permissions.map((permission) => { + const seededPermission = permissionByKey.get(permission.key); + + if (!seededPermission) { + throw new Error(`missing_permission:${permission.key}`); + } + + return { + roleId: seededRole.id, + permissionId: seededPermission.id, + }; + }); + }); + + await rolePermissionRepository.upsert(rolePermissions, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`); + } } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 75a233955..3bfc004ea 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,5 +1,5 @@ import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; import { LayoutDashboard, Network, Paperclip, Settings } from "lucide-react"; import { useAuth } from "./auth/useAuth"; @@ -9,6 +9,7 @@ import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; 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 UsersPage from "./pages/dashboard/user-management/UsersPage"; import LoadingScreen from "./components/LoadingScreen"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; @@ -16,50 +17,17 @@ import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import { RuleEnginePage } from "./pages/ruleEngine/RuleEngine"; -// Create a QueryClient instance const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, - staleTime: 5 * 60 * 1000, // 5 minutes + staleTime: 5 * 60 * 1000, }, }, }); -// 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", -// }, -// ], -// }, -// { -// label: "Rule Engine", -// href: "/dashboard/rule-engine", -// icon: , -// }, -// ]; - -const sidebarItems: SidebarItem[] = [ +const baseSidebarItems: SidebarItem[] = [ { label: "Overview", href: "/dashboard/overview", @@ -70,6 +38,10 @@ const sidebarItems: SidebarItem[] = [ href: "/dashboard/user-management", icon: , children: [ + { + label: "Users", + href: "/dashboard/user-management/users", + }, { label: "Employees", href: "/dashboard/user-management/employees", @@ -98,7 +70,7 @@ const sidebarItems: SidebarItem[] = [ label: "Rule Engine", href: "/dashboard/rule-engine", icon: , - } + }, ]; const hasPermission = ( @@ -107,16 +79,41 @@ const hasPermission = ( ) => { 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[] = [ + ...baseSidebarItems, + ...(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 ( @@ -158,20 +155,33 @@ const App = () => { } /> } /> + }> } /> + } /> - } /> + } /> } /> } /> } /> - } /> - } /> + } /> } /> - } /> - } /> + } /> + + } /> + } /> + + } + /> + } + /> + } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx index 3d490fcf4..88fa34647 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx @@ -1433,6 +1433,7 @@ const UserManagementPage = () => { No organizations available. )} +