feat(freight:backoffice): migrated smart-office user-management

This commit is contained in:
Michael Abebe
2026-05-26 12:50:40 +03:00
parent a8d26b73b8
commit b487d47055
9 changed files with 2350 additions and 15 deletions

View File

@@ -16,6 +16,7 @@ import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
@Module({
@@ -40,6 +41,7 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
NotificationsModule,
FileUploadSettingsModule,
DropdownSettingsModule,
BackofficeModule,
],
providers: [EdrOrgSeeder],
})

View File

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

View File

@@ -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 {}

View File

@@ -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");
}
}
}

View File

@@ -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[];
}

View File

@@ -1,14 +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, Network } 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 OrgStructurePage from "./pages/dashboard/org-structure/OrgStructurePage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
import LoadingScreen from "./components/LoadingScreen";
const sidebarItems: SidebarItem[] = [
@@ -18,8 +15,8 @@ const sidebarItems: SidebarItem[] = [
icon: <LayoutDashboard />,
},
{
label: "Org structure",
href: "/dashboard/org-structure",
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
},
];
@@ -69,9 +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="org-structure" element={<OrgStructurePage />} />
<Route path="org-structure/units/:unitId" element={<OrgStructurePage />} />
<Route path="org-structure/units/:unitId/:section" element={<OrgStructurePage />} />
<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>

View File

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

View File

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