Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ model User {
phoneNumber String?
email String?
disabled Boolean?
archivedAt DateTime? @db.Date

@@unique([username])
@@map("UserModel")
Expand Down
43 changes: 40 additions & 3 deletions apps/api/src/auth/__tests__/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CryptoService, LoggingService } from '@douglasneuroinformatics/libnest'
import type { RequestUser } from '@douglasneuroinformatics/libnest';
import { MockFactory } from '@douglasneuroinformatics/libnest/testing';
import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing';
import { ForbiddenException } from '@nestjs/common';
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { beforeEach, describe, expect, it } from 'vitest';

Expand All @@ -29,7 +29,9 @@ const BASE_PAYLOAD = {
describe('AuthService', () => {
let abilityFactory: AbilityFactory;
let authService: AuthService;
let cryptoService: MockedInstance<CryptoService>;
let jwtService: MockedInstance<JwtService>;
let usersService: MockedInstance<UsersService>;

const requestUserFor = (basePermissionLevel: 'ADMIN' | 'GROUP_MANAGER' | 'STANDARD'): RequestUser => {
const ability = abilityFactory.createForPayload({ ...BASE_PAYLOAD, basePermissionLevel } as any);
Expand All @@ -46,15 +48,50 @@ describe('AuthService', () => {
abilityFactory = new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService);
jwtService = MockFactory.createMock(JwtService);
jwtService.signAsync.mockResolvedValue('__TOKEN__');
cryptoService = MockFactory.createMock(CryptoService);
usersService = MockFactory.createMock(UsersService);
authService = new AuthService(
abilityFactory,
MockFactory.createMock(AuditLogger) as unknown as AuditLogger,
MockFactory.createMock(CryptoService) as unknown as CryptoService,
cryptoService as unknown as CryptoService,
jwtService as unknown as JwtService,
MockFactory.createMock(UsersService) as unknown as UsersService
usersService as unknown as UsersService
);
});

describe('login', () => {
const credentials = { password: 'guess', username: 'test-user' };

const storedUser = (status: { archivedAt?: Date; disabled?: boolean }) => ({
...BASE_PAYLOAD,
basePermissionLevel: 'STANDARD',
groups: [],
hashedPassword: '__HASH__',
...status
});

it.each([{ archivedAt: new Date() }, { disabled: true }])(
'should answer a wrong password for a %o account as invalid credentials, so a guess cannot reveal its status',
async (status) => {
usersService.findByUsername.mockResolvedValue(storedUser(status) as any);
cryptoService.comparePassword.mockResolvedValue(false);
await expect(authService.login(credentials)).rejects.toThrow(UnauthorizedException);
}
);

it('should refuse an archived account once the password is proven', async () => {
usersService.findByUsername.mockResolvedValue(storedUser({ archivedAt: new Date() }) as any);
cryptoService.comparePassword.mockResolvedValue(true);
await expect(authService.login(credentials)).rejects.toThrow('Account Archived');
});

it('should refuse a disabled account once the password is proven', async () => {
usersService.findByUsername.mockResolvedValue(storedUser({ disabled: true }) as any);
cryptoService.comparePassword.mockResolvedValue(true);
await expect(authService.login(credentials)).rejects.toThrow('Account Disabled');
});
});

describe('getCreateInstrumentToken', () => {
it('should mint a token that satisfies the instrument create route, so the playground can upload a bundle', async () => {
const ability = createAppAbility(await mintedPermissions(requestUserFor('ADMIN')));
Expand Down
13 changes: 9 additions & 4 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,20 @@ export class AuthService {
throw err;
}

if (user.disabled) {
throw new ForbiddenException('Account Disabled');
}

// Account status is checked only once the password is proven, so a wrong guess cannot reveal
// whether a username exists or has been archived.
const isCorrectPassword = await this.cryptoService.comparePassword(credentials.password, user.hashedPassword);
if (isCorrectPassword !== true) {
throw new UnauthorizedException('Invalid Credentials');
}

if (user.archivedAt) {
throw new ForbiddenException('Account Archived');
}
if (user.disabled) {
throw new ForbiddenException('Account Disabled');
}

const tokenPayload: Omit<TokenPayload, 'permissions'> = {
additionalPermissions: user.additionalPermissions,
basePermissionLevel: user.basePermissionLevel,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/users/__tests__/users.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe('UsersController', () => {
// evaluated against a whole ability. `manage User` covers every action a narrower declaration could
// name, so a holder refused here is refused by any declaration short of `manage all`.
describe('route access for writes to a user', () => {
const WRITE_HANDLERS = ['create', 'deleteById', 'updateById', 'updatePermissions'] as const;
const WRITE_HANDLERS = ['archiveById', 'create', 'unarchiveById', 'updateById', 'updatePermissions'] as const;

const abilityFor = (basePermissionLevel: BasePermissionLevel, additionalPermissions: Permissions = []) =>
new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload({
Expand Down
37 changes: 30 additions & 7 deletions apps/api/src/users/__tests__/users.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { pwnedPassword } from 'hibp';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';

import { AuditLogger } from '@/audit/audit.logger';
import { accessibleQuery, createAppAbility } from '@/auth/ability.utils';

import { GroupsService } from '../../groups/groups.service';
Expand Down Expand Up @@ -43,6 +44,7 @@ describe('UsersService', () => {
providers: [
UsersService,
MockFactory.createForModelToken(getModelToken('User')),
MockFactory.createForService(AuditLogger),
MockFactory.createForService(CryptoService),
MockFactory.createForService(GroupsService)
]
Expand Down Expand Up @@ -160,15 +162,36 @@ describe('UsersService', () => {
});
});

describe('deleteById', () => {
it('should refuse an administrator deleting their own account, so the last one cannot remove every admin', async () => {
await expect(usersService.deleteById(admin.id, admin)).rejects.toThrow(ForbiddenException);
expect(userModel.delete).not.toHaveBeenCalled();
describe('archiveById', () => {
beforeEach(() => {
userModel.update.mockResolvedValue({});
});

it('should refuse an administrator archiving their own account, so the last one cannot remove every admin', async () => {
await expect(usersService.archiveById(admin.id, admin)).rejects.toThrow(ForbiddenException);
expect(userModel.update).not.toHaveBeenCalled();
});

it('should set archivedAt instead of deleting the record', async () => {
await usersService.archiveById('user-1', admin);
expect(userModel.update.mock.lastCall?.[0].data.archivedAt).toBeInstanceOf(Date);
expect(userModel.update.mock.lastCall?.[0].where).toMatchObject({ id: 'user-1' });
});
});

describe('unarchiveById', () => {
beforeEach(() => {
userModel.update.mockResolvedValue({});
});

it('should refuse an administrator unarchiving their own account', async () => {
await expect(usersService.unarchiveById(admin.id, admin)).rejects.toThrow(ForbiddenException);
});

it('should let an administrator delete another user', async () => {
await usersService.deleteById('user-1', admin);
expect(userModel.delete.mock.lastCall?.[0].where).toMatchObject({ id: 'user-1' });
it('should clear archivedAt to restore the account', async () => {
await usersService.unarchiveById('user-1', admin);
expect(userModel.update.mock.lastCall?.[0].data).toMatchObject({ archivedAt: null });
expect(userModel.update.mock.lastCall?.[0].where).toMatchObject({ id: 'user-1' });
});
});

Expand Down
35 changes: 15 additions & 20 deletions apps/api/src/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,6 @@
import { ApiOperation, CurrentUser, ParseSchemaPipe } from '@douglasneuroinformatics/libnest';
import type { RequestUser } from '@douglasneuroinformatics/libnest';
import {
Body,
Controller,
Delete,
Get,
Headers,
NotFoundException,
Param,
Patch,
Post,
Put,
Query
} from '@nestjs/common';
import { Body, Controller, Get, Headers, NotFoundException, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { $Language } from '@opendatacapture/schemas/core';
import type { Language } from '@opendatacapture/schemas/core';
import {
Expand Down Expand Up @@ -44,6 +32,13 @@ export class UsersController {
private readonly mailService: MailService
) {}

@ApiOperation({ summary: 'Archive User' })
@Patch(':id/archive')
@RouteAccess(ADMIN_ONLY)
archiveById(@Param('id') id: string, @CurrentUser() currentUser: RequestUser) {
return this.usersService.archiveById(id, currentUser);
}

@ApiOperation({ summary: 'Get User by Username' })
@Get('/check-username/:username')
@RouteAccess({ action: 'read', subject: 'User' })
Expand Down Expand Up @@ -76,13 +71,6 @@ export class UsersController {
return { ...created, welcomeEmail };
}

@ApiOperation({ summary: 'Delete User' })
@Delete(':id')
@RouteAccess(ADMIN_ONLY)
deleteById(@Param('id') id: string, @CurrentUser() currentUser: RequestUser) {
return this.usersService.deleteById(id, currentUser);
}

@ApiOperation({ summary: 'Get All Users' })
@Get()
@RouteAccess({ action: 'read', subject: 'User' })
Expand All @@ -97,6 +85,13 @@ export class UsersController {
return this.usersService.findById(id, { ability });
}

@ApiOperation({ summary: 'Unarchive User' })
@Patch(':id/unarchive')
@RouteAccess(ADMIN_ONLY)
unarchiveById(@Param('id') id: string, @CurrentUser() currentUser: RequestUser) {
return this.usersService.unarchiveById(id, currentUser);
}

@ApiOperation({ summary: 'Update User' })
@Patch(':id')
@RouteAccess(ADMIN_ONLY)
Expand Down
52 changes: 40 additions & 12 deletions apps/api/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { $CreateUserData, $SelfUpdateUserData, $UpdateUserData } from '@opendata
import type { PasswordErrorCode } from '@opendatacapture/schemas/user';
import { pwnedPassword } from 'hibp';

import { AuditLogger } from '@/audit/audit.logger';
import { accessibleQuery } from '@/auth/ability.utils';
import type { EntityOperationOptions } from '@/core/types';
import { GroupsService } from '@/groups/groups.service';
Expand All @@ -24,10 +25,30 @@ export class UsersService {

constructor(
@InjectModel('User') private readonly userModel: Model<'User'>,
private readonly auditLogger: AuditLogger,
private readonly cryptoService: CryptoService,
private readonly groupsService: GroupsService
) {}

async archiveById(id: string, currentUser: RequestUser) {
if (id === currentUser.id) {
throw new ForbiddenException('You may not archive your own account');
}
const user = await this.userModel.update({
data: { archivedAt: new Date() },
omit: {
hashedPassword: true
},
where: { AND: [accessibleQuery(currentUser.ability, 'update', 'User')], id }
});
await this.auditLogger.log('UPDATE', 'USER', {
groupId: null,
metadata: { action: 'archive', targetUserId: id },
userId: currentUser.id
});
return user;
}

async checkUsernameExists(username: string, { ability }: EntityOperationOptions = {}): Promise<{ success: boolean }> {
const user = await this.userModel.findFirst({
include: { groups: true },
Expand Down Expand Up @@ -109,18 +130,6 @@ export class UsersService {
});
}

async deleteById(id: string, currentUser: RequestUser) {
if (id === currentUser.id) {
throw new ForbiddenException('You may not delete your own account');
}
return this.userModel.delete({
omit: {
hashedPassword: true
},
where: { AND: [accessibleQuery(currentUser.ability, 'delete', 'User')], id }
});
}

/** Delete the user with the provided username, otherwise throws */
async deleteByUsername(username: string, { ability }: EntityOperationOptions = {}) {
const user = await this.findByUsername(username);
Expand Down Expand Up @@ -173,6 +182,25 @@ export class UsersService {
return user;
}

async unarchiveById(id: string, currentUser: RequestUser) {
if (id === currentUser.id) {
throw new ForbiddenException('You may not unarchive your own account');
}
const user = await this.userModel.update({
data: { archivedAt: null },
omit: {
hashedPassword: true
},
where: { AND: [accessibleQuery(currentUser.ability, 'update', 'User')], id }
});
await this.auditLogger.log('UPDATE', 'USER', {
groupId: null,
metadata: { action: 'unarchive', targetUserId: id },
userId: currentUser.id
});
return user;
}

async updateById(id: string, { groupIds, password, ...data }: $UpdateUserData, currentUser: RequestUser) {
const { ability } = currentUser;
const isDemotion = data.basePermissionLevel !== undefined && data.basePermissionLevel !== 'ADMIN';
Expand Down
3 changes: 2 additions & 1 deletion apps/api/test/suites/02-user-permissions.suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ export default defineSuite('user permissions', function () {
{ method: 'PATCH', payload: { basePermissionLevel: 'ADMIN' }, url: `/v1/users/${grantee.id}` },
{ method: 'PATCH', payload: { groupIds: [group.id, otherGroup.id] }, url: `/v1/users/${grantee.id}` },
{ method: 'PATCH', payload: { password: PASSWORD }, url: `/v1/users/${teammateAdmin.id}` },
{ method: 'DELETE', url: `/v1/users/${teammateAdmin.id}` },
{ method: 'PATCH', url: `/v1/users/${teammateAdmin.id}/archive` },
{ method: 'PATCH', url: `/v1/users/${teammateAdmin.id}/unarchive` },
{
method: 'POST',
payload: {
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/__tests__/admin-users-table.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { PropsWithChildren } from 'react';

import type { User } from '@opendatacapture/schemas/user';
import { cleanup, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { Route } from '@/routes/_app/admin/users/index';

import '@/services/i18n';

const mocks = vi.hoisted(() => ({ users: [] as User[] }));

vi.mock('@tanstack/react-router', async (importOriginal) => ({
...(await importOriginal<typeof import('@tanstack/react-router')>()),
Link: ({ children }: PropsWithChildren) => <span>{children}</span>,
useNavigate: () => vi.fn()
}));
vi.mock('@/hooks/useUsersQuery', () => ({
usersQueryOptions: vi.fn(),
useUsersQuery: () => ({ data: mocks.users })
}));
vi.mock('@/hooks/useArchiveUserMutation', () => ({ useArchiveUserMutation: () => ({ mutate: vi.fn() }) }));
vi.mock('@/hooks/useUnarchiveUserMutation', () => ({ useUnarchiveUserMutation: () => ({ mutate: vi.fn() }) }));
vi.mock('@/store', () => ({
useAppStore: (selector: (store: { currentUser: null }) => unknown) => selector({ currentUser: null })
}));

afterEach(cleanup);

describe('admin users table', () => {
it.each([false, true, null, undefined])('should display disabled=%s independently of archive status', (disabled) => {
mocks.users = [
{
additionalPermissions: [],
archivedAt: new Date('2026-10-01'),
basePermissionLevel: 'STANDARD',
createdAt: new Date('2026-01-01'),
disabled,
firstName: 'Jane',
groupIds: [],
id: 'user-1',
lastName: 'Doe',
updatedAt: new Date('2026-10-01'),
username: 'jane'
}
];
const Component = Route.options.component!;
render(<Component />);
expect(screen.getByTestId('user-login-status').textContent).toBe(disabled ? 'Disabled' : 'Enabled');
expect(screen.getByTestId('user-status-archived')).toBeTruthy();
const headers = within(screen.getByTestId('data-table-head'))
.getAllByRole('button', { hidden: true })
.map((header) => header.textContent)
.filter(Boolean);
expect(headers.indexOf('Enabled / Disabled')).toBe(headers.indexOf('Status') - 1);
});
});
Loading
Loading