mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add service-types and cargo-types CRUD modules with pagination, filtering
This commit is contained in:
@@ -21,6 +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 { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-settings.service";
|
||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -48,16 +51,18 @@ import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-se
|
||||
OtpModule,
|
||||
ServiceTypesModule,
|
||||
CargoTypesModule,
|
||||
BackofficeModule,
|
||||
],
|
||||
providers: [EdrOrgSeeder],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
await this.seeder.run();
|
||||
await this.dropdownSettingsService.seedDefaultStations();
|
||||
await this.edrOrgSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BackofficeService } from "./backoffice.service";
|
||||
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
||||
|
||||
@ApiTags("backoffice")
|
||||
@Controller("backoffice")
|
||||
export class BackofficeController {
|
||||
constructor(private readonly backofficeService: BackofficeService) {}
|
||||
|
||||
@Get("organizations/:orgId/employee-users/:userId/roles")
|
||||
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
|
||||
getEmployeeUserRoles(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@Param("userId", ParseUUIDPipe) userId: string,
|
||||
) {
|
||||
return this.backofficeService.getEmployeeUserRoles(organizationId, userId);
|
||||
}
|
||||
|
||||
@Put("organizations/:orgId/employee-users/:userId/roles")
|
||||
@ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" })
|
||||
replaceEmployeeUserRoles(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@Param("userId", ParseUUIDPipe) userId: string,
|
||||
@Body() dto: UpdateEmployeeUserRolesDto,
|
||||
) {
|
||||
return this.backofficeService.replaceEmployeeUserRoles(
|
||||
organizationId,
|
||||
userId,
|
||||
dto.roleIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
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 { BackofficeController } from "./backoffice.controller";
|
||||
import { BackofficeService } from "./backoffice.service";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Role, UserRole, User])],
|
||||
controllers: [BackofficeController],
|
||||
providers: [BackofficeService],
|
||||
exports: [BackofficeService],
|
||||
})
|
||||
export class BackofficeModule {}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { DataSource, In, IsNull, Repository } from "typeorm";
|
||||
|
||||
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";
|
||||
|
||||
const RESERVED_ROLE_KEYS = new Set([
|
||||
"super_admin",
|
||||
"organization_admin",
|
||||
"unit_admin",
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class BackofficeService {
|
||||
constructor(
|
||||
@InjectRepository(Role)
|
||||
private readonly roleRepository: Repository<Role>,
|
||||
@InjectRepository(UserRole)
|
||||
private readonly userRoleRepository: Repository<UserRole>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async getEmployeeUserRoles(organizationId: string, userId: string) {
|
||||
await this.assertUserBelongsToOrganization(organizationId, userId);
|
||||
|
||||
const userRoles = await this.userRoleRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
unitId: IsNull(),
|
||||
},
|
||||
relations: {
|
||||
role: true,
|
||||
},
|
||||
order: {
|
||||
role: {
|
||||
key: "ASC",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return userRoles
|
||||
.map((userRole) => userRole.role)
|
||||
.filter((role): role is Role => Boolean(role))
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
key: role.key,
|
||||
name: role.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async replaceEmployeeUserRoles(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
roleIds: string[],
|
||||
) {
|
||||
await this.assertUserBelongsToOrganization(organizationId, userId);
|
||||
|
||||
const uniqueRoleIds = [...new Set(roleIds)];
|
||||
const roles = uniqueRoleIds.length
|
||||
? await this.roleRepository.find({
|
||||
where: {
|
||||
id: In(uniqueRoleIds),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
if (roles.length !== uniqueRoleIds.length) {
|
||||
throw new NotFoundException("one_or_more_roles_not_found");
|
||||
}
|
||||
|
||||
const reservedRoles = roles.filter((role) => RESERVED_ROLE_KEYS.has(role.key));
|
||||
if (reservedRoles.length) {
|
||||
throw new BadRequestException("reserved_roles_must_use_admin_actions");
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(UserRole).delete({
|
||||
userId,
|
||||
organizationId,
|
||||
unitId: IsNull(),
|
||||
});
|
||||
|
||||
if (!roles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.getRepository(UserRole).insert(
|
||||
roles.map((role) => ({
|
||||
userId,
|
||||
roleId: role.id,
|
||||
organizationId,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
return this.getEmployeeUserRoles(organizationId, userId);
|
||||
}
|
||||
|
||||
private async assertUserBelongsToOrganization(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const exists = await this.userRepository
|
||||
.createQueryBuilder("user")
|
||||
.innerJoin(
|
||||
"user.employee",
|
||||
"employee",
|
||||
"employee.organizationId = :organizationId AND employee.isCurrent = true",
|
||||
{ organizationId },
|
||||
)
|
||||
.where("user.id = :userId", { userId })
|
||||
.getExists();
|
||||
|
||||
if (!exists) {
|
||||
throw new NotFoundException("user_not_found_in_organization");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsUUID } from "class-validator";
|
||||
|
||||
export class UpdateEmployeeUserRolesDto {
|
||||
@ApiProperty({ type: [String] })
|
||||
@IsArray()
|
||||
@IsUUID("4", { each: true })
|
||||
roleIds!: string[];
|
||||
}
|
||||
82
apps/edr-freight-api/src/seed/edr-org.seeder.ts
Normal file
82
apps/edr-freight-api/src/seed/edr-org.seeder.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
Organization,
|
||||
OrganizationConfiguration,
|
||||
Role,
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
const EDR_ORG_KEY = "edr_freight";
|
||||
const EDR_ORG_NAME = { en: "EDR Freight" };
|
||||
const SEED_FLAG = "SEED_EDR_ORG";
|
||||
const EDR_ROLES = [
|
||||
{
|
||||
key: "edr_employee",
|
||||
name: { en: "EDR Employee" },
|
||||
},
|
||||
{
|
||||
key: "edr_customer",
|
||||
name: { en: "EDR Customer" },
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class EdrOrgSeeder {
|
||||
private readonly logger = new Logger(EdrOrgSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run() {
|
||||
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true";
|
||||
|
||||
if (!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 roleRepository.upsert(EDR_ROLES, {
|
||||
conflictPaths: { key: true },
|
||||
});
|
||||
|
||||
this.logger.log("Ensured EDR roles 'edr_employee' and 'edr_customer'");
|
||||
|
||||
let organization = await organizationRepository.findOne({
|
||||
where: { key: EDR_ORG_KEY },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
const insertResult = await organizationRepository.insert({
|
||||
key: EDR_ORG_KEY,
|
||||
name: EDR_ORG_NAME,
|
||||
isGovernmentOrganization: true,
|
||||
});
|
||||
|
||||
organization = {
|
||||
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}'`);
|
||||
}
|
||||
|
||||
await organizationConfigurationRepository.upsert({
|
||||
organizationId: organization.id,
|
||||
canCreateBranchByItself: true,
|
||||
canStartReceivingRecord: true,
|
||||
}, {
|
||||
conflictPaths: { organizationId: true },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Ensured organization configuration for '${EDR_ORG_KEY}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
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 { LayoutDashboard, Network } from "lucide-react";
|
||||
|
||||
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 UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
||||
import LoadingScreen from "./components/LoadingScreen";
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
@@ -19,23 +17,7 @@ const sidebarItems: SidebarItem[] = [
|
||||
{
|
||||
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 />,
|
||||
},
|
||||
],
|
||||
icon: <Network />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -84,16 +66,9 @@ const App = () => {
|
||||
<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 path="user-management" element={<UserManagementPage />} />
|
||||
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
|
||||
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -125,8 +125,15 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
mfaEmailRef.current = null;
|
||||
},
|
||||
logout: () => {
|
||||
const preservedTheme = window.localStorage.getItem("edr-theme");
|
||||
|
||||
clearSessionCookies();
|
||||
localStorage.clear();
|
||||
window.localStorage.clear();
|
||||
|
||||
if (preservedTheme === "dark" || preservedTheme === "light") {
|
||||
window.localStorage.setItem("edr-theme", preservedTheme);
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
window.location.replace("/auth");
|
||||
},
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
interface LocaleText {
|
||||
en?: string;
|
||||
am?: string;
|
||||
}
|
||||
|
||||
interface AuthRole {
|
||||
id?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface AuthPermission {
|
||||
id?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface AuthEmployeePosition {
|
||||
id?: string;
|
||||
employeePositionId?: string;
|
||||
name?: LocaleText;
|
||||
key?: string;
|
||||
isDelegate?: boolean;
|
||||
parentPositionId?: string | null;
|
||||
permissions?: AuthPermission[];
|
||||
}
|
||||
|
||||
interface AuthEmployeeRecord {
|
||||
id?: string;
|
||||
organizationId?: string;
|
||||
unitId?: string;
|
||||
name?: LocaleText;
|
||||
positions?: AuthEmployeePosition[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
name?: {
|
||||
en?: string;
|
||||
am?: string;
|
||||
};
|
||||
phoneNumber?: string;
|
||||
name?: LocaleText;
|
||||
roles?: AuthRole[];
|
||||
permissions?: AuthPermission[];
|
||||
employee?: AuthEmployeeRecord[];
|
||||
hasSetPassword?: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
|
||||
@@ -104,40 +104,8 @@ const LoginPage = () => {
|
||||
};
|
||||
|
||||
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="min-h-screen 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 ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ const App = () => {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (user) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<EDRFreightLandingPage />} />
|
||||
|
||||
Reference in New Issue
Block a user