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-test Super Admin, Admin, Owner, Staff, and Technical behavior.
  • Create: backend/src/users/dto/query-users.dto.ts
    Responsibility: typed query DTO for page, limit, search, status, roleId, and orgId.
  • Modify: backend/src/users/dto/create-user.dto.ts
    Responsibility: accept organizationId so Super Admin can create users in the selected organization.
  • Modify: backend/src/users/dto/index.ts
    Responsibility: export QueryUsersDto.
  • Modify: backend/src/users/users.controller.ts
    Responsibility: accept QueryUsersDto in GET /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: register Role repository and UserAuthorizationService.
  • Modify: backend/src/roles/role.controller.ts
    Responsibility: normalize controller path to roles so the frontend can call /api/v1/roles.
  • Modify: backend/test/rbac/role.controller.e2e-spec.ts
    Responsibility: make the e2e route test mirror the real api/v1 global 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 with QueryClientProvider, 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: add changePassword API method.
  • Modify: frontend/src/hooks/useUsers.ts
    Responsibility: add useChangeUserPassword mutation 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 mount OrganizationUsersTab.

Task 1: Create Backend User Authorization Helper

Files:

  • Create: backend/src/users/user-authorization.service.ts

  • Create: backend/src/users/user-authorization.service.spec.ts

  • Modify: backend/src/users/users.module.ts

  • Step 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.ts

  • Modify: backend/src/users/dto/create-user.dto.ts

  • Modify: backend/src/users/dto/index.ts

  • Modify: backend/src/users/users.controller.ts

  • Modify: backend/src/users/user.service.ts

  • Modify: backend/src/users/user.service.spec.ts

  • Step 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.ts

  • Modify: backend/test/rbac/role.controller.e2e-spec.ts

  • Step 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.ts

  • Create: frontend/src/pages/organizations/organizationUserCapabilities.spec.ts

  • Step 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.ts

  • Create: frontend/src/services/role.service.ts

  • Create: frontend/src/hooks/useRoles.ts

  • Create: frontend/src/test/setup.ts

  • Create: frontend/src/test/renderWithProviders.tsx

  • Create: frontend/src/pages/organizations/components/OrganizationUsersTab.tsx

  • Create: frontend/src/pages/organizations/components/OrganizationUsersTab.spec.tsx

  • Create: frontend/vitest.config.ts

  • Modify: frontend/package.json

  • Modify: frontend/src/types/user.types.ts

  • Modify: frontend/src/services/user.service.ts

  • Modify: frontend/src/hooks/useUsers.ts

  • Modify: frontend/src/schemas/user.schema.ts

  • Modify: frontend/src/components/users/UserForm.tsx

  • Modify: frontend/src/components/users/UserSlideOver.tsx

  • Modify: frontend/src/components/users/UserTable.tsx

  • Modify: frontend/src/pages/organizations/OrganizationDetail.tsx

  • Step 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 Admin and open /admin/organizations/:id
  • Confirm both Thông tin tổ chức and Quản lý người dùng tabs render
  • Create a user in organization A and verify it appears only in organization A
  • Log in as Admin from organization A and verify the tab is hidden or blocked on organization B
  • Log in as Owner from organization A and verify create/delete are unavailable, but edit and suspend work for Staff
  • Confirm /users global 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, Technical matrix: 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.
  • Type consistency:
    • Backend create flow uses organizationId from CreateUserDto.
    • Frontend edit password flow uses ChangeUserPasswordData and useChangeUserPassword.
    • Shared role option shape is consistently RoleOption { id, name }.