mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Merge pull request #40 from Tria-plc/freight/feature/bootstrap_backoffice
Freight/feature/bootstrap backoffice
This commit is contained in:
@@ -17,7 +17,9 @@ import { BillingModule } from "./modules/billing/billing.module";
|
|||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -42,16 +44,18 @@ import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-se
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
|
BackofficeModule,
|
||||||
],
|
],
|
||||||
|
providers: [EdrOrgSeeder],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly seeder: DataSeeder,
|
private readonly seeder: DataSeeder,
|
||||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
await this.seeder.run();
|
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 { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
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 { useAuth } from "./auth/useAuth";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
||||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
|
||||||
import DepartmentsPage from "./pages/dashboard/user-management/DepartmentsPage";
|
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
@@ -19,23 +17,7 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
{
|
{
|
||||||
label: "User management",
|
label: "User management",
|
||||||
href: "/dashboard/user-management",
|
href: "/dashboard/user-management",
|
||||||
icon: <ShieldCheck />,
|
icon: <Network />,
|
||||||
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 />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -84,16 +66,9 @@ const App = () => {
|
|||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
<Route
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
path="user-management"
|
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
|
||||||
element={<Navigate to="/dashboard/user-management/users" replace />}
|
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
|
||||||
/>
|
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
|
||||||
<Route path="user-management/roles" element={<RolesPage />} />
|
|
||||||
<Route
|
|
||||||
path="user-management/departments"
|
|
||||||
element={<DepartmentsPage />}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -125,8 +125,15 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
mfaEmailRef.current = null;
|
mfaEmailRef.current = null;
|
||||||
},
|
},
|
||||||
logout: () => {
|
logout: () => {
|
||||||
|
const preservedTheme = window.localStorage.getItem("edr-theme");
|
||||||
|
|
||||||
clearSessionCookies();
|
clearSessionCookies();
|
||||||
localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
|
||||||
|
if (preservedTheme === "dark" || preservedTheme === "light") {
|
||||||
|
window.localStorage.setItem("edr-theme", preservedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
window.location.replace("/auth");
|
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 {
|
export interface AuthUser {
|
||||||
id?: string;
|
id?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
name?: {
|
phoneNumber?: string;
|
||||||
en?: string;
|
name?: LocaleText;
|
||||||
am?: string;
|
roles?: AuthRole[];
|
||||||
};
|
permissions?: AuthPermission[];
|
||||||
|
employee?: AuthEmployeeRecord[];
|
||||||
|
hasSetPassword?: boolean;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
|
|||||||
@@ -104,40 +104,8 @@ const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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]">
|
<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">
|
<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">
|
<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 ? (
|
{!needsMfa ? (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
142
orgstructure.md
Normal file
142
orgstructure.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# IAM Org Structure
|
||||||
|
|
||||||
|
## Core Model
|
||||||
|
|
||||||
|
### Organization
|
||||||
|
- Top-level tenant or company.
|
||||||
|
- Entity: `Organization`
|
||||||
|
- Has: `units`, `positions`, `employees`
|
||||||
|
- Supports hierarchy through `parent` and `branches`
|
||||||
|
|
||||||
|
### Unit
|
||||||
|
- Organizational subdivision under an organization.
|
||||||
|
- Entity: `Unit`
|
||||||
|
- Belongs to `organizationId`
|
||||||
|
- Supports hierarchy through `parentUnit` and `subUnits`
|
||||||
|
- Has: `positions`, `positionTypes`, `employees`, `employeePositions`
|
||||||
|
|
||||||
|
### Department
|
||||||
|
- In the IAM UI, a department is effectively a `Position`.
|
||||||
|
- There is no separate backend `Department` entity in this package.
|
||||||
|
- In the org tree UI:
|
||||||
|
- Organization -> Unit -> Department ~= Position
|
||||||
|
- Sub-department ~= subPosition
|
||||||
|
|
||||||
|
### Position
|
||||||
|
- The actual backend model behind the UI's department concept.
|
||||||
|
- Entity: `Position`
|
||||||
|
- Belongs to: `unitId`, `organizationId`
|
||||||
|
- Supports hierarchy through `parentPositionId` and `subPositions`
|
||||||
|
- Has assigned people through `employeePositions`
|
||||||
|
- Can have direct permissions through `positionPermission`
|
||||||
|
- Also linked to a `positionType`
|
||||||
|
|
||||||
|
### Position Type
|
||||||
|
- Template or category for positions.
|
||||||
|
- Entity: `PositionType`
|
||||||
|
- Example seeded concepts include things like employee, team leader, director, deputy.
|
||||||
|
- Can carry permissions through `position_type_permissions`
|
||||||
|
|
||||||
|
### Employee
|
||||||
|
- Org-scoped representation of a person inside an organization and unit.
|
||||||
|
- Entity: `Employee`
|
||||||
|
- Links a `User` into `organizationId` and `unitId`
|
||||||
|
- Has `employeePositions[]` for actual assignments
|
||||||
|
- Uses `isCurrent` and `status` to indicate active records
|
||||||
|
|
||||||
|
### EmployeePosition
|
||||||
|
- Assignment join between `Employee` and `Position`.
|
||||||
|
- Entity: `EmployeePosition`
|
||||||
|
- Holds the active working context:
|
||||||
|
- `isCurrent`
|
||||||
|
- `status`
|
||||||
|
- `isDelegate`
|
||||||
|
- `delegatorId`
|
||||||
|
- `startDate` and `endDate`
|
||||||
|
- This is the position context the auth layer ultimately uses
|
||||||
|
|
||||||
|
### User
|
||||||
|
- Global identity record.
|
||||||
|
- Entity: `User`
|
||||||
|
- Has login/account fields like `username`, `email`, `phoneNumber`
|
||||||
|
- Has:
|
||||||
|
- `userRoles[]`
|
||||||
|
- `employee[]`
|
||||||
|
- A single user can have multiple employee records and multiple org assignments
|
||||||
|
|
||||||
|
### Role
|
||||||
|
- RBAC grouping of permissions.
|
||||||
|
- Entity: `Role`
|
||||||
|
- Assigned to users via `UserRole`
|
||||||
|
- Has permissions via `RolePermission`
|
||||||
|
|
||||||
|
### Permission
|
||||||
|
- Atomic authorization capability.
|
||||||
|
- Entity: `Permission`
|
||||||
|
- Main fields include `key`, `name`, and optional `applicationKey`
|
||||||
|
- Can be granted through:
|
||||||
|
1. `role_permissions`
|
||||||
|
2. `position_permissions`
|
||||||
|
3. `position_type_permissions`
|
||||||
|
|
||||||
|
## Relationship Summary
|
||||||
|
|
||||||
|
1. `Organization` contains many `Unit` records.
|
||||||
|
2. `Unit` contains many `Position` records.
|
||||||
|
3. The UI calls those positions departments.
|
||||||
|
4. `User` is the identity.
|
||||||
|
5. `Employee` links that user to an organization and unit.
|
||||||
|
6. `EmployeePosition` links the employee to one or more positions.
|
||||||
|
7. `Role` is assigned directly to the user via `UserRole`.
|
||||||
|
8. `Permission` can come from the user's roles, the position itself, or the position type.
|
||||||
|
|
||||||
|
## Runtime Permission Model
|
||||||
|
|
||||||
|
At login, IAM builds a session `userInfo` payload that includes:
|
||||||
|
|
||||||
|
- `roles`: from `userRoles`
|
||||||
|
- `permissions`: flattened from role permissions
|
||||||
|
- `employee.positions[].permissions`: combined from:
|
||||||
|
- direct `positionPermission`
|
||||||
|
- inherited `positionTypePermissions`
|
||||||
|
|
||||||
|
This means authorization has two practical layers:
|
||||||
|
|
||||||
|
1. User-level permissions from roles
|
||||||
|
2. Position-context permissions from the active position and its type
|
||||||
|
|
||||||
|
## Active Context During Requests
|
||||||
|
|
||||||
|
The auth guard uses request headers to decide which employee position is the current working context.
|
||||||
|
|
||||||
|
Important headers include:
|
||||||
|
|
||||||
|
- `x-current-position-id`
|
||||||
|
- `x-delegator-position-id`
|
||||||
|
- `x-current-project-id`
|
||||||
|
- `x-organization-unit-id`
|
||||||
|
|
||||||
|
That selected context becomes the active `request.user.employee.position` and is also used for auditing.
|
||||||
|
|
||||||
|
## Practical Mental Model
|
||||||
|
|
||||||
|
Use this simplified model when reasoning about IAM:
|
||||||
|
|
||||||
|
1. A `User` is the account.
|
||||||
|
2. An `Employee` is that user inside an organization.
|
||||||
|
3. A `Position` is the department-like slot in the org tree.
|
||||||
|
4. An `EmployeePosition` says which employee occupies which position.
|
||||||
|
5. A `Role` gives broad user-level permissions.
|
||||||
|
6. A `Position` and `PositionType` give contextual working permissions.
|
||||||
|
|
||||||
|
## UI Mapping
|
||||||
|
|
||||||
|
In `@tria-plc/iamui-common` user management:
|
||||||
|
|
||||||
|
- Organizations -> `Organization`
|
||||||
|
- Units -> `Unit`
|
||||||
|
- Departments -> `Position`
|
||||||
|
- Sub-departments -> child `Position`
|
||||||
|
- Team members/employees -> `Employee` plus `EmployeePosition`
|
||||||
|
- Roles -> `Role`
|
||||||
|
- Permissions -> `Permission`
|
||||||
@@ -37,7 +37,7 @@ function getInitialTheme(): Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const iconButtonClass =
|
const iconButtonClass =
|
||||||
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] dark:border-slate-700 dark:text-slate-300 dark:hover:border-[#10B981]/40 dark:hover:bg-[#10B981]/20 dark:hover:text-white";
|
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-border bg-card text-foreground transition hover:border-[#10B981]/30 hover:bg-accent hover:text-accent-foreground";
|
||||||
|
|
||||||
const DashboardLayout = ({
|
const DashboardLayout = ({
|
||||||
title,
|
title,
|
||||||
@@ -112,7 +112,7 @@ const DashboardLayout = ({
|
|||||||
aria-label={
|
aria-label={
|
||||||
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
||||||
}
|
}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981] dark:text-slate-300 dark:hover:bg-[#10B981]/20 dark:hover:text-white"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
@@ -132,8 +132,8 @@ const DashboardLayout = ({
|
|||||||
headerExtra={themeToggleButton}
|
headerExtra={themeToggleButton}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<header className="flex h-16 items-center justify-between border-b px-6 ">
|
<header className="flex h-16 items-center justify-between border-b border-border bg-background px-6 text-foreground">
|
||||||
<div className="text-base font-medium ">{title}</div>
|
<div className="text-base font-medium">{title}</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -159,31 +159,31 @@ const DashboardLayout = ({
|
|||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={isUserMenuOpen}
|
aria-expanded={isUserMenuOpen}
|
||||||
onClick={() => setIsUserMenuOpen((open) => !open)}
|
onClick={() => setIsUserMenuOpen((open) => !open)}
|
||||||
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5 aria-expanded:border-[#10B981]/30 aria-expanded:bg-[#10B981]/10 dark:hover:border-[#10B981]/30 dark:hover:bg-[#10B981]/10 dark:aria-expanded:border-[#10B981]/40 dark:aria-expanded:bg-[#10B981]/20"
|
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#10B981]/20 hover:bg-accent aria-expanded:border-[#10B981]/30 aria-expanded:bg-accent"
|
||||||
>
|
>
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#10B981] text-xs font-semibold text-white">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#10B981] text-xs font-semibold text-white">
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden text-sm font-medium text-slate-700 md:block dark:text-slate-200">
|
<span className="hidden text-sm font-medium text-foreground md:block">
|
||||||
{userName}
|
{userName}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`h-4 w-4 text-slate-400 transition dark:text-slate-500 ${isUserMenuOpen ? "rotate-180 text-[#33578D]" : ""
|
className={`h-4 w-4 text-muted-foreground transition ${isUserMenuOpen ? "rotate-180 text-[#10B981]" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isUserMenuOpen ? (
|
{isUserMenuOpen ? (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800"
|
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-border bg-card py-1 shadow-lg"
|
||||||
>
|
>
|
||||||
<div className="border-b border-slate-100 px-4 py-3 dark:border-slate-700">
|
<div className="border-b border-border px-4 py-3">
|
||||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
<p className="text-sm font-semibold text-card-foreground">
|
||||||
{userName}
|
{userName}
|
||||||
</p>
|
</p>
|
||||||
{userEmail ? (
|
{userEmail ? (
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
{userEmail}
|
{userEmail}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -192,7 +192,7 @@ const DashboardLayout = ({
|
|||||||
href="#profile"
|
href="#profile"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setIsUserMenuOpen(false)}
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 transition hover:bg-[#10B981]/10 hover:text-[#10B981] dark:text-slate-300 dark:hover:bg-[#10B981]/20 dark:hover:text-white"
|
className="flex items-center gap-2 px-4 py-2 text-sm text-card-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
Profile
|
Profile
|
||||||
|
|||||||
@@ -83,34 +83,38 @@ const Sidebar = ({
|
|||||||
item.children?.some((child) =>
|
item.children?.some((child) =>
|
||||||
activePath.startsWith(child.href.toLowerCase()),
|
activePath.startsWith(child.href.toLowerCase()),
|
||||||
) ?? false;
|
) ?? false;
|
||||||
const isActive =
|
const isCurrentItem = hasChildren
|
||||||
activePath === itemHref ||
|
? activePath === itemHref
|
||||||
activePath.startsWith(`${itemHref}/`) ||
|
: activePath === itemHref || activePath.startsWith(`${itemHref}/`);
|
||||||
childActive;
|
const isSectionActive = childActive && !isCurrentItem;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.href} className="flex flex-col gap-1">
|
<div key={item.href} className="flex flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group flex items-center gap-2 rounded-md transition",
|
"group flex items-center gap-2 rounded-md transition",
|
||||||
isActive
|
isCurrentItem
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||||
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
: isSectionActive
|
||||||
|
? "bg-sidebar-accent/70 text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={(event) => navigateTo(event, item.href)}
|
onClick={(event) => navigateTo(event, item.href)}
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isCurrentItem ? "page" : undefined}
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm font-medium"
|
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
||||||
isActive
|
isCurrentItem
|
||||||
? "text-white"
|
? "text-white"
|
||||||
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
: isSectionActive
|
||||||
|
? "text-[#10B981] dark:text-emerald-300"
|
||||||
|
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
@@ -131,9 +135,11 @@ const Sidebar = ({
|
|||||||
}
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md transition",
|
"mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md transition",
|
||||||
isActive
|
isCurrentItem
|
||||||
? "text-white/90 hover:bg-white/10"
|
? "text-white/90 hover:bg-white/10"
|
||||||
: "text-slate-500 hover:bg-sidebar-accent dark:text-slate-400",
|
: isSectionActive
|
||||||
|
? "text-[#10B981] hover:bg-sidebar-accent/80 dark:text-emerald-300"
|
||||||
|
: "text-slate-500 hover:bg-sidebar-accent/60 dark:text-slate-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -161,7 +167,7 @@ const Sidebar = ({
|
|||||||
"rounded-md px-3 py-2 text-sm transition",
|
"rounded-md px-3 py-2 text-sm transition",
|
||||||
childActiveHref
|
childActiveHref
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||||
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{child.label}
|
{child.label}
|
||||||
|
|||||||
Reference in New Issue
Block a user