diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml index b892c48f0..b7604405c 100644 --- a/.github/workflows/jest.yml +++ b/.github/workflows/jest.yml @@ -27,6 +27,8 @@ jobs: echo "AWS_SECRET_ACCESS_KEY=dummy" >> .env echo "AWS_ACCESS_KEY_ID=dummy" >> .env echo "AWS_REGION=us-east-2" >> .env + echo "COGNITO_APP_CLIENT_ID=dummy" >> .env + echo "COGNITO_USER_POOL_ID=dummy" >> .env - name: Install Dependencies run: yarn install diff --git a/apps/backend/src/admin-provisioning/admin-lifecycle.controller.spec.ts b/apps/backend/src/admin-provisioning/admin-lifecycle.controller.spec.ts new file mode 100644 index 000000000..605a6f87c --- /dev/null +++ b/apps/backend/src/admin-provisioning/admin-lifecycle.controller.spec.ts @@ -0,0 +1,90 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminLifecycleController } from './admin-lifecycle.controller'; +import { AdminLifecycleService } from './admin-lifecycle.service'; +import { RolesGuard } from '../auth/roles.guard'; +import { UsersService } from '../users/users.service'; + +jest.mock('../util/aws-exports', () => ({ + __esModule: true, + default: { + CognitoAuthConfig: { + userPoolId: 'test-user-pool-id', + clientId: 'test-client-id', + }, + AWSConfig: { region: 'us-east-2' }, + PublicFrontendUrl: 'https://app.test', + }, +})); + +describe('AdminLifecycleController', () => { + let controller: AdminLifecycleController; + + const mockAdminLifecycleService = { + listAdmins: jest.fn(), + deactivateAdmin: jest.fn(), + reactivateAdmin: jest.fn(), + }; + + const mockRolesGuard = { canActivate: jest.fn(() => true) }; + const mockUsersService = { findOne: jest.fn() }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminLifecycleController], + providers: [ + { provide: AdminLifecycleService, useValue: mockAdminLifecycleService }, + { provide: UsersService, useValue: mockUsersService }, + { provide: RolesGuard, useValue: mockRolesGuard }, + ], + }).compile(); + + controller = module.get(AdminLifecycleController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('lists admins via the service', async () => { + const admins = [ + { + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + isActive: true, + }, + ]; + mockAdminLifecycleService.listAdmins.mockResolvedValue(admins); + + await expect(controller.listAdmins()).resolves.toEqual(admins); + expect(mockAdminLifecycleService.listAdmins).toHaveBeenCalledTimes(1); + }); + + it('delegates deactivation to the service', async () => { + const result = { email: 'ada@example.com', isActive: false }; + mockAdminLifecycleService.deactivateAdmin.mockResolvedValue(result); + + await expect( + controller.deactivateAdmin('ada@example.com'), + ).resolves.toEqual(result); + expect(mockAdminLifecycleService.deactivateAdmin).toHaveBeenCalledWith( + 'ada@example.com', + ); + }); + + it('delegates reactivation to the service', async () => { + const result = { email: 'ada@example.com', isActive: true }; + mockAdminLifecycleService.reactivateAdmin.mockResolvedValue(result); + + await expect( + controller.reactivateAdmin('ada@example.com'), + ).resolves.toEqual(result); + expect(mockAdminLifecycleService.reactivateAdmin).toHaveBeenCalledWith( + 'ada@example.com', + ); + }); +}); diff --git a/apps/backend/src/admin-provisioning/admin-lifecycle.controller.ts b/apps/backend/src/admin-provisioning/admin-lifecycle.controller.ts new file mode 100644 index 000000000..add2339eb --- /dev/null +++ b/apps/backend/src/admin-provisioning/admin-lifecycle.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { UserType } from '../users/types'; +import { AdminLifecycleService } from './admin-lifecycle.service'; +import { + AdminAccountSummary, + AdminLifecycleResult, +} from './admin-lifecycle.types'; + +/** + * Admin-only endpoints for managing the admin account lifecycle: listing + * admins and deactivating / directly reactivating accounts (the requester's own + * account or another admin's). + */ +@ApiTags('Admin Lifecycle') +@ApiBearerAuth() +@Controller('admins') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class AdminLifecycleController { + constructor(private readonly adminLifecycleService: AdminLifecycleService) {} + + /** + * Lists all admin accounts with their active status. + */ + @Get() + @Roles(UserType.ADMIN) + async listAdmins(): Promise { + return this.adminLifecycleService.listAdmins(); + } + + /** + * Deactivates an admin account so it can no longer authenticate. + * @param email the admin to deactivate. + */ + @Patch(':email/deactivate') + @Roles(UserType.ADMIN) + async deactivateAdmin( + @Param('email') email: string, + ): Promise { + return this.adminLifecycleService.deactivateAdmin(email); + } + + /** + * Reactivates an admin account directly (admin-initiated). + * @param email the admin to reactivate. + */ + @Patch(':email/reactivate') + @Roles(UserType.ADMIN) + async reactivateAdmin( + @Param('email') email: string, + ): Promise { + return this.adminLifecycleService.reactivateAdmin(email); + } +} diff --git a/apps/backend/src/admin-provisioning/admin-lifecycle.service.spec.ts b/apps/backend/src/admin-provisioning/admin-lifecycle.service.spec.ts new file mode 100644 index 000000000..53aea69d5 --- /dev/null +++ b/apps/backend/src/admin-provisioning/admin-lifecycle.service.spec.ts @@ -0,0 +1,205 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { + AdminDisableUserCommand, + AdminEnableUserCommand, + AdminUserGlobalSignOutCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { AdminLifecycleService } from './admin-lifecycle.service'; +import { User } from '../users/user.entity'; +import { COGNITO_IDENTITY_PROVIDER } from './cognito.provider'; +import { UserType } from '../users/types'; + +jest.mock('../util/aws-exports', () => ({ + __esModule: true, + default: { + CognitoAuthConfig: { + userPoolId: 'test-user-pool-id', + clientId: 'test-client-id', + }, + AWSConfig: { + region: 'us-east-2', + }, + }, +})); + +const makeAdmin = (overrides: Partial = {}): User => ({ + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + userType: UserType.ADMIN, + isActive: true, + ...overrides, +}); + +describe('AdminLifecycleService', () => { + let service: AdminLifecycleService; + + const mockCognitoIdentityProvider = { + send: jest.fn(), + }; + + const mockUserRepository = { + find: jest.fn(), + findOneBy: jest.fn(), + count: jest.fn(), + save: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminLifecycleService, + { + provide: COGNITO_IDENTITY_PROVIDER, + useValue: mockCognitoIdentityProvider, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + ], + }).compile(); + + service = module.get(AdminLifecycleService); + mockCognitoIdentityProvider.send.mockResolvedValue({}); + mockUserRepository.save.mockImplementation(async (user) => user); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('listAdmins', () => { + it('returns admins with their active status', async () => { + mockUserRepository.find.mockResolvedValue([ + makeAdmin(), + makeAdmin({ email: 'bob@example.com', isActive: false }), + ]); + + const result = await service.listAdmins(); + + expect(mockUserRepository.find).toHaveBeenCalledWith({ + where: { userType: UserType.ADMIN }, + }); + expect(result).toEqual([ + { + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + isActive: true, + }, + { + email: 'bob@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + isActive: false, + }, + ]); + }); + }); + + describe('deactivateAdmin', () => { + it('global-signs-out, disables Cognito, and flips isActive', async () => { + mockUserRepository.findOneBy.mockResolvedValue(makeAdmin()); + mockUserRepository.count.mockResolvedValue(2); + + const result = await service.deactivateAdmin('Ada@Example.com'); + + const firstCommand = mockCognitoIdentityProvider.send.mock.calls[0][0]; + const secondCommand = mockCognitoIdentityProvider.send.mock.calls[1][0]; + expect(firstCommand).toBeInstanceOf(AdminUserGlobalSignOutCommand); + expect(firstCommand.input).toEqual({ + UserPoolId: 'test-user-pool-id', + Username: 'ada@example.com', + }); + expect(secondCommand).toBeInstanceOf(AdminDisableUserCommand); + expect(secondCommand.input).toEqual({ + UserPoolId: 'test-user-pool-id', + Username: 'ada@example.com', + }); + expect(mockUserRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ email: 'ada@example.com', isActive: false }), + ); + expect(result).toEqual({ email: 'ada@example.com', isActive: false }); + }); + + it('refuses to deactivate the last active admin', async () => { + mockUserRepository.findOneBy.mockResolvedValue(makeAdmin()); + mockUserRepository.count.mockResolvedValue(1); + + await expect(service.deactivateAdmin('ada@example.com')).rejects.toThrow( + ConflictException, + ); + expect(mockCognitoIdentityProvider.send).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + }); + + it('skips the last-admin guard when the target is already inactive', async () => { + mockUserRepository.findOneBy.mockResolvedValue( + makeAdmin({ isActive: false }), + ); + + await service.deactivateAdmin('ada@example.com'); + + expect(mockUserRepository.count).not.toHaveBeenCalled(); + expect(mockCognitoIdentityProvider.send).toHaveBeenCalledTimes(2); + }); + + it('throws NotFound when the user does not exist', async () => { + mockUserRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.deactivateAdmin('missing@example.com'), + ).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequest when the user is not an admin', async () => { + mockUserRepository.findOneBy.mockResolvedValue( + makeAdmin({ userType: UserType.STANDARD }), + ); + + await expect(service.deactivateAdmin('ada@example.com')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('reactivateAdmin', () => { + it('enables the Cognito user and flips isActive', async () => { + mockUserRepository.findOneBy.mockResolvedValue( + makeAdmin({ isActive: false }), + ); + + const result = await service.reactivateAdmin('ada@example.com'); + + const command = mockCognitoIdentityProvider.send.mock.calls[0][0]; + expect(command).toBeInstanceOf(AdminEnableUserCommand); + expect(command.input).toEqual({ + UserPoolId: 'test-user-pool-id', + Username: 'ada@example.com', + }); + expect(mockUserRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ email: 'ada@example.com', isActive: true }), + ); + expect(result).toEqual({ email: 'ada@example.com', isActive: true }); + }); + + it('throws NotFound when the user does not exist', async () => { + mockUserRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.reactivateAdmin('missing@example.com'), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/backend/src/admin-provisioning/admin-lifecycle.service.ts b/apps/backend/src/admin-provisioning/admin-lifecycle.service.ts new file mode 100644 index 000000000..0076d7769 --- /dev/null +++ b/apps/backend/src/admin-provisioning/admin-lifecycle.service.ts @@ -0,0 +1,175 @@ +import { + AdminDisableUserCommand, + AdminEnableUserCommand, + AdminUserGlobalSignOutCommand, + CognitoIdentityProviderClient, +} from '@aws-sdk/client-cognito-identity-provider'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../users/user.entity'; +import { UserType } from '../users/types'; +import { COGNITO_IDENTITY_PROVIDER } from './cognito.provider'; +import envConfig from '../util/aws-exports'; +import { + AdminAccountSummary, + AdminLifecycleResult, +} from './admin-lifecycle.types'; + +/** + * Handles the admin account lifecycle: deactivation and reactivation. + * + * Deactivation flips the app-layer `isActive` flag (enforced on every protected + * route by the RolesGuard) and disables the Cognito user so no new tokens can + * be issued. Reactivation re-enables the Cognito user; the admin then logs in + * normally with their existing credentials. Existing data (User, AdminInfo, + * applications) is never deleted. + */ +@Injectable() +export class AdminLifecycleService { + private readonly logger = new Logger(AdminLifecycleService.name); + + constructor( + @Inject(COGNITO_IDENTITY_PROVIDER) + private readonly cognitoIdentityProvider: CognitoIdentityProviderClient, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + /** + * Reads and validates the configured Cognito user pool id. + * @throws {Error} if the user pool id is not configured. + */ + private getCognitoUserPoolId(): string { + const userPoolId = envConfig.CognitoAuthConfig.userPoolId; + if (!userPoolId) { + throw new Error( + 'Missing COGNITO_USER_POOL_ID or VITE_COGNITO_USER_POOL_ID.', + ); + } + return userPoolId; + } + + private normalizeEmail(email: string): string { + return email.trim().toLowerCase(); + } + + /** + * Loads an existing admin user or throws if missing / not an admin. + */ + private async loadAdminUser(normalizedEmail: string): Promise { + const user = await this.userRepository.findOneBy({ + email: normalizedEmail, + }); + if (!user) { + throw new NotFoundException(`No user found for ${normalizedEmail}.`); + } + if (user.userType !== UserType.ADMIN) { + throw new BadRequestException( + 'Only admin accounts can be deactivated or reactivated.', + ); + } + return user; + } + + /** + * Returns every admin account with its current active status, for the admin + * management screen. + */ + async listAdmins(): Promise { + const admins = await this.userRepository.find({ + where: { userType: UserType.ADMIN }, + }); + return admins.map((admin) => ({ + email: admin.email, + firstName: admin.firstName, + lastName: admin.lastName, + isActive: admin.isActive, + })); + } + + /** + * Deactivates an admin account. + * + * Refuses to deactivate the final remaining active admin so the system is + * never left without administrative access. Existing sessions are revoked + * immediately via global sign-out, and the Cognito user is disabled so it can + * no longer authenticate. + * + * @param email the admin to deactivate (their own or another admin's). + * @throws {NotFoundException} if no user exists for the email. + * @throws {BadRequestException} if the user is not an admin. + * @throws {ConflictException} if this is the last active admin. + */ + async deactivateAdmin(email: string): Promise { + const normalizedEmail = this.normalizeEmail(email); + const user = await this.loadAdminUser(normalizedEmail); + + if (user.isActive) { + const activeAdminCount = await this.userRepository.count({ + where: { userType: UserType.ADMIN, isActive: true }, + }); + if (activeAdminCount <= 1) { + throw new ConflictException( + 'Cannot deactivate the last active admin account.', + ); + } + } + + const userPoolId = this.getCognitoUserPoolId(); + this.logger.log(`Deactivating admin ${normalizedEmail}`); + + await this.cognitoIdentityProvider.send( + new AdminUserGlobalSignOutCommand({ + UserPoolId: userPoolId, + Username: normalizedEmail, + }), + ); + await this.cognitoIdentityProvider.send( + new AdminDisableUserCommand({ + UserPoolId: userPoolId, + Username: normalizedEmail, + }), + ); + + user.isActive = false; + await this.userRepository.save(user); + + return { email: normalizedEmail, isActive: false }; + } + + /** + * Reactivates an admin account by enabling the Cognito user and clearing the + * `isActive` flag. The admin then logs in normally with their existing + * credentials. + * + * @throws {NotFoundException} if no user exists for the email. + * @throws {BadRequestException} if the user is not an admin. + */ + async reactivateAdmin(email: string): Promise { + const normalizedEmail = this.normalizeEmail(email); + const user = await this.loadAdminUser(normalizedEmail); + + const userPoolId = this.getCognitoUserPoolId(); + this.logger.log(`Reactivating admin ${normalizedEmail}`); + + await this.cognitoIdentityProvider.send( + new AdminEnableUserCommand({ + UserPoolId: userPoolId, + Username: normalizedEmail, + }), + ); + + user.isActive = true; + await this.userRepository.save(user); + + return { email: normalizedEmail, isActive: true }; + } +} diff --git a/apps/backend/src/admin-provisioning/admin-lifecycle.types.ts b/apps/backend/src/admin-provisioning/admin-lifecycle.types.ts new file mode 100644 index 000000000..20f7b0849 --- /dev/null +++ b/apps/backend/src/admin-provisioning/admin-lifecycle.types.ts @@ -0,0 +1,17 @@ +/** + * Summary of an admin account for the management list. + */ +export type AdminAccountSummary = { + email: string; + firstName: string; + lastName: string; + isActive: boolean; +}; + +/** + * Result of a deactivate/reactivate operation. + */ +export type AdminLifecycleResult = { + email: string; + isActive: boolean; +}; diff --git a/apps/backend/src/admin-provisioning/admin-provisioning.module.ts b/apps/backend/src/admin-provisioning/admin-provisioning.module.ts index 7cfc5affa..b5f2a8fd5 100644 --- a/apps/backend/src/admin-provisioning/admin-provisioning.module.ts +++ b/apps/backend/src/admin-provisioning/admin-provisioning.module.ts @@ -6,6 +6,8 @@ import { UsersModule } from '../users/users.module'; import { User } from '../users/user.entity'; import { AdminProvisioningController } from './admin-provisioning.controller'; import { AdminProvisioningService } from './admin-provisioning.service'; +import { AdminLifecycleController } from './admin-lifecycle.controller'; +import { AdminLifecycleService } from './admin-lifecycle.service'; import { cognitoIdentityProviderFactory } from './cognito.provider'; import { DisciplinesModule } from '../disciplines/disciplines.module'; import { UtilModule } from '../util/util.module'; @@ -18,8 +20,12 @@ import { UtilModule } from '../util/util.module'; DisciplinesModule, UtilModule, ], - controllers: [AdminProvisioningController], - providers: [AdminProvisioningService, cognitoIdentityProviderFactory], - exports: [AdminProvisioningService], + controllers: [AdminProvisioningController, AdminLifecycleController], + providers: [ + AdminProvisioningService, + AdminLifecycleService, + cognitoIdentityProviderFactory, + ], + exports: [AdminProvisioningService, AdminLifecycleService], }) export class AdminProvisioningModule {} diff --git a/apps/backend/src/auth/roles.guard.spec.ts b/apps/backend/src/auth/roles.guard.spec.ts new file mode 100644 index 000000000..b2f637a1a --- /dev/null +++ b/apps/backend/src/auth/roles.guard.spec.ts @@ -0,0 +1,62 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard } from './roles.guard'; +import { UsersService } from '../users/users.service'; +import { UserType } from '../users/types'; +import { User } from '../users/user.entity'; + +const makeContext = (user: { email?: string }): ExecutionContext => { + const request: { user?: unknown } = { user }; + return { + switchToHttp: () => ({ getRequest: () => request }), + getHandler: () => undefined, + getClass: () => undefined, + } as unknown as ExecutionContext; +}; + +const makeUser = (overrides: Partial = {}): User => ({ + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + userType: UserType.ADMIN, + isActive: true, + ...overrides, +}); + +describe('RolesGuard', () => { + let guard: RolesGuard; + const reflector = { getAllAndOverride: jest.fn() } as unknown as Reflector; + const usersService = { findOne: jest.fn() }; + + beforeEach(() => { + guard = new RolesGuard(reflector, usersService as unknown as UsersService); + (reflector.getAllAndOverride as jest.Mock).mockReturnValue([ + UserType.ADMIN, + ]); + }); + + afterEach(() => jest.clearAllMocks()); + + it('allows an active user with the required role', async () => { + usersService.findOne.mockResolvedValue(makeUser()); + + await expect( + guard.canActivate(makeContext({ email: 'ada@example.com' })), + ).resolves.toBe(true); + }); + + it('forbids a deactivated user even with the required role', async () => { + usersService.findOne.mockResolvedValue(makeUser({ isActive: false })); + + await expect( + guard.canActivate(makeContext({ email: 'ada@example.com' })), + ).rejects.toThrow(ForbiddenException); + }); + + it('skips checks when no roles are required', async () => { + (reflector.getAllAndOverride as jest.Mock).mockReturnValue(undefined); + + await expect(guard.canActivate(makeContext({}))).resolves.toBe(true); + expect(usersService.findOne).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/auth/roles.guard.ts b/apps/backend/src/auth/roles.guard.ts index 779d4b8f8..055da13eb 100644 --- a/apps/backend/src/auth/roles.guard.ts +++ b/apps/backend/src/auth/roles.guard.ts @@ -63,6 +63,10 @@ export class RolesGuard implements CanActivate { throw new ForbiddenException('Authenticated user was not found.'); } + if (!databaseUser.isActive) { + throw new ForbiddenException('This account has been deactivated.'); + } + request.user = databaseUser; if (!requiredRoles.includes(databaseUser.userType)) { diff --git a/apps/backend/src/migrations/1779800000000-AddAdminLifecycle.ts b/apps/backend/src/migrations/1779800000000-AddAdminLifecycle.ts new file mode 100644 index 000000000..b31e22c09 --- /dev/null +++ b/apps/backend/src/migrations/1779800000000-AddAdminLifecycle.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds account-lifecycle support for admins: an `isActive` flag on the users + * table (the app-layer source of truth, enforced by the RolesGuard and mirrored + * by enabling/disabling the Cognito user). + */ +export class AddAdminLifecycle1779800000000 implements MigrationInterface { + name = 'AddAdminLifecycle1779800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "isActive" boolean NOT NULL DEFAULT true`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN IF EXISTS "isActive"`, + ); + } +} diff --git a/apps/backend/src/users/user.entity.ts b/apps/backend/src/users/user.entity.ts index 5b2bf3b1b..635185665 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -39,4 +39,13 @@ export class User { */ @Column({ type: 'enum', enum: UserType, default: UserType.STANDARD }) userType: UserType; + + /** + * Whether the account is active. Deactivated accounts are blocked from + * accessing protected routes and have their Cognito user disabled. + * + * Example: true. + */ + @Column({ type: 'boolean', default: true }) + isActive?: boolean; } diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index b8456eccc..9cbf7332f 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -18,6 +18,7 @@ const mockUser: User = { firstName: 'Test', lastName: 'User', userType: UserType.STANDARD, + isActive: true, }; describe('UsersController', () => { diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index e7cb846b6..69615a70f 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -19,6 +19,7 @@ const mockUser: User = { firstName: 'Test', lastName: 'User', userType: UserType.STANDARD, + isActive: true, }; describe('UsersService', () => { diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 696866d8f..aa88b0500 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -17,6 +17,8 @@ import { DisciplineAdminMap, DisciplineCatalogItem, User, + AdminAccountSummary, + AdminLifecycleResult, } from './types'; const defaultBaseUrl = @@ -175,6 +177,24 @@ export class ApiClient { ) as Promise; } + public async listAdmins(): Promise { + return this.get('/api/admins') as Promise; + } + + public async deactivateAdmin(email: string): Promise { + return this.patch( + `/api/admins/${encodeURIComponent(email)}/deactivate`, + {}, + ) as Promise; + } + + public async reactivateAdmin(email: string): Promise { + return this.patch( + `/api/admins/${encodeURIComponent(email)}/reactivate`, + {}, + ) as Promise; + } + public async updateAvailability( appId: number, availability: Partial, diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index c08a8f11c..05e3447c4 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -177,6 +177,25 @@ export interface User { firstName: string; lastName: string; userType: UserType; + isActive?: boolean; +} + +/** + * Summary of an admin account shown on the admin management screen. + */ +export interface AdminAccountSummary { + email: string; + firstName: string; + lastName: string; + isActive: boolean; +} + +/** + * Result of a deactivate / reactivate operation. + */ +export interface AdminLifecycleResult { + email: string; + isActive: boolean; } export interface CandidateInfo { diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index c3dcb32b3..117323bd5 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -13,6 +13,7 @@ import PasswordReset from './containers/PasswordReset'; import FormsPage from '@containers/FormsPage'; import CreateNewAdmin from '@containers/CreateNewAdmin'; import AdminSettings from '@containers/AdminSettings'; +import ManageAdmins from '@containers/ManageAdmins'; import AdminExportData from '@containers/AdminExportData'; export const App: React.FC = () => { @@ -43,6 +44,7 @@ export const App: React.FC = () => { } /> } /> } /> + } /> diff --git a/apps/frontend/src/components/NavBar/NavBar.tsx b/apps/frontend/src/components/NavBar/NavBar.tsx index 3e96df30e..0f7c20849 100644 --- a/apps/frontend/src/components/NavBar/NavBar.tsx +++ b/apps/frontend/src/components/NavBar/NavBar.tsx @@ -7,6 +7,7 @@ import { FaRegFile, FaRightFromBracket, FaUserPlus, + FaUsersGear, FaGear, } from 'react-icons/fa6'; import { UserType } from '@api/types'; @@ -62,6 +63,13 @@ export default function NavBar({ logo, userType }: NavBarProps) { icon={} /> )} + {userType === UserType.ADMIN && ( + } + /> + )} {userType === UserType.ADMIN && ( { @@ -56,6 +57,9 @@ const AdminSettings: React.FC = () => { const [saveError, setSaveError] = useState(null); const [saveSuccess, setSaveSuccess] = useState(false); const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); + const [isDeactivatePopoverOpen, setIsDeactivatePopoverOpen] = useState(false); + const [isDeactivating, setIsDeactivating] = useState(false); + const [deactivateError, setDeactivateError] = useState(null); const activeDisciplineKeys = useMemo(() => { return new Set( @@ -253,6 +257,33 @@ const AdminSettings: React.FC = () => { setIsConfirmPopoverOpen(false); }; + const onConfirmDeactivate = async () => { + if (!currentUser?.email || isDeactivating) { + return; + } + + setIsDeactivating(true); + setDeactivateError(null); + + try { + await apiClient.deactivateAdmin(currentUser.email); + // The account is now disabled in Cognito; sign out and return to login. + await signOutUser().catch(() => undefined); + window.location.replace('/login'); + } catch (error) { + const status = + typeof error === 'object' && error !== null && 'response' in error + ? (error as { response?: { status?: number } }).response?.status + : undefined; + setDeactivateError( + status === 409 + ? 'You are the last active admin and cannot deactivate your account.' + : 'Failed to deactivate your account. Please try again.', + ); + setIsDeactivating(false); + } + }; + const canSave = firstName.trim().length > 0 && lastName.trim().length > 0 && @@ -507,6 +538,66 @@ const AdminSettings: React.FC = () => { )} + + {!isLoading && !loadError && ( + + + Deactivate Account + + + Deactivating your account signs you out and blocks you from + logging in. You can regain access later through an email + reactivation link. + + + + { + setIsDeactivatePopoverOpen(details.open); + setDeactivateError(null); + }} + positioning={{ placement: 'top' }} + > + + + + + { + setIsDeactivatePopoverOpen(false); + setDeactivateError(null); + }} + confirmLoading={isDeactivating} + cancelDisabled={isDeactivating} + errorMessage={deactivateError} + /> + + + + )} ); diff --git a/apps/frontend/src/containers/ManageAdmins.tsx b/apps/frontend/src/containers/ManageAdmins.tsx new file mode 100644 index 000000000..855c7f503 --- /dev/null +++ b/apps/frontend/src/containers/ManageAdmins.tsx @@ -0,0 +1,285 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + Flex, + Heading, + Popover, + Spinner, + Text, +} from '@chakra-ui/react'; + +import NavBar from '@components/NavBar/NavBar'; +import ConfirmationPopoverContent from '@components/ConfirmationPopoverContent'; +import StatusPill, { StatusVariant } from '@components/StatusPill'; +import apiClient from '@api/apiClient'; +import { UserType, type AdminAccountSummary } from '@api/types'; + +type ToastState = { title: string; description: string } | null; + +const ManageAdmins: React.FC = () => { + const [admins, setAdmins] = useState([]); + const [currentEmail, setCurrentEmail] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + // Email of the row whose confirmation popover is open. + const [openRowEmail, setOpenRowEmail] = useState(null); + const [actionEmail, setActionEmail] = useState(null); + const [actionError, setActionError] = useState(null); + const [toast, setToast] = useState(null); + + const loadAdmins = useCallback(async () => { + setIsLoading(true); + setLoadError(null); + try { + const [list, currentUser] = await Promise.all([ + apiClient.listAdmins(), + apiClient.getCurrentUser(), + ]); + const sorted = [...list].sort((a, b) => a.email.localeCompare(b.email)); + setAdmins(sorted); + setCurrentEmail(currentUser?.email ?? null); + } catch (error) { + console.error('[ManageAdmins] failed to load admins', error); + setLoadError('Failed to load admins. Please try again.'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadAdmins(); + }, [loadAdmins]); + + useEffect(() => { + if (!toast) return; + const timer = setTimeout(() => setToast(null), 4500); + return () => clearTimeout(timer); + }, [toast]); + + const onConfirmAction = async (admin: AdminAccountSummary) => { + setActionEmail(admin.email); + setActionError(null); + try { + if (admin.isActive) { + await apiClient.deactivateAdmin(admin.email); + setToast({ + title: 'Admin deactivated', + description: `${admin.email} can no longer sign in.`, + }); + } else { + await apiClient.reactivateAdmin(admin.email); + setToast({ + title: 'Admin reactivated', + description: `${admin.email} can sign in again.`, + }); + } + setOpenRowEmail(null); + await loadAdmins(); + } catch (error) { + const status = + typeof error === 'object' && error !== null && 'response' in error + ? (error as { response?: { status?: number } }).response?.status + : undefined; + setActionError( + status === 409 + ? 'You cannot deactivate the last active admin.' + : 'The action could not be completed. Please try again.', + ); + } finally { + setActionEmail(null); + } + }; + + return ( + + + + + {toast && ( + + + ! + + + {toast.title} + {toast.description} + + + )} + + + Manage Admins + + + {isLoading && ( + + + Loading admins... + + )} + + {loadError && ( + + + + Unable to load admins + {loadError} + + + )} + + {!isLoading && !loadError && ( + + + NAME + EMAIL + STATUS + + ACTION + + + + {admins.map((admin) => { + const isSelf = admin.email === currentEmail; + return ( + + + {admin.firstName} {admin.lastName} + {isSelf && ( + + (you) + + )} + + + {admin.email} + + + + {admin.isActive ? 'Active' : 'Inactive'} + + + + { + setOpenRowEmail(details.open ? admin.email : null); + setActionError(null); + }} + positioning={{ placement: 'top' }} + > + + + + + onConfirmAction(admin)} + onCancel={() => { + setOpenRowEmail(null); + setActionError(null); + }} + confirmLoading={actionEmail === admin.email} + cancelDisabled={actionEmail === admin.email} + errorMessage={ + openRowEmail === admin.email ? actionError : null + } + /> + + + + ); + })} + + {admins.length === 0 && ( + + No admins found. + + )} + + )} + + + ); +}; + +export default ManageAdmins; diff --git a/apps/frontend/src/containers/login.tsx b/apps/frontend/src/containers/login.tsx index 617a7158d..8c92d3416 100644 --- a/apps/frontend/src/containers/login.tsx +++ b/apps/frontend/src/containers/login.tsx @@ -143,11 +143,16 @@ const Login: React.FC = () => { return; } } catch (err: unknown) { - const message = + const rawMessage = err instanceof Error ? err.message : 'Sign in failed. Verify your credentials and try again.'; - setError(message); + const isDisabled = /disabled/i.test(rawMessage); + setError( + isDisabled + ? 'This account has been deactivated. Contact an administrator to regain access.' + : rawMessage, + ); console.error('Cognito sign-in failed:', err); setLoading(false); return;