mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #53 from Tria-plc/freight/feature/backoffice_iam_demo
Freight/feature/backoffice iam demo
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { DemoPermissionsController } from "./demo-permissions.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [DemoPermissionsController],
|
||||
})
|
||||
export class DemoPermissionsModule {}
|
||||
213
apps/edr-freight-api/src/seed/demo-users.seeder.ts
Normal file
213
apps/edr-freight-api/src/seed/demo-users.seeder.ts
Normal 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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user