Merge pull request #53 from Tria-plc/freight/feature/backoffice_iam_demo

Freight/feature/backoffice iam demo
This commit is contained in:
yaschalew10
2026-05-29 12:17:02 +03:00
committed by GitHub
7 changed files with 449 additions and 33 deletions

View File

@@ -20,7 +20,9 @@ import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-set
import { OtpModule } from './modules/otp/otp.module';
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.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: [
@@ -48,17 +50,20 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
OtpModule,
RuleEngineModule,
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();
}
}

View File

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

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { DemoPermissionsController } from "./demo-permissions.controller";
@Module({
controllers: [DemoPermissionsController],
})
export class DemoPermissionsModule {}

View File

@@ -0,0 +1,213 @@
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,
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 superAdminRole = await roleRepository.findOne({
where: { key: ERoleKey.SUPER_ADMIN },
select: { id: true, key: true },
});
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,
},
...(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, {
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)",
);
}
}

View File

@@ -10,45 +10,77 @@ 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 { RuleEnginePage } from "./pages/ruleEngine/RuleEngine";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
const sidebarItems: SidebarItem[] = [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
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: <Settings />,
},
];
const hasPermission = (
user: ReturnType<typeof useAuth>["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: <LayoutDashboard />,
},
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
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: <Settings />,
},
...(hasPermission(user, "can:demo:user1")
? ([
{
label: "User1",
href: "/dashboard/user1",
icon: <Settings />,
},
] as SidebarItem[])
: []),
...(hasPermission(user, "can:demo:user2")
? ([
{
label: "User2",
href: "/dashboard/user2",
icon: <Settings />,
},
] as SidebarItem[])
: []),
];
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
@@ -97,6 +129,8 @@ const App = () => {
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
</Route>

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { api } from "@/auth/http";
const DemoUser1Page = () => {
const [data, setData] = useState<unknown>(null);
const [error, setError] = useState<string | null>(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 (
<div className="p-6">
<div className="rounded-2xl border border-border bg-card p-6">
<h1 className="text-lg font-semibold text-foreground">User1 Demo</h1>
<p className="mt-1 text-sm text-muted-foreground">
Calls <code className="font-mono">GET /api/test_user1</code> (requires{' '}
<code className="font-mono">can:demo:user1</code>).
</p>
<div className="mt-4">
{loading ? <p className="text-sm text-muted-foreground">Loading...</p> : null}
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{!loading && !error ? (
<pre className="mt-3 overflow-auto rounded-xl border border-border bg-background p-4 text-xs text-foreground">
{JSON.stringify(data, null, 2)}
</pre>
) : null}
</div>
</div>
</div>
);
};
export default DemoUser1Page;

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { api } from "@/auth/http";
const DemoUser2Page = () => {
const [data, setData] = useState<unknown>(null);
const [error, setError] = useState<string | null>(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 (
<div className="p-6">
<div className="rounded-2xl border border-border bg-card p-6">
<h1 className="text-lg font-semibold text-foreground">User2 Demo</h1>
<p className="mt-1 text-sm text-muted-foreground">
Calls <code className="font-mono">GET /api/test_user2</code> (requires{' '}
<code className="font-mono">can:demo:user2</code>).
</p>
<div className="mt-4">
{loading ? <p className="text-sm text-muted-foreground">Loading...</p> : null}
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{!loading && !error ? (
<pre className="mt-3 overflow-auto rounded-xl border border-border bg-background p-4 text-xs text-foreground">
{JSON.stringify(data, null, 2)}
</pre>
) : null}
</div>
</div>
</div>
);
};
export default DemoUser2Page;