Organization Detail User Management Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a Quản lý người dùng tab to organization detail so Super Admin, Admin, and Owner can manage organization users with the approved scope and hierarchy rules.
Architecture: Keep the current /admin/organizations/:id route and split the work into two thin layers: a backend user-authorization helper that enforces actor-target rules, and a frontend organization-scoped tab that reuses existing user-management components with capability-based props. Use the existing /users CRUD endpoints plus /users/:id/change-password, and normalize role loading so the organization tab can submit real roleIds instead of hard-coded role names.
Tech Stack: Bun workspaces, NestJS, TypeORM, React 19, Ant Design 6, TanStack Query v5, Zustand, Biome, Vitest + Testing Library for frontend render tests, Bun test for pure TypeScript/unit tests
File Structure
Backend
- Create:
backend/src/users/user-authorization.service.ts
Responsibility: centralize actor-role, organization-scope, and target-role checks for user management. - Create:
backend/src/users/user-authorization.service.spec.ts
Responsibility: unit-testSuper Admin,Admin,Owner,Staff, andTechnicalbehavior. - Create:
backend/src/users/dto/query-users.dto.ts
Responsibility: typed query DTO forpage,limit,search,status,roleId, andorgId. - Modify:
backend/src/users/dto/create-user.dto.ts
Responsibility: acceptorganizationIdsoSuper Admincan create users in the selected organization. - Modify:
backend/src/users/dto/index.ts
Responsibility: exportQueryUsersDto. - Modify:
backend/src/users/users.controller.ts
Responsibility: acceptQueryUsersDtoinGET /users. - Modify:
backend/src/users/user.service.ts
Responsibility: apply organization-scoped list filtering, role-hierarchy checks, create/update/delete/status enforcement, and password-change authorization. - Modify:
backend/src/users/user.service.spec.ts
Responsibility: lock in the new query and mutation behavior. - Modify:
backend/src/users/users.module.ts
Responsibility: registerRolerepository andUserAuthorizationService. - Modify:
backend/src/roles/role.controller.ts
Responsibility: normalize controller path torolesso the frontend can call/api/v1/roles. - Modify:
backend/test/rbac/role.controller.e2e-spec.ts
Responsibility: make the e2e route test mirror the realapi/v1global prefix.
Frontend
- Create:
frontend/src/pages/organizations/organizationUserCapabilities.ts
Responsibility: pure helper for tab visibility, row-action visibility, and assignable-role filtering. - Create:
frontend/src/pages/organizations/organizationUserCapabilities.spec.ts
Responsibility: unit-test actor and target capability rules without a DOM harness. - Create:
frontend/src/types/role.types.ts
Responsibility: typed role shape for dropdowns and filtering. - Create:
frontend/src/services/role.service.ts
Responsibility: fetch roles from/roles. - Create:
frontend/src/hooks/useRoles.ts
Responsibility: cache role lists with React Query. - Create:
frontend/src/pages/organizations/components/OrganizationUsersTab.tsx
Responsibility: organization-scoped user-management container. - Create:
frontend/src/pages/organizations/components/OrganizationUsersTab.spec.tsx
Responsibility: render-test the organization tab behavior and verify the organization context is fixed. - Create:
frontend/src/test/setup.ts
Responsibility: Testing Library and DOM matchers setup. - Create:
frontend/src/test/renderWithProviders.tsx
Responsibility: shared render helper withQueryClientProvider,ConfigProvider, and router. - Create:
frontend/vitest.config.ts
Responsibility: jsdom test config with@/alias support. - Modify:
frontend/package.json
Responsibility: add frontend test tooling. - Modify:
frontend/src/types/user.types.ts
Responsibility: add password-change payload types and component capability types. - Modify:
frontend/src/services/user.service.ts
Responsibility: addchangePasswordAPI method. - Modify:
frontend/src/hooks/useUsers.ts
Responsibility: adduseChangeUserPasswordmutation and keep cache invalidation centralized. - Modify:
frontend/src/schemas/user.schema.ts
Responsibility: support optional password updates in edit mode and organization-locked create mode. - Modify:
frontend/src/components/users/UserForm.tsx
Responsibility: support hidden/read-only organization context, optional edit password fields, filtered role options, and status control flags. - Modify:
frontend/src/components/users/UserSlideOver.tsx
Responsibility: pass organization context and edit capabilities into the form. - Modify:
frontend/src/components/users/UserTable.tsx
Responsibility: hide the organization column in organization context and hide row actions the actor cannot perform. - Modify:
frontend/src/pages/organizations/OrganizationDetail.tsx
Responsibility: add the tabs shell and mountOrganizationUsersTab.
Task 1: Create Backend User Authorization Helper
Files:
Create:
backend/src/users/user-authorization.service.tsCreate:
backend/src/users/user-authorization.service.spec.tsModify:
backend/src/users/users.module.tsStep 1: Write the failing test
import { ForbiddenException } from '@nestjs/common';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { UserAuthorizationService } from './user-authorization.service';
describe('UserAuthorizationService', () => {
let service: UserAuthorizationService;
const superAdmin: CurrentUserData = {
userId: 'super-admin-id',
email: 'superadmin@example.com',
organizationId: null,
roles: ['Super Admin'],
permissions: ['user:create:all', 'user:read:all', 'user:update:all', 'user:delete:all'],
assignedResources: [],
};
const admin: CurrentUserData = {
userId: 'admin-id',
email: 'admin@example.com',
organizationId: 'org-1',
roles: ['Admin'],
permissions: ['user:create:all', 'user:read:all', 'user:update:all', 'user:delete:all'],
assignedResources: [],
};
const owner: CurrentUserData = {
userId: 'owner-id',
email: 'owner@example.com',
organizationId: 'org-1',
roles: ['Owner'],
permissions: ['user:read:all', 'user:update:all'],
assignedResources: [],
};
const staff: CurrentUserData = {
userId: 'staff-id',
email: 'staff@example.com',
organizationId: 'org-1',
roles: ['Staff'],
permissions: ['user:read:own'],
assignedResources: [],
};
beforeEach(() => {
service = new UserAuthorizationService();
});
it('allows Super Admin to access any organization tab and create Admin users', () => {
expect(service.getActorCapabilities(superAdmin, 'org-2')).toMatchObject({
canAccessTab: true,
canCreateUser: true,
canDeleteUser: true,
});
expect(() =>
service.assertCanCreate(superAdmin, {
organizationId: 'org-2',
nextRoleNames: ['Admin'],
}),
).not.toThrow();
});
it('allows Admin to fully manage users only in their own organization', () => {
expect(service.getActorCapabilities(admin, 'org-1').canAccessTab).toBe(true);
expect(service.getActorCapabilities(admin, 'org-2').canAccessTab).toBe(false);
expect(() =>
service.assertCanDelete(admin, {
organizationId: 'org-1',
targetRoleNames: ['Staff'],
}),
).not.toThrow();
});
it('blocks Owner from create and delete, and from operating on Admin', () => {
expect(service.getActorCapabilities(owner, 'org-1')).toMatchObject({
canAccessTab: true,
canCreateUser: false,
canDeleteUser: false,
});
expect(() =>
service.assertCanCreate(owner, {
organizationId: 'org-1',
nextRoleNames: ['Staff'],
}),
).toThrow(ForbiddenException);
expect(() =>
service.assertCanUpdate(owner, {
organizationId: 'org-1',
targetRoleNames: ['Admin'],
nextRoleNames: ['Owner'],
}),
).toThrow(ForbiddenException);
});
it('blocks Staff from accessing organization user management', () => {
expect(service.getActorCapabilities(staff, 'org-1').canAccessTab).toBe(false);
expect(() =>
service.assertCanList(staff, {
organizationId: 'org-1',
}),
).toThrow(ForbiddenException);
});
});
- Step 2: Run test to verify it fails
Run: cd backend && bun test src/users/user-authorization.service.spec.ts
Expected: FAIL with Cannot find module './user-authorization.service' or missing-method assertion failures.
- Step 3: Write minimal implementation
import { ForbiddenException, Injectable } from '@nestjs/common';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
interface UserAuthorizationContext {
organizationId: string | null;
targetRoleNames?: string[];
nextRoleNames?: string[];
}
export interface UserActorCapabilities {
canAccessTab: boolean;
canCreateUser: boolean;
canDeleteUser: boolean;
canEditUser: boolean;
canToggleStatus: boolean;
}
@Injectable()
export class UserAuthorizationService {
private readonly roleRank = new Map<string, number>([
['technical', 1],
['staff', 2],
['owner', 3],
['admin', 4],
['super admin', 5],
]);
getActorCapabilities(actor: CurrentUserData, organizationId: string): UserActorCapabilities {
const actorRole = this.getHighestRole(actor.roles);
const inScope = actorRole === 'super admin' || actor.organizationId === organizationId;
return {
canAccessTab: inScope && ['super admin', 'admin', 'owner'].includes(actorRole),
canCreateUser: inScope && ['super admin', 'admin'].includes(actorRole),
canDeleteUser: inScope && ['super admin', 'admin'].includes(actorRole),
canEditUser: inScope && ['super admin', 'admin', 'owner'].includes(actorRole),
canToggleStatus: inScope && ['super admin', 'admin', 'owner'].includes(actorRole),
};
}
assertCanList(actor: CurrentUserData, context: Pick<UserAuthorizationContext, 'organizationId'>) {
if (!this.getActorCapabilities(actor, context.organizationId ?? '').canAccessTab) {
throw new ForbiddenException('You do not have permission to access organization users');
}
}
assertCanCreate(actor: CurrentUserData, context: UserAuthorizationContext) {
this.assertInScope(actor, context.organizationId);
if (!['super admin', 'admin'].includes(this.getHighestRole(actor.roles))) {
throw new ForbiddenException('You do not have permission to create users');
}
this.assertAssignableRoles(actor.roles, context.nextRoleNames ?? []);
}
assertCanUpdate(actor: CurrentUserData, context: UserAuthorizationContext) {
this.assertInScope(actor, context.organizationId);
const actorRole = this.getHighestRole(actor.roles);
if (!['super admin', 'admin', 'owner'].includes(actorRole)) {
throw new ForbiddenException('You do not have permission to update users');
}
this.assertTargetBelowActor(actor.roles, context.targetRoleNames ?? []);
this.assertAssignableRoles(actor.roles, context.nextRoleNames ?? []);
}
assertCanDelete(actor: CurrentUserData, context: UserAuthorizationContext) {
this.assertInScope(actor, context.organizationId);
if (!['super admin', 'admin'].includes(this.getHighestRole(actor.roles))) {
throw new ForbiddenException('You do not have permission to delete users');
}
this.assertTargetBelowActor(actor.roles, context.targetRoleNames ?? []);
}
assertCanToggleStatus(actor: CurrentUserData, context: UserAuthorizationContext) {
this.assertInScope(actor, context.organizationId);
const actorRole = this.getHighestRole(actor.roles);
if (!['super admin', 'admin', 'owner'].includes(actorRole)) {
throw new ForbiddenException('You do not have permission to change user status');
}
this.assertTargetBelowActor(actor.roles, context.targetRoleNames ?? []);
}
private assertInScope(actor: CurrentUserData, organizationId: string | null) {
const actorRole = this.getHighestRole(actor.roles);
if (actorRole === 'super admin') {
return;
}
if (!organizationId || actor.organizationId !== organizationId) {
throw new ForbiddenException('You cannot manage users outside your organization');
}
}
private assertTargetBelowActor(actorRoles: string[], targetRoleNames: string[]) {
const actorRank = this.getHighestRoleRank(actorRoles);
const targetHighestRank = this.getHighestRoleRank(targetRoleNames);
if (targetHighestRank >= actorRank && actorRank < this.getHighestRoleRank(['Super Admin'])) {
throw new ForbiddenException('You cannot manage a user with an equal or higher role');
}
}
private assertAssignableRoles(actorRoles: string[], nextRoleNames: string[]) {
const actorRank = this.getHighestRoleRank(actorRoles);
const nextHighestRank = this.getHighestRoleRank(nextRoleNames);
if (nextHighestRank > actorRank && actorRank < this.getHighestRoleRank(['Super Admin'])) {
throw new ForbiddenException('You cannot assign a role higher than your own');
}
}
private getHighestRole(roles: string[]): string {
return [...roles]
.map((role) => role.toLowerCase())
.sort((left, right) => this.getRoleRank(right) - this.getRoleRank(left))[0] ?? 'technical';
}
private getHighestRoleRank(roles: string[]): number {
return roles.reduce((highest, role) => Math.max(highest, this.getRoleRank(role)), 0);
}
private getRoleRank(role: string): number {
return this.roleRank.get(role.toLowerCase()) ?? 0;
}
}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@/auth/entities/user.entity';
import { Role } from '@/roles/entities/role.entity';
import { UserRole } from '@/user-roles/entities/user-role.entity';
import { UserAuthorizationService } from './user-authorization.service';
import { UserService } from './user.service';
import { UsersController } from './users.controller';
@Module({
imports: [TypeOrmModule.forFeature([User, UserRole, Role])],
controllers: [UsersController],
providers: [UserAuthorizationService, UserService],
exports: [UserAuthorizationService, UserService],
})
export class UsersModule {}
- Step 4: Run test to verify it passes
Run: cd backend && bun test src/users/user-authorization.service.spec.ts
Expected: PASS with 4 passing tests.
- Step 5: Commit
git add backend/src/users/user-authorization.service.ts \
backend/src/users/user-authorization.service.spec.ts \
backend/src/users/users.module.ts
git commit -m "feat(backend): add user authorization helper for organization management"
Task 2: Enforce Organization Scope and Role Hierarchy in Users API
Files:
Create:
backend/src/users/dto/query-users.dto.tsModify:
backend/src/users/dto/create-user.dto.tsModify:
backend/src/users/dto/index.tsModify:
backend/src/users/users.controller.tsModify:
backend/src/users/user.service.tsModify:
backend/src/users/user.service.spec.tsStep 1: Write the failing tests
it('allows Super Admin to list users for a selected organization via orgId', async () => {
const queryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(1),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([mockUser]),
};
(userRepository.createQueryBuilder as jest.Mock).mockReturnValue(queryBuilder);
await service.findAll(
{
...mockUser,
organizationId: null,
roles: ['Super Admin'],
} as CurrentUserData,
{ page: 1, limit: 20, orgId: 'org-id-2' },
);
expect(queryBuilder.andWhere).toHaveBeenCalledWith('user.organizationId = :orgId', {
orgId: 'org-id-2',
});
});
it('rejects Owner delete requests even when the target user is in the same organization', async () => {
await expect(
service.remove('user-id-2', {
...mockUser,
roles: ['Owner'],
permissions: ['user:read:all', 'user:update:all'],
} as unknown as User),
).rejects.toThrow('delete users');
});
it('uses the payload organizationId when Super Admin creates a user', async () => {
const manager = {
create: jest.fn().mockReturnValue({ id: 'new-user-id' }),
save: jest.fn().mockResolvedValue({ id: 'new-user-id' }),
find: jest.fn(),
delete: jest.fn(),
};
dataSource.transaction.mockImplementation(async (handler) => handler(manager as never));
(userRepository.findOne as jest.Mock).mockResolvedValue(null);
await service.create(
{
email: 'new@example.com',
password: 'Password123',
fullName: 'New User',
organizationId: 'org-id-2',
roleIds: ['role-staff-id'],
},
{
...mockUser,
organizationId: null,
roles: ['Super Admin'],
} as unknown as User,
);
expect(manager.create).toHaveBeenCalledWith(
User,
expect.objectContaining({ organizationId: 'org-id-2' }),
);
});
- Step 2: Run tests to verify they fail
Run: cd backend && bun test src/users/user.service.spec.ts
Expected: FAIL because orgId is ignored, remove still only checks currentUser.organizationId, and create still forces the actor organization.
- Step 3: Write minimal implementation
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class QueryUsersDto {
@ApiPropertyOptional({ example: 1 })
@IsOptional()
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ example: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@ApiPropertyOptional({ example: 'owner@example.com' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ example: 'active' })
@IsOptional()
@IsString()
status?: string;
@ApiPropertyOptional({ example: 'role-id-1' })
@IsOptional()
@IsUUID()
roleId?: string;
@ApiPropertyOptional({ example: 'organization-id-1' })
@IsOptional()
@IsUUID()
orgId?: string;
}
@ApiProperty({ example: 'organization-id-1' })
@IsString()
@IsNotEmpty({ message: 'Organization ID is required' })
organizationId: string;
export * from './query-users.dto';
import { QueryUsersDto } from './dto/query-users.dto';
async findAll(
@CurrentUser() currentUser: CurrentUserData,
@Query() query: QueryUsersDto,
) {
return this.userService.findAll(currentUser, query);
}
async findAll(
currentUser: CurrentUserData,
query: QueryUsersDto,
): Promise<{ data: User[]; meta: { total: number; page: number; limit: number; totalPages: number } }> {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const requestedOrganizationId = query.orgId ?? currentUser.organizationId ?? null;
if (requestedOrganizationId) {
this.userAuthorizationService.assertCanList(currentUser, {
organizationId: requestedOrganizationId,
});
}
const qb = this.userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.organization', 'organization')
.leftJoinAndSelect('user.userRoles', 'userRoles')
.leftJoinAndSelect('userRoles.role', 'role');
if (requestedOrganizationId) {
qb.andWhere('user.organizationId = :orgId', { orgId: requestedOrganizationId });
}
qb.andWhere('user.id != :currentUserId', { currentUserId: currentUser.userId });
if (query.search) {
qb.andWhere('(user.fullName ILIKE :search OR user.email ILIKE :search)', {
search: `%${query.search}%`,
});
}
if (query.status) {
qb.andWhere('user.status = :status', { status: query.status });
}
if (query.roleId) {
qb.andWhere('userRoles.roleId = :roleId', { roleId: query.roleId });
}
const total = await qb.getCount();
const users = await qb.skip((page - 1) * limit).take(limit).getMany();
return {
data: users.map((user) => this.sanitizeUser(user)),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async create(createUserDto: CreateUserDto, currentUser: User): Promise<User> {
const organizationId = createUserDto.organizationId;
const requestedRoles = await this.roleRepository.findBy({ id: In(createUserDto.roleIds) });
this.userAuthorizationService.assertCanCreate(currentUser as unknown as CurrentUserData, {
organizationId,
nextRoleNames: requestedRoles.map((role) => role.name),
});
const existingUser = await this.userRepository.findOne({
where: { email: createUserDto.email, organizationId },
});
if (existingUser) {
throw new BadRequestException('Email already registered in this organization');
}
const password = await bcrypt.hash(createUserDto.password, 10);
return this.dataSource.transaction(async (manager) => {
const user = manager.create(User, {
email: createUserDto.email,
password,
fullName: createUserDto.fullName,
phone: createUserDto.phone,
organizationId,
status: UserStatus.ACTIVE,
});
const savedUser = await manager.save(user);
for (const roleId of createUserDto.roleIds) {
await manager.save(UserRole, {
userId: savedUser.id,
roleId,
organizationId,
assignedResources: createUserDto.assignedResources || [],
});
}
return this.findOne(savedUser.id, currentUser);
});
}
async remove(id: string, currentUser: User): Promise<void> {
const user = await this.userRepository.findOne({
where: { id },
relations: ['userRoles', 'userRoles.role'],
});
if (!user) {
throw new NotFoundException('User not found');
}
this.userAuthorizationService.assertCanDelete(currentUser as unknown as CurrentUserData, {
organizationId: user.organizationId,
targetRoleNames: user.userRoles.map((userRole) => userRole.role.name),
});
if (await this.hasDependencies(id)) {
throw new BadRequestException('Cannot delete user with active cycles or resources');
}
await this.userRepository.softDelete(id);
}
- Step 4: Run tests to verify they pass
Run: cd backend && bun test src/users/user.service.spec.ts
Expected: PASS with the new orgId, create-scope, and owner-denial cases green.
- Step 5: Commit
git add backend/src/users/dto/query-users.dto.ts \
backend/src/users/dto/create-user.dto.ts \
backend/src/users/dto/index.ts \
backend/src/users/users.controller.ts \
backend/src/users/user.service.ts \
backend/src/users/user.service.spec.ts
git commit -m "feat(backend): enforce organization user management rules"
Task 3: Normalize the Roles Endpoint for Frontend Role Dropdowns
Files:
Modify:
backend/src/roles/role.controller.tsModify:
backend/test/rbac/role.controller.e2e-spec.tsStep 1: Write the failing route test
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(RoleService)
.useValue({
findAll: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
})
.compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
it('serves GET /api/v1/roles when the global prefix is enabled', async () => {
const mockToken = 'mock.jwt.token';
return request(app.getHttpServer())
.get('/api/v1/roles')
.set('Authorization', `Bearer ${mockToken}`)
.expect(200)
.expect([]);
});
- Step 2: Run test to verify it fails
Run: cd backend && bun test test/rbac/role.controller.e2e-spec.ts
Expected: FAIL with 404 because the controller is currently mounted at api/v1/roles and then prefixed again by api/v1.
- Step 3: Write minimal implementation
@ApiTags('Roles')
@ApiBearerAuth()
@Controller('roles')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class RoleController {
constructor(private readonly roleService: RoleService) {}
@Get()
@RequirePermissions('role:read:all')
async findAll() {
return this.roleService.findAll();
}
// keep the rest of the controller unchanged
}
- Step 4: Run test to verify it passes
Run: cd backend && bun test test/rbac/role.controller.e2e-spec.ts
Expected: PASS with /api/v1/roles resolving correctly under the real runtime prefix.
- Step 5: Commit
git add backend/src/roles/role.controller.ts \
backend/test/rbac/role.controller.e2e-spec.ts
git commit -m "fix(backend): normalize roles endpoint under global api prefix"
Task 4: Add Frontend Capability Helper for Organization User Management
Files:
Create:
frontend/src/pages/organizations/organizationUserCapabilities.tsCreate:
frontend/src/pages/organizations/organizationUserCapabilities.spec.tsStep 1: Write the failing test
import { describe, expect, it } from 'bun:test';
import {
getOrganizationUserCapabilities,
getAssignableRoles,
} from '@/pages/organizations/organizationUserCapabilities';
const roles = [
{ id: 'role-super-admin', name: 'Super Admin' },
{ id: 'role-admin', name: 'Admin' },
{ id: 'role-owner', name: 'Owner' },
{ id: 'role-staff', name: 'Staff' },
{ id: 'role-technical', name: 'Technical' },
];
describe('organizationUserCapabilities', () => {
it('lets Admin fully manage users only inside their own organization', () => {
expect(
getOrganizationUserCapabilities({
actorRole: 'Admin',
actorOrganizationId: 'org-1',
selectedOrganizationId: 'org-1',
targetRoleNames: ['Staff'],
}),
).toMatchObject({
canAccessTab: true,
canCreateUser: true,
canDeleteUser: true,
canEditUser: true,
canToggleStatus: true,
});
expect(
getOrganizationUserCapabilities({
actorRole: 'Admin',
actorOrganizationId: 'org-1',
selectedOrganizationId: 'org-2',
targetRoleNames: ['Staff'],
}).canAccessTab,
).toBe(false);
});
it('lets Owner edit and suspend Staff but not create, delete, or manage Admin', () => {
expect(
getOrganizationUserCapabilities({
actorRole: 'Owner',
actorOrganizationId: 'org-1',
selectedOrganizationId: 'org-1',
targetRoleNames: ['Staff'],
}),
).toMatchObject({
canAccessTab: true,
canCreateUser: false,
canDeleteUser: false,
canEditUser: true,
canToggleStatus: true,
});
expect(
getOrganizationUserCapabilities({
actorRole: 'Owner',
actorOrganizationId: 'org-1',
selectedOrganizationId: 'org-1',
targetRoleNames: ['Admin'],
}).canEditUser,
).toBe(false);
});
it('filters assignable roles for Owner down to Owner, Staff, and Technical', () => {
expect(getAssignableRoles('Owner', roles).map((role) => role.name)).toEqual([
'Owner',
'Staff',
'Technical',
]);
});
});
- Step 2: Run test to verify it fails
Run: cd frontend && bun test src/pages/organizations/organizationUserCapabilities.spec.ts
Expected: FAIL with Cannot find module '@/pages/organizations/organizationUserCapabilities'.
- Step 3: Write minimal implementation
import type { RoleOption } from '@/types/role.types';
export interface OrganizationUserCapabilityInput {
actorRole: string | null;
actorOrganizationId: string | null;
selectedOrganizationId: string;
targetRoleNames?: string[];
}
export interface OrganizationUserCapabilities {
canAccessTab: boolean;
canCreateUser: boolean;
canDeleteUser: boolean;
canEditUser: boolean;
canToggleStatus: boolean;
}
const roleRank: Record<string, number> = {
'technical': 1,
'staff': 2,
'owner': 3,
'admin': 4,
'super admin': 5,
};
export function getOrganizationUserCapabilities(
input: OrganizationUserCapabilityInput,
): OrganizationUserCapabilities {
const actorRole = input.actorRole?.toLowerCase() ?? '';
const inScope =
actorRole === 'super admin' || input.actorOrganizationId === input.selectedOrganizationId;
const targetHighestRank = Math.max(
0,
...(input.targetRoleNames ?? []).map((role) => roleRank[role.toLowerCase()] ?? 0),
);
const actorRank = roleRank[actorRole] ?? 0;
const canManageTarget = targetHighestRank < actorRank || actorRole === 'super admin';
return {
canAccessTab: inScope && ['super admin', 'admin', 'owner'].includes(actorRole),
canCreateUser: inScope && ['super admin', 'admin'].includes(actorRole),
canDeleteUser:
inScope && ['super admin', 'admin'].includes(actorRole) && (canManageTarget || !targetHighestRank),
canEditUser:
inScope && ['super admin', 'admin', 'owner'].includes(actorRole) && (canManageTarget || !targetHighestRank),
canToggleStatus:
inScope && ['super admin', 'admin', 'owner'].includes(actorRole) && (canManageTarget || !targetHighestRank),
};
}
export function getAssignableRoles(actorRole: string | null, roles: RoleOption[]): RoleOption[] {
const normalizedRole = actorRole?.toLowerCase() ?? '';
if (normalizedRole === 'super admin') {
return roles;
}
if (normalizedRole === 'admin') {
return roles.filter((role) => role.name !== 'Super Admin');
}
if (normalizedRole === 'owner') {
return roles.filter((role) => ['Owner', 'Staff', 'Technical'].includes(role.name));
}
return [];
}
- Step 4: Run test to verify it passes
Run: cd frontend && bun test src/pages/organizations/organizationUserCapabilities.spec.ts
Expected: PASS with 3 passing tests.
- Step 5: Commit
git add frontend/src/pages/organizations/organizationUserCapabilities.ts \
frontend/src/pages/organizations/organizationUserCapabilities.spec.ts
git commit -m "feat(frontend): add organization user capability helper"
Task 5: Build the Organization Users Tab and Refactor Shared User UI
Files:
Create:
frontend/src/types/role.types.tsCreate:
frontend/src/services/role.service.tsCreate:
frontend/src/hooks/useRoles.tsCreate:
frontend/src/test/setup.tsCreate:
frontend/src/test/renderWithProviders.tsxCreate:
frontend/src/pages/organizations/components/OrganizationUsersTab.tsxCreate:
frontend/src/pages/organizations/components/OrganizationUsersTab.spec.tsxCreate:
frontend/vitest.config.tsModify:
frontend/package.jsonModify:
frontend/src/types/user.types.tsModify:
frontend/src/services/user.service.tsModify:
frontend/src/hooks/useUsers.tsModify:
frontend/src/schemas/user.schema.tsModify:
frontend/src/components/users/UserForm.tsxModify:
frontend/src/components/users/UserSlideOver.tsxModify:
frontend/src/components/users/UserTable.tsxModify:
frontend/src/pages/organizations/OrganizationDetail.tsxStep 1: Install frontend test tooling
Run: cd frontend && bun add -d vitest @testing-library/react @testing-library/jest-dom jsdom
Expected: packages added to frontend/package.json and frontend/bun.lock updated.
- Step 2: Write the failing component test
import { QueryClient } from '@tanstack/react-query';
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '@/test/renderWithProviders';
import { OrganizationUsersTab } from '@/pages/organizations/components/OrganizationUsersTab';
vi.mock('@/hooks/useUsers', () => ({
useUsers: () => ({
data: {
data: [
{
id: 'user-1',
email: 'staff@example.com',
fullName: 'Staff User',
phone: null,
avatarUrl: null,
status: 'active',
organizationId: 'org-1',
organization: { id: 'org-1', name: 'Org 1' },
roles: [{ id: 'role-staff', name: 'Staff' }],
createdAt: '2026-04-23T00:00:00.000Z',
updatedAt: '2026-04-23T00:00:00.000Z',
},
],
meta: { total: 1, page: 1, limit: 20, totalPages: 1 },
},
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useCreateUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
useUpdateUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
useSuspendUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
useActivateUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
useChangeUserPassword: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock('@/hooks/useRoles', () => ({
useRoles: () => ({
data: [
{ id: 'role-admin', name: 'Admin' },
{ id: 'role-owner', name: 'Owner' },
{ id: 'role-staff', name: 'Staff' },
],
isLoading: false,
}),
}));
describe('OrganizationUsersTab', () => {
it('renders organization-scoped user management without the organization selector or organization column', () => {
const queryClient = new QueryClient();
renderWithProviders(
<OrganizationUsersTab organization={{ id: 'org-1', name: 'Org 1', code: 'ORG1', isActive: true, createdAt: '', updatedAt: '' }} />,
{
queryClient,
authState: {
role: 'Admin',
user: { organizationId: 'org-1' },
},
},
);
expect(screen.getByRole('heading', { name: 'Quản lý người dùng' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Tạo người dùng' })).toBeInTheDocument();
expect(screen.queryByText('Tổ chức')).not.toBeInTheDocument();
expect(screen.getByText('Staff User')).toBeInTheDocument();
});
});
- Step 3: Run test to verify it fails
Run: cd frontend && bunx vitest run src/pages/organizations/components/OrganizationUsersTab.spec.tsx
Expected: FAIL because the test harness, role hook, and OrganizationUsersTab do not exist yet.
- Step 4: Write minimal implementation
export interface RoleOption {
id: string;
name: string;
}
import { api } from '@/lib/api';
import type { RoleOption } from '@/types/role.types';
class RoleService {
async getAll(): Promise<RoleOption[]> {
const response = await api.get<RoleOption[]>('/roles');
return response.data;
}
}
export const roleService = new RoleService();
import { useQuery } from '@tanstack/react-query';
import { roleService } from '@/services/role.service';
export function useRoles() {
return useQuery({
queryKey: ['roles'],
queryFn: () => roleService.getAll(),
});
}
import type { RoleOption } from '@/types/role.types';
export interface ChangeUserPasswordData {
currentPassword?: string;
newPassword: string;
}
export interface UserRowCapabilities {
canEdit: boolean;
canDelete: boolean;
canToggleStatus: boolean;
}
export interface UserFormContext {
organizationName?: string;
showOrganizationField?: boolean;
allowPasswordChange?: boolean;
roleOptions: RoleOption[];
}
async changePassword(id: string, data: ChangeUserPasswordData): Promise<void> {
await api.post(`/users/${id}/change-password`, data);
}
export function useChangeUserPassword() {
return useMutation({
mutationFn: ({ id, data }: { id: string; data: ChangeUserPasswordData }) =>
userService.changePassword(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['user', variables.id] });
message.success('Đổi mật khẩu thành công');
},
onError: (error: unknown) => {
message.error(extractApiError(error) || 'Đổi mật khẩu thất bại');
},
});
}
export const updateUserSchema = z
.object({
fullName: z.string().min(2, 'Họ tên phải có ít nhất 2 ký tự').optional(),
phone: z.string().optional(),
status: z.enum(['active', 'inactive', 'suspended']).optional(),
roleIds: z.array(z.string()).optional(),
newPassword: z.string().min(8, 'Mật khẩu phải có ít nhất 8 ký tự').optional(),
confirmNewPassword: z.string().optional(),
})
.refine(
(data) =>
(!data.newPassword && !data.confirmNewPassword) ||
data.newPassword === data.confirmNewPassword,
{
message: 'Mật khẩu xác nhận không khớp',
path: ['confirmNewPassword'],
},
);
interface UserFormProps {
mode: 'create' | 'edit';
user?: User;
organizations: Organization[];
roleOptions: RoleOption[];
showOrganizationField?: boolean;
organizationName?: string;
allowPasswordChange?: boolean;
onSubmit: (values: unknown) => void;
onCancel: () => void;
loading?: boolean;
}
{mode === 'create' && showOrganizationField && (
<Form.Item label="Tổ chức" name="organizationId" rules={createUserRules.organizationId}>
<Select placeholder="Chọn tổ chức" options={orgOptions} size="large" allowClear />
</Form.Item>
)}
{mode === 'edit' && organizationName && (
<Form.Item label="Tổ chức">
<Input disabled value={organizationName} />
</Form.Item>
)}
{mode === 'edit' && allowPasswordChange && (
<>
<Form.Item label="Mật khẩu mới" name="newPassword" rules={updateUserRules.newPassword}>
<Input.Password placeholder="Để trống nếu không đổi mật khẩu" size="large" />
</Form.Item>
<Form.Item
label="Xác nhận mật khẩu mới"
name="confirmNewPassword"
rules={updateUserRules.confirmNewPassword}
>
<Input.Password placeholder="Nhập lại mật khẩu mới" size="large" />
</Form.Item>
</>
)}
interface UserTableProps {
data: User[];
loading?: boolean;
showOrganizationColumn?: boolean;
getRowCapabilities?: (user: User) => UserRowCapabilities;
onEdit: (user: User) => void;
onView: (user: User) => void;
onDelete: (user: User) => void;
onSuspend: (user: User) => void;
onActivate: (user: User) => void;
}
const rowCapabilities = getRowCapabilities?.(user) ?? {
canEdit: true,
canDelete: true,
canToggleStatus: true,
};
import { ConfigProvider } from 'antd';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { render } from '@testing-library/react';
export function renderWithProviders(
ui: React.ReactElement,
options: {
queryClient?: QueryClient;
initialEntries?: string[];
authState?: Partial<{ role: string | null; user: { organizationId?: string | null } | null }>;
} = {},
) {
const queryClient = options.queryClient ?? new QueryClient();
return render(
<ConfigProvider>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={options.initialEntries ?? ['/']}>{ui}</MemoryRouter>
</QueryClientProvider>
</ConfigProvider>,
);
}
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});
import { PlusOutlined } from '@ant-design/icons';
import { Button, Card, Pagination, Row, Space, Typography } from 'antd';
import { useMemo, useState } from 'react';
import { useAuthStore } from '@/stores/auth.store';
import { useRoles } from '@/hooks/useRoles';
import {
useActivateUser,
useChangeUserPassword,
useCreateUser,
useDeleteUser,
useSuspendUser,
useUpdateUser,
useUsers,
} from '@/hooks/useUsers';
import { DeleteConfirmModal } from '@/components/users/DeleteConfirmModal';
import { UserSlideOver } from '@/components/users/UserSlideOver';
import { UserTable } from '@/components/users/UserTable';
import { getAssignableRoles, getOrganizationUserCapabilities } from '@/pages/organizations/organizationUserCapabilities';
export const OrganizationUsersTab = ({ organization }: { organization: Organization }) => {
const actorRole = useAuthStore((state) => state.role);
const actorOrganizationId = useAuthStore((state) => state.user?.organizationId ?? null);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const tabCapabilities = getOrganizationUserCapabilities({
actorRole,
actorOrganizationId,
selectedOrganizationId: organization.id,
});
const { data: usersData, isLoading, isError, refetch } = useUsers({
page,
limit: 20,
search: search || undefined,
orgId: organization.id,
});
const { data: allRoles = [] } = useRoles();
const roleOptions = useMemo(() => getAssignableRoles(actorRole, allRoles), [actorRole, allRoles]);
if (!tabCapabilities.canAccessTab) {
return (
<Card>
<Typography.Text type="secondary">
Bạn không có quyền quản lý người dùng của tổ chức này.
</Typography.Text>
</Card>
);
}
return (
<div>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<Typography.Title level={4}>Quản lý người dùng</Typography.Title>
<Typography.Text type="secondary">
Quản lý người dùng trong tổ chức {organization.name}
</Typography.Text>
</div>
{tabCapabilities.canCreateUser && (
<Button type="primary" icon={<PlusOutlined />}>
Tạo người dùng
</Button>
)}
</Space>
<UserTable
data={usersData?.data ?? []}
loading={isLoading}
showOrganizationColumn={false}
getRowCapabilities={(user) => {
const capabilities = getOrganizationUserCapabilities({
actorRole,
actorOrganizationId,
selectedOrganizationId: organization.id,
targetRoleNames: user.roles.map((role) => role.name),
});
return {
canEdit: capabilities.canEditUser,
canDelete: capabilities.canDeleteUser,
canToggleStatus: capabilities.canToggleStatus,
};
}}
onEdit={() => undefined}
onView={() => undefined}
onDelete={() => undefined}
onSuspend={() => undefined}
onActivate={() => undefined}
/>
</div>
);
};
import { Tabs } from 'antd';
import { OrganizationUsersTab } from '@/pages/organizations/components/OrganizationUsersTab';
return (
<div>
<PageHeader
title={organization.name}
subtitle={`Mã tổ chức: ${organization.code}`}
onBack={handleBack}
/>
<Tabs
items={[
{
key: 'information',
label: 'Thông tin tổ chức',
children: <Card>{/* existing descriptions content */}</Card>,
},
{
key: 'users',
label: 'Quản lý người dùng',
children: <OrganizationUsersTab organization={organization} />,
},
]}
/>
</div>
);
- Step 5: Run tests to verify they pass
Run: cd frontend && bunx vitest run src/pages/organizations/components/OrganizationUsersTab.spec.tsx
Expected: PASS with the organization-scoped rendering test green.
- Step 6: Run project check
Run: bun run check
Expected: PASS with Checked ... files and no remaining diagnostics.
- Step 7: Commit
git add frontend/package.json \
frontend/src/types/role.types.ts \
frontend/src/services/role.service.ts \
frontend/src/hooks/useRoles.ts \
frontend/src/test/setup.ts \
frontend/src/test/renderWithProviders.tsx \
frontend/src/types/user.types.ts \
frontend/src/services/user.service.ts \
frontend/src/hooks/useUsers.ts \
frontend/src/schemas/user.schema.ts \
frontend/src/components/users/UserForm.tsx \
frontend/src/components/users/UserSlideOver.tsx \
frontend/src/components/users/UserTable.tsx \
frontend/src/pages/organizations/components/OrganizationUsersTab.tsx \
frontend/src/pages/organizations/components/OrganizationUsersTab.spec.tsx \
frontend/src/pages/organizations/OrganizationDetail.tsx \
frontend/vitest.config.ts
git commit -m "feat(frontend): add organization-scoped user management tab"
Task 6: Final Verification Across Backend, Frontend, and Regression Paths
Files:
No new files
Re-run verification against files changed in Tasks 1-5
Step 1: Run backend unit and e2e checks
Run: cd backend && bun test src/users/user-authorization.service.spec.ts src/users/user.service.spec.ts test/rbac/role.controller.e2e-spec.ts
Expected: PASS with all backend user-management and roles-route tests green.
- Step 2: Run frontend unit and render tests
Run: cd frontend && bun test src/pages/organizations/organizationUserCapabilities.spec.ts && bunx vitest run src/pages/organizations/components/OrganizationUsersTab.spec.tsx
Expected: PASS for both the pure capability helper and the organization tab render test.
- Step 3: Run repository check
Run: bun run check
Expected: PASS with Biome clean across the repo.
- Step 4: Manual regression smoke test
Run:
bun run backend:start:dev
bun run frontend:start:dev
Manual checklist:
- Log in as
Super Adminand open/admin/organizations/:id - Confirm both
Thông tin tổ chứcandQuản lý người dùngtabs render - Create a user in organization A and verify it appears only in organization A
- Log in as
Adminfrom organization A and verify the tab is hidden or blocked on organization B - Log in as
Ownerfrom organization A and verify create/delete are unavailable, but edit and suspend work forStaff - Confirm
/usersglobal page still works after the shared component refactor
Expected: no console errors, no authorization leaks, and no regressions in the global user page.
Self-Review
- Spec coverage:
- Tab structure and route shell: covered by Task 5.
Super Admin,Admin,Owner,Staff,Technicalmatrix: covered by Tasks 1, 2, 4, and 6.- Create/edit/delete/suspend/activate behavior: covered by Tasks 2 and 5.
- Optional password change in edit flow: covered by Task 5.
- Role filtering and real
roleIds: covered by Tasks 3 and 5. - Global users page regression: covered by Task 6.
- Placeholder scan:
- No
TODO,TBD, or “implement later” markers remain. - Every command is concrete.
- Every code-edit step includes explicit code blocks.
- No
- Type consistency:
- Backend create flow uses
organizationIdfromCreateUserDto. - Frontend edit password flow uses
ChangeUserPasswordDataanduseChangeUserPassword. - Shared role option shape is consistently
RoleOption { id, name }.
- Backend create flow uses