From e8a18444fde7b93139002e3f5d9ca1d1ab15f498 Mon Sep 17 00:00:00 2001 From: Patrick Schiller Date: Sat, 10 Oct 2026 09:47:02 +0200 Subject: [PATCH 1/2] fix(api): authorize employee data access Signed-off-by: Codex --- apps/api/openapi.json | 20 +++++++++ .../src/app/accounts/accounts.controller.ts | 35 +++++++++++++--- apps/api/src/app/accounts/accounts.service.ts | 42 +++++++++++++------ .../src/app/employees/employees.controller.ts | 32 +++++++++----- .../src/app/employees/employees.service.ts | 38 +++++++++++++++++ 5 files changed, 138 insertions(+), 29 deletions(-) diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c4d3e79..099a8df 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -126,6 +126,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["employees"] }, "post": { @@ -172,6 +177,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["employees"] }, "put": { @@ -1704,6 +1714,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["accounts"] } }, @@ -1733,6 +1748,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["accounts"] } }, diff --git a/apps/api/src/app/accounts/accounts.controller.ts b/apps/api/src/app/accounts/accounts.controller.ts index b60c9d3..1239d7c 100644 --- a/apps/api/src/app/accounts/accounts.controller.ts +++ b/apps/api/src/app/accounts/accounts.controller.ts @@ -1,27 +1,50 @@ -import { Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { + Controller, + Get, + Param, + ParseIntPipe, + ParseUUIDPipe, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { EmployeesService } from '../employees/employees.service'; import { AccountsService } from './accounts.service'; import { VacationBalanceService } from './vacation-balance.service'; import type { AccountDto, VacationBalanceDto } from './accounts.dto'; @ApiTags('accounts') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) @Controller('accounts') export class AccountsController { constructor( private readonly accounts: AccountsService, private readonly vacation: VacationBalanceService, + private readonly employees: EmployeesService, ) {} @Get(':employeeId') - account(@Param('employeeId', new ParseUUIDPipe()) employeeId: string): Promise { - return this.accounts.account(employeeId); + account( + @Param('employeeId', new ParseUUIDPipe()) employeeId: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.accounts.account(employeeId, user); } @Get(':employeeId/vacation') - vacationBalance( + async vacationBalance( @Param('employeeId', new ParseUUIDPipe()) employeeId: string, + @CurrentUser() user: JwtUser, @Query('year', new ParseIntPipe({ optional: true })) year?: number, ): Promise { - return this.vacation.compute(employeeId, year ?? new Date().getUTCFullYear()); + await this.employees.assertCanRead(employeeId, user); + return this.vacation.compute( + employeeId, + year ?? new Date().getUTCFullYear(), + ); } } diff --git a/apps/api/src/app/accounts/accounts.service.ts b/apps/api/src/app/accounts/accounts.service.ts index d999325..bccb273 100644 --- a/apps/api/src/app/accounts/accounts.service.ts +++ b/apps/api/src/app/accounts/accounts.service.ts @@ -9,6 +9,7 @@ import type { HolidayProvider } from 'shared'; import { EmployeesService } from '../employees/employees.service'; import { PrismaService } from '../prisma/prisma.service'; import { WorkSchedulesService } from '../work-schedules/work-schedules.service'; +import type { JwtUser } from '../auth/jwt.strategy'; import { VacationBalanceService } from './vacation-balance.service'; import type { AccountDto } from './accounts.dto'; @@ -21,7 +22,8 @@ export class AccountsService { private readonly schedules: WorkSchedulesService, ) {} - async account(employeeId: string): Promise { + async account(employeeId: string, actor: JwtUser): Promise { + await this.employees.assertCanRead(employeeId, actor); const employee = await this.employees.getById(employeeId); const schedule = await this.schedules.resolveForEmployee(employeeId); const now = new Date(); @@ -30,13 +32,19 @@ export class AccountsService { // Sum net minutes from completed time entries YTD. const entries = await this.prisma.timeEntry.findMany({ - where: { employeeId, clockIn: { gte: yearStart }, clockOut: { not: null } }, + where: { + employeeId, + clockIn: { gte: yearStart }, + clockOut: { not: null }, + }, select: { clockIn: true, clockOut: true }, }); let netMinutesYtd = 0; for (const e of entries) { if (!e.clockOut) continue; - const gross = Math.floor((e.clockOut.getTime() - e.clockIn.getTime()) / 60_000); + const gross = Math.floor( + (e.clockOut.getTime() - e.clockIn.getTime()) / 60_000, + ); netMinutesYtd += calculateNetMinutes(gross); } @@ -44,13 +52,16 @@ export class AccountsService { // excused working days — their absence on a workday is not a deficit. // Flextime/Gleittage are intentionally NOT excused: that's what makes // them drain the overtime account. - const sollFrom = employee.startDate.getTime() > yearStart.getTime() - ? new Date(Date.UTC( - employee.startDate.getUTCFullYear(), - employee.startDate.getUTCMonth(), - employee.startDate.getUTCDate(), - )) - : yearStart; + const sollFrom = + employee.startDate.getTime() > yearStart.getTime() + ? new Date( + Date.UTC( + employee.startDate.getUTCFullYear(), + employee.startDate.getUTCMonth(), + employee.startDate.getUTCDate(), + ), + ) + : yearStart; const excusedDays = await this.excusedWorkingDays( employeeId, sollFrom, @@ -123,15 +134,20 @@ export class AccountsService { for (const a of absences) { const start = a.from.getTime() < from.getTime() ? from : a.from; const end = a.to.getTime() > to.getTime() ? to : a.to; - total += calculateWorkingDays(start, end, { holidayProvider, workingDays }); + total += calculateWorkingDays(start, end, { + holidayProvider, + workingDays, + }); } for (const v of vacationRequests) { // Only credit half a day when the half-day boundary falls inside our // window — if we clipped that end off, it would otherwise be lost. const clippedStart = v.from.getTime() < from.getTime() ? from : v.from; const clippedEnd = v.to.getTime() > to.getTime() ? to : v.to; - const halfDayStart = v.halfDayStart && clippedStart.getTime() === v.from.getTime(); - const halfDayEnd = v.halfDayEnd && clippedEnd.getTime() === v.to.getTime(); + const halfDayStart = + v.halfDayStart && clippedStart.getTime() === v.from.getTime(); + const halfDayEnd = + v.halfDayEnd && clippedEnd.getTime() === v.to.getTime(); total += calculateVacationDays(clippedStart, clippedEnd, { holidayProvider, workingDays, diff --git a/apps/api/src/app/employees/employees.controller.ts b/apps/api/src/app/employees/employees.controller.ts index ba9b98a..60c8c3b 100644 --- a/apps/api/src/app/employees/employees.controller.ts +++ b/apps/api/src/app/employees/employees.controller.ts @@ -14,6 +14,8 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { JwtUser } from '../auth/jwt.strategy'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; import { EmployeesService } from './employees.service'; @@ -25,22 +27,32 @@ import { } from './employees.dto'; @ApiTags('employees') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) @Controller('employees') export class EmployeesController { constructor(private readonly employees: EmployeesService) {} @Get() - list(@Query('includeInactive') includeInactive?: string): Promise { - return this.employees.list({ includeInactive: includeInactive === 'true' }); + list( + @CurrentUser() user: JwtUser, + @Query('includeInactive') includeInactive?: string, + ): Promise { + return this.employees.listForActor(user, { + includeInactive: includeInactive === 'true', + }); } @Get(':id') - get(@Param('id', new ParseUUIDPipe()) id: string): Promise { + async get( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() user: JwtUser, + ): Promise { + await this.employees.assertCanRead(id, user); return this.employees.getDtoById(id); } @Post() - @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('HRAdmin') create(@Body() dto: CreateEmployeeDto): Promise { @@ -48,7 +60,6 @@ export class EmployeesController { } @Put(':id') - @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('HRAdmin') update( @@ -59,7 +70,6 @@ export class EmployeesController { } @Post(':id/password') - @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('HRAdmin') @HttpCode(HttpStatus.NO_CONTENT) @@ -71,18 +81,20 @@ export class EmployeesController { } @Delete(':id') - @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('HRAdmin') - deactivate(@Param('id', new ParseUUIDPipe()) id: string): Promise { + deactivate( + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise { return this.employees.deactivate(id); } @Post(':id/reactivate') - @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('HRAdmin') - reactivate(@Param('id', new ParseUUIDPipe()) id: string): Promise { + reactivate( + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise { return this.employees.reactivate(id); } } diff --git a/apps/api/src/app/employees/employees.service.ts b/apps/api/src/app/employees/employees.service.ts index e5206fc..6552e91 100644 --- a/apps/api/src/app/employees/employees.service.ts +++ b/apps/api/src/app/employees/employees.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import type { Employee, Prisma } from '@prisma/client'; import * as bcrypt from 'bcrypt'; +import type { JwtUser } from '../auth/jwt.strategy'; import { PrismaService } from '../prisma/prisma.service'; import { toEmployeeDto, @@ -30,6 +31,43 @@ export class EmployeesService { return employees.map(toEmployeeDto); } + async listForActor( + actor: JwtUser, + opts: { includeInactive?: boolean } = {}, + ): Promise { + const accessFilter: Prisma.EmployeeWhereInput = + actor.role === 'HRAdmin' + ? {} + : actor.role === 'Manager' + ? { OR: [{ id: actor.id }, { managerId: actor.id }] } + : { id: actor.id }; + const employees = await this.prisma.employee.findMany({ + where: { + AND: [ + accessFilter, + ...(opts.includeInactive ? [] : [{ isActive: true }]), + ], + }, + include: { workSchedule: true }, + orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], + }); + return employees.map(toEmployeeDto); + } + + async assertCanRead(id: string, actor: JwtUser): Promise { + if (actor.role === 'HRAdmin' || id === actor.id) return; + const visible = + actor.role === 'Manager' && + (await this.prisma.employee.count({ + where: { id, managerId: actor.id }, + })) > 0; + if (!visible) { + throw new ForbiddenException( + 'Employees may only access their own or their team records', + ); + } + } + async getById(id: string): Promise { const employee = await this.prisma.employee.findUnique({ where: { id } }); if (!employee) throw new NotFoundException(`Employee ${id} not found`); From b2cd3130c2f258874dbc7612052c0b1314c917a8 Mon Sep 17 00:00:00 2001 From: Patrick Schiller Date: Sat, 10 Oct 2026 10:02:00 +0200 Subject: [PATCH 2/2] fix(api): close related personnel read paths and preserve directory workflows Signed-off-by: Patrick Schiller --- .../src/api/core-time-violations.e2e.spec.ts | 6 + .../api/employee-account-access.e2e.spec.ts | 449 ++++++++++++++++++ apps/api/openapi.json | 53 +++ .../src/app/absences/absences.controller.ts | 7 +- apps/api/src/app/absences/absences.module.ts | 3 +- apps/api/src/app/absences/absences.service.ts | 12 +- .../src/app/employees/employees.controller.ts | 10 +- apps/api/src/app/employees/employees.dto.ts | 12 + .../src/app/employees/employees.service.ts | 25 +- .../leave-allowances.controller.ts | 19 +- .../leave-allowances.module.ts | 3 +- .../leave-allowances.service.ts | 43 +- .../app/violations/violations.controller.ts | 11 +- .../src/app/violations/violations.service.ts | 14 +- apps/web/src/api/client.ts | 8 + apps/web/src/api/generated.ts | 41 ++ .../web/src/routes/AdminProjectsPage.spec.tsx | 57 ++- apps/web/src/routes/AdminProjectsPage.tsx | 17 +- apps/web/src/routes/RequestsPage.spec.tsx | 75 +++ apps/web/src/routes/RequestsPage.tsx | 6 +- 20 files changed, 795 insertions(+), 76 deletions(-) create mode 100644 apps/api-e2e/src/api/employee-account-access.e2e.spec.ts create mode 100644 apps/web/src/routes/RequestsPage.spec.tsx diff --git a/apps/api-e2e/src/api/core-time-violations.e2e.spec.ts b/apps/api-e2e/src/api/core-time-violations.e2e.spec.ts index 67335e5..2e9ea04 100644 --- a/apps/api-e2e/src/api/core-time-violations.e2e.spec.ts +++ b/apps/api-e2e/src/api/core-time-violations.e2e.spec.ts @@ -197,8 +197,10 @@ describe('Violations — gap-based core-time detection', () => { }, }); + const token = await login(ctx.http, anna.email); const res = await ctx.http .get(`/api/violations?employeeId=${anna.id}`) + .set('Authorization', `Bearer ${token}`) .expect(200); expect(res.body).toEqual([]); }); @@ -221,8 +223,10 @@ describe('Violations — gap-based core-time detection', () => { }, }); + const token = await login(ctx.http, anna.email); const res = await ctx.http .get(`/api/violations?employeeId=${anna.id}`) + .set('Authorization', `Bearer ${token}`) .expect(200); expect(res.body).toHaveLength(1); expect(res.body[0]).toMatchObject({ boundary: '10:00–11:00', deltaMinutes: 60 }); @@ -290,8 +294,10 @@ describe('Violations — gap-based core-time detection', () => { ], }); + const token = await login(ctx.http, anna.email); const res = await ctx.http .get(`/api/violations?employeeId=${anna.id}`) + .set('Authorization', `Bearer ${token}`) .expect(200); // Only yesterday's violation surfaces — today is in progress. diff --git a/apps/api-e2e/src/api/employee-account-access.e2e.spec.ts b/apps/api-e2e/src/api/employee-account-access.e2e.spec.ts new file mode 100644 index 0000000..bf90751 --- /dev/null +++ b/apps/api-e2e/src/api/employee-account-access.e2e.spec.ts @@ -0,0 +1,449 @@ +import { JwtService } from '@nestjs/jwt'; +import { + createTestApp, + login, + seedEmployee, + seedLeaveAllowance, + type TestContext, +} from '../support/test-app'; + +const YEAR = new Date().getUTCFullYear(); +const ABSENCE_DAY = `${YEAR - 1}-05-05`; + +async function seedAccessFixture(ctx: TestContext) { + const hr = await seedEmployee(ctx.prisma, { + personalNo: 'ACCESS-HR', + firstName: 'Hannah', + lastName: 'Human Resources', + email: 'access.hr@test.local', + role: 'HRAdmin', + }); + const manager = await seedEmployee(ctx.prisma, { + personalNo: 'ACCESS-MANAGER', + firstName: 'Mara', + lastName: 'Manager', + email: 'access.manager@test.local', + role: 'Manager', + }); + const employee = await seedEmployee(ctx.prisma, { + personalNo: 'ACCESS-EMPLOYEE', + firstName: 'Emil', + lastName: 'Employee', + email: 'access.employee@test.local', + managerId: manager.id, + }); + const foreign = await seedEmployee(ctx.prisma, { + personalNo: 'ACCESS-FOREIGN', + firstName: 'Frida', + lastName: 'Foreign', + email: 'access.foreign@test.local', + }); + const inactive = await seedEmployee(ctx.prisma, { + personalNo: 'ACCESS-INACTIVE', + firstName: 'Ida', + lastName: 'Inactive', + email: 'access.inactive@test.local', + managerId: manager.id, + }); + await ctx.prisma.employee.update({ + where: { id: inactive.id }, + data: { isActive: false }, + }); + const people = { hr, manager, employee, foreign, inactive }; + for (const person of Object.values(people)) { + await seedLeaveAllowance( + ctx.prisma, + person.id, + YEAR, + person.id === employee.id ? 17 : person.id === foreign.id ? 23 : 30, + ); + } + await ctx.prisma.absence.createMany({ + data: Object.values(people).map((person) => ({ + employeeId: person.id, + kind: 'Sickness' as const, + from: new Date(`${ABSENCE_DAY}T00:00:00.000Z`), + to: new Date(`${ABSENCE_DAY}T00:00:00.000Z`), + certified: true, + note: `Synthetic private absence for ${person.personalNo}`, + })), + }); + await ctx.prisma.workSchedule.create({ + data: { + name: 'Access test core window', + frameStart: '07:00', + frameEnd: '18:00', + isDefault: true, + coreTimes: { + create: [ + { label: 'Core', start: '10:00', end: '11:00', weekdays: 127 }, + ], + }, + }, + }); + await ctx.prisma.timeEntry.createMany({ + data: Object.values(people).map((person) => ({ + employeeId: person.id, + clockIn: new Date(YEAR - 1, 4, 5, 11, 30), + clockOut: new Date(YEAR - 1, 4, 5, 12, 0), + status: 'Approved' as const, + })), + }); + return people; +} + +function recordPaths(employeeId: string) { + return [ + `/api/employees/${employeeId}`, + `/api/accounts/${employeeId}`, + `/api/accounts/${employeeId}/vacation?year=${YEAR}`, + ]; +} + +describe('Personal employee data read access', () => { + let ctx: TestContext; + let fixture: Awaited>; + + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + fixture = await seedAccessFixture(ctx); + }); + + async function expectRecordAccess( + token: string, + employeeId: string, + status: number, + query: Record = {}, + ) { + for (const [index, path] of recordPaths(employeeId).entries()) { + const response = await ctx.http + .get(path) + .query(query) + .set('Authorization', `Bearer ${token}`) + .expect(status); + if (status === 200) { + expect(response.body).toMatchObject( + index === 0 ? { id: employeeId } : { employeeId }, + ); + expect(response.body).not.toHaveProperty('passwordHash'); + } + } + for (const path of [ + `/api/employees/${employeeId}/leave-allowances`, + '/api/violations', + '/api/absences', + ]) { + const response = await ctx.http + .get(path) + .query({ + ...query, + // On these endpoints employeeId selects the record. Keep the target + // fixed while testing spoofed actor/role parameters. + ...(path.startsWith('/api/employees/') ? {} : { employeeId }), + }) + .set('Authorization', `Bearer ${token}`) + .expect(status); + if (status === 200) { + expect(response.body).toHaveLength(1); + expect(response.body[0]).toMatchObject({ employeeId }); + } + } + } + + async function expectProtectedResourcesReject(token: string) { + for (const path of [ + '/api/employees', + '/api/employees/directory', + ...recordPaths(fixture.employee.id), + `/api/employees/${fixture.employee.id}/leave-allowances`, + `/api/violations?employeeId=${fixture.employee.id}`, + `/api/absences?employeeId=${fixture.employee.id}`, + ]) { + await ctx.http + .get(path) + .set('Authorization', `Bearer ${token}`) + .expect(401); + } + } + + it.each([ + 'list', + 'directory', + 'profile', + 'account', + 'vacation', + 'leave allowances', + 'violations', + 'absences', + ])('rejects anonymous access to the %s endpoint', async (resource) => { + const paths: Record = { + list: '/api/employees', + directory: '/api/employees/directory', + profile: `/api/employees/${fixture.employee.id}`, + account: `/api/accounts/${fixture.employee.id}`, + vacation: `/api/accounts/${fixture.employee.id}/vacation?year=${YEAR}`, + 'leave allowances': `/api/employees/${fixture.employee.id}/leave-allowances`, + violations: `/api/violations?employeeId=${fixture.employee.id}`, + absences: `/api/absences?employeeId=${fixture.employee.id}`, + }; + await ctx.http.get(paths[resource]).expect(401); + }); + + it('lists only the authenticated employee, including with spoofed query parameters', async () => { + const token = await login(ctx.http, fixture.employee.email); + for (const query of [ + {}, + { + includeInactive: 'true', + employeeId: fixture.foreign.id, + actorId: fixture.hr.id, + role: 'HRAdmin', + }, + ]) { + const response = await ctx.http + .get('/api/employees') + .query(query) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(response.body.map((entry: { id: string }) => entry.id)).toEqual([ + fixture.employee.id, + ]); + } + }); + + it('allows an employee to read their own profile, balances, allowances, violations, and absences', async () => { + const token = await login(ctx.http, fixture.employee.email); + await expectRecordAccess(token, fixture.employee.id, 200); + const vacation = await ctx.http + .get(`/api/accounts/${fixture.employee.id}/vacation`) + .query({ year: YEAR, employeeId: fixture.foreign.id }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(vacation.body).toMatchObject({ + employeeId: fixture.employee.id, + year: YEAR, + baseDays: 17, + totalEntitlement: 17, + }); + }); + + it('forbids employees from reading colleagues, managers, HR, or inactive employees', async () => { + const token = await login(ctx.http, fixture.employee.email); + for (const target of [ + fixture.foreign, + fixture.manager, + fixture.hr, + fixture.inactive, + ]) { + await expectRecordAccess(token, target.id, 403); + } + }); + + it('does not let query parameters grant employee access to foreign records', async () => { + const token = await login(ctx.http, fixture.employee.email); + await expectRecordAccess(token, fixture.foreign.id, 403, { + employeeId: fixture.employee.id, + actorId: fixture.hr.id, + role: 'HRAdmin', + includeInactive: 'true', + }); + }); + + it('lists only a manager and their direct reports, retaining the active default filter', async () => { + const token = await login(ctx.http, fixture.manager.email); + for (const includeInactive of [false, true]) { + const response = await ctx.http + .get('/api/employees') + .query(includeInactive ? { includeInactive: 'true' } : {}) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const expectedIds = [fixture.manager.id, fixture.employee.id]; + if (includeInactive) expectedIds.push(fixture.inactive.id); + expect( + response.body.map((entry: { id: string }) => entry.id).sort(), + ).toEqual(expectedIds.sort()); + } + }); + + it('allows managers to read themselves and their direct reports', async () => { + const token = await login(ctx.http, fixture.manager.email); + for (const target of [ + fixture.manager, + fixture.employee, + fixture.inactive, + ]) { + await expectRecordAccess(token, target.id, 200); + } + }); + + it('forbids managers from reading unrelated employees or HR, even with spoofed query parameters', async () => { + const token = await login(ctx.http, fixture.manager.email); + for (const target of [fixture.foreign, fixture.hr]) { + await expectRecordAccess(token, target.id, 403, { + employeeId: fixture.employee.id, + actorId: fixture.hr.id, + role: 'HRAdmin', + }); + } + }); + + it('allows HR to list all employees while excluding inactive employees by default', async () => { + const token = await login(ctx.http, fixture.hr.email); + for (const includeInactive of [false, true]) { + const response = await ctx.http + .get('/api/employees') + .query(includeInactive ? { includeInactive: 'true' } : {}) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const expectedIds = [ + fixture.hr.id, + fixture.manager.id, + fixture.employee.id, + fixture.foreign.id, + ]; + if (includeInactive) expectedIds.push(fixture.inactive.id); + expect( + response.body.map((entry: { id: string }) => entry.id).sort(), + ).toEqual(expectedIds.sort()); + } + }); + + it('allows HR to read all employee records, including inactive employees', async () => { + const token = await login(ctx.http, fixture.hr.email); + for (const target of Object.values(fixture)) { + await expectRecordAccess(token, target.id, 200); + } + }); + + it.each(['Employee', 'Manager', 'HRAdmin'] as const)( + 'scopes unfiltered absence lists to the authenticated %s, including with dates and spoofed actor parameters', + async (role) => { + const actor = + role === 'Employee' + ? fixture.employee + : role === 'Manager' + ? fixture.manager + : fixture.hr; + const expectedIds = + role === 'Employee' + ? [fixture.employee.id] + : role === 'Manager' + ? [fixture.manager.id, fixture.employee.id, fixture.inactive.id] + : Object.values(fixture).map((person) => person.id); + const token = await login(ctx.http, actor.email); + for (const query of [ + {}, + { + from: ABSENCE_DAY, + to: ABSENCE_DAY, + actorId: fixture.hr.id, + role: 'HRAdmin', + includeInactive: 'true', + }, + ]) { + const response = await ctx.http + .get('/api/absences') + .query(query) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect( + response.body + .map((entry: { employeeId: string }) => entry.employeeId) + .sort(), + ).toEqual([...expectedIds].sort()); + expect( + response.body.every( + (entry: { certified: boolean }) => entry.certified, + ), + ).toBe(true); + } + }, + ); + + it.each([false, true])( + 'exposes only active names and IDs in the directory (includeInactive=%s)', + async (includeInactive) => { + const token = await login(ctx.http, fixture.employee.email); + const response = await ctx.http + .get('/api/employees/directory') + .query(includeInactive ? { includeInactive: 'true' } : {}) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const expected = [ + fixture.hr, + fixture.manager, + fixture.employee, + fixture.foreign, + ].map(({ id, firstName, lastName }) => ({ id, firstName, lastName })); + expect(response.body).toHaveLength(expected.length); + expect(response.body).toEqual(expect.arrayContaining(expected)); + for (const entry of response.body) { + expect(Object.keys(entry).sort()).toEqual([ + 'firstName', + 'id', + 'lastName', + ]); + } + await expectRecordAccess(token, fixture.foreign.id, 403); + }, + ); + + it('rejects a JWT whose role claim was altered without a valid signature', async () => { + const token = await login(ctx.http, fixture.employee.email); + const [header, payload, signature] = token.split('.'); + const claims = JSON.parse( + Buffer.from(payload, 'base64url').toString('utf8'), + ); + const forgedPayload = Buffer.from( + JSON.stringify({ ...claims, role: 'HRAdmin' }), + ).toString('base64url'); + await expectProtectedResourcesReject( + `${header}.${forgedPayload}.${signature}`, + ); + }); + + it('rejects a correctly signed but expired access token', async () => { + const token = ctx.app.get(JwtService).sign( + { + sub: fixture.employee.id, + email: fixture.employee.email, + role: fixture.employee.role, + typ: 'access', + ver: fixture.employee.authVersion, + }, + { expiresIn: -1 }, + ); + await expectProtectedResourcesReject(token); + }); + + it('rejects an existing access token immediately after its employee is deactivated', async () => { + const token = await login(ctx.http, fixture.employee.email); + await ctx.prisma.employee.update({ + where: { id: fixture.employee.id }, + data: { isActive: false }, + }); + await expectProtectedResourcesReject(token); + }); + + it('uses the current database role instead of stale HR claims in an access token', async () => { + const token = await login(ctx.http, fixture.hr.email); + await ctx.prisma.employee.update({ + where: { id: fixture.hr.id }, + data: { role: 'Employee' }, + }); + const response = await ctx.http + .get('/api/employees') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(response.body.map((entry: { id: string }) => entry.id)).toEqual([ + fixture.hr.id, + ]); + await expectRecordAccess(token, fixture.foreign.id, 403); + }); +}); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 1e3499e..1658dc8 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -815,6 +815,33 @@ "tags": ["employees"] } }, + "/api/employees/directory": { + "get": { + "operationId": "EmployeesController_directory", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmployeeDirectoryDto" + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["employees"] + } + }, "/api/employees/{id}": { "get": { "operationId": "EmployeesController_get", @@ -2782,6 +2809,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["leave-allowances"] } }, @@ -3780,6 +3812,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["violations"] } }, @@ -4662,6 +4699,22 @@ }, "required": ["status", "service", "version", "utcTimestamp"] }, + "EmployeeDirectoryDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + }, + "required": ["id", "firstName", "lastName"] + }, "CreateEmployeeDto": { "type": "object", "properties": { diff --git a/apps/api/src/app/absences/absences.controller.ts b/apps/api/src/app/absences/absences.controller.ts index 91ceb6b..d111860 100644 --- a/apps/api/src/app/absences/absences.controller.ts +++ b/apps/api/src/app/absences/absences.controller.ts @@ -32,11 +32,13 @@ export class AbsencesController { @Get() list( + @CurrentUser() user: JwtUser, @Query('employeeId') employeeId?: string, @Query('from') from?: string, @Query('to') to?: string, ): Promise { return this.absences.list( + user, employeeId, from ? new Date(from) : undefined, to ? new Date(to) : undefined, @@ -44,7 +46,10 @@ export class AbsencesController { } @Post() - create(@CurrentUser() user: JwtUser, @Body() dto: CreateAbsenceDto): Promise { + create( + @CurrentUser() user: JwtUser, + @Body() dto: CreateAbsenceDto, + ): Promise { return this.absences.create(user, dto); } diff --git a/apps/api/src/app/absences/absences.module.ts b/apps/api/src/app/absences/absences.module.ts index 587c258..c6a7eb2 100644 --- a/apps/api/src/app/absences/absences.module.ts +++ b/apps/api/src/app/absences/absences.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { EmployeesModule } from '../employees/employees.module'; import { AbsencesController } from './absences.controller'; import { AbsencesService } from './absences.service'; @Module({ - imports: [AuthModule], + imports: [AuthModule, EmployeesModule], controllers: [AbsencesController], providers: [AbsencesService], exports: [AbsencesService], diff --git a/apps/api/src/app/absences/absences.service.ts b/apps/api/src/app/absences/absences.service.ts index 5f1fa1e..3dccf12 100644 --- a/apps/api/src/app/absences/absences.service.ts +++ b/apps/api/src/app/absences/absences.service.ts @@ -8,6 +8,7 @@ import { import type { Absence, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import type { JwtUser } from '../auth/jwt.strategy'; +import { EmployeesService } from '../employees/employees.service'; import { toAbsenceDto, type AbsenceDto, @@ -17,14 +18,21 @@ import { @Injectable() export class AbsencesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly employees: EmployeesService, + ) {} async list( + actor: JwtUser, employeeId: string | undefined, from?: Date, to?: Date, ): Promise { - const where: Prisma.AbsenceWhereInput = {}; + if (employeeId) await this.employees.assertCanRead(employeeId, actor); + const where: Prisma.AbsenceWhereInput = { + employee: this.employees.readScope(actor), + }; if (employeeId) where.employeeId = employeeId; if (from || to) { where.OR = [ diff --git a/apps/api/src/app/employees/employees.controller.ts b/apps/api/src/app/employees/employees.controller.ts index 18c3aca..4606d06 100644 --- a/apps/api/src/app/employees/employees.controller.ts +++ b/apps/api/src/app/employees/employees.controller.ts @@ -12,7 +12,7 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { JwtUser } from '../auth/jwt.strategy'; @@ -21,6 +21,7 @@ import { RolesGuard } from '../auth/roles.guard'; import { EmployeesService } from './employees.service'; import { CreateEmployeeDto, + EmployeeDirectoryDto, SetPasswordDto, UpdateEmployeeDto, type EmployeeDto, @@ -43,6 +44,13 @@ export class EmployeesController { }); } + // Static route before ':id'; authenticated names without personnel details. + @Get('directory') + @ApiOkResponse({ type: [EmployeeDirectoryDto] }) + directory(): Promise { + return this.employees.directory(); + } + @Get(':id') async get( @Param('id', new ParseUUIDPipe()) id: string, diff --git a/apps/api/src/app/employees/employees.dto.ts b/apps/api/src/app/employees/employees.dto.ts index 32bcede..5e5c7ee 100644 --- a/apps/api/src/app/employees/employees.dto.ts +++ b/apps/api/src/app/employees/employees.dto.ts @@ -51,6 +51,18 @@ export type EmployeeWithSchedule = Employee & { workSchedule: WorkSchedule | null; }; +/** Minimal names for choosing a substitute or assigning a project. */ +export class EmployeeDirectoryDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + firstName!: string; + + @ApiProperty() + lastName!: string; +} + export interface EmployeeDto { id: string; personalNo: string; diff --git a/apps/api/src/app/employees/employees.service.ts b/apps/api/src/app/employees/employees.service.ts index e7f8c86..fa1752e 100644 --- a/apps/api/src/app/employees/employees.service.ts +++ b/apps/api/src/app/employees/employees.service.ts @@ -12,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { toEmployeeDto, type CreateEmployeeDto, + type EmployeeDirectoryDto, type EmployeeDto, type UpdateEmployeeDto, } from './employees.dto'; @@ -22,6 +23,14 @@ const BCRYPT_ROUNDS = 10; export class EmployeesService { constructor(private readonly prisma: PrismaService) {} + async directory(): Promise { + return this.prisma.employee.findMany({ + where: { isActive: true }, + select: { id: true, firstName: true, lastName: true }, + orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], + }); + } + async list(opts: { includeInactive?: boolean } = {}): Promise { const employees = await this.prisma.employee.findMany({ where: opts.includeInactive ? undefined : { isActive: true }, @@ -35,16 +44,10 @@ export class EmployeesService { actor: JwtUser, opts: { includeInactive?: boolean } = {}, ): Promise { - const accessFilter: Prisma.EmployeeWhereInput = - actor.role === 'HRAdmin' - ? {} - : actor.role === 'Manager' - ? { OR: [{ id: actor.id }, { managerId: actor.id }] } - : { id: actor.id }; const employees = await this.prisma.employee.findMany({ where: { AND: [ - accessFilter, + this.readScope(actor), ...(opts.includeInactive ? [] : [{ isActive: true }]), ], }, @@ -54,6 +57,14 @@ export class EmployeesService { return employees.map(toEmployeeDto); } + readScope(actor: JwtUser): Prisma.EmployeeWhereInput { + if (actor.role === 'HRAdmin') return {}; + if (actor.role === 'Manager') { + return { OR: [{ id: actor.id }, { managerId: actor.id }] }; + } + return { id: actor.id }; + } + async assertCanRead(id: string, actor: JwtUser): Promise { if (actor.role === 'HRAdmin' || id === actor.id) return; const visible = diff --git a/apps/api/src/app/leave-allowances/leave-allowances.controller.ts b/apps/api/src/app/leave-allowances/leave-allowances.controller.ts index 2e4e73f..7bf458f 100644 --- a/apps/api/src/app/leave-allowances/leave-allowances.controller.ts +++ b/apps/api/src/app/leave-allowances/leave-allowances.controller.ts @@ -13,25 +13,34 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { JwtUser } from '../auth/jwt.strategy'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; import { CronKeyGuard } from './cron-key.guard'; import { LeaveAllowancesService } from './leave-allowances.service'; -import { UpsertLeaveAllowanceDto, type LeaveAllowanceDto } from './leave-allowances.dto'; +import { + UpsertLeaveAllowanceDto, + type LeaveAllowanceDto, +} from './leave-allowances.dto'; @ApiTags('leave-allowances') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) @Controller('employees/:employeeId/leave-allowances') export class LeaveAllowancesController { constructor(private readonly service: LeaveAllowancesService) {} @Get() - list(@Param('employeeId', new ParseUUIDPipe()) employeeId: string): Promise { - return this.service.list(employeeId); + list( + @Param('employeeId', new ParseUUIDPipe()) employeeId: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.list(employeeId, user); } @Put(':year') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(RolesGuard) @Roles('HRAdmin') upsert( @Param('employeeId', new ParseUUIDPipe()) employeeId: string, diff --git a/apps/api/src/app/leave-allowances/leave-allowances.module.ts b/apps/api/src/app/leave-allowances/leave-allowances.module.ts index 7517d74..34f54b6 100644 --- a/apps/api/src/app/leave-allowances/leave-allowances.module.ts +++ b/apps/api/src/app/leave-allowances/leave-allowances.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { EmployeesModule } from '../employees/employees.module'; import { CronKeyGuard } from './cron-key.guard'; import { CronCarryOverController, @@ -9,7 +10,7 @@ import { import { LeaveAllowancesService } from './leave-allowances.service'; @Module({ - imports: [AuthModule], + imports: [AuthModule, EmployeesModule], controllers: [ LeaveAllowancesController, LeaveAllowancesAdminController, diff --git a/apps/api/src/app/leave-allowances/leave-allowances.service.ts b/apps/api/src/app/leave-allowances/leave-allowances.service.ts index 5547540..76c1f78 100644 --- a/apps/api/src/app/leave-allowances/leave-allowances.service.ts +++ b/apps/api/src/app/leave-allowances/leave-allowances.service.ts @@ -1,13 +1,23 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import type { EmployeeLeaveAllowance } from '@prisma/client'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { EmployeesService } from '../employees/employees.service'; import { PrismaService } from '../prisma/prisma.service'; -import { toLeaveAllowanceDto, type LeaveAllowanceDto, type UpsertLeaveAllowanceDto } from './leave-allowances.dto'; +import { + toLeaveAllowanceDto, + type LeaveAllowanceDto, + type UpsertLeaveAllowanceDto, +} from './leave-allowances.dto'; @Injectable() export class LeaveAllowancesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly employees: EmployeesService, + ) {} - async list(employeeId: string): Promise { + async list(employeeId: string, actor: JwtUser): Promise { + await this.employees.assertCanRead(employeeId, actor); await this.assertEmployee(employeeId); const rows = await this.prisma.employeeLeaveAllowance.findMany({ where: { employeeId }, @@ -16,7 +26,10 @@ export class LeaveAllowancesService { return rows.map(toLeaveAllowanceDto); } - async getOrDefault(employeeId: string, year: number): Promise { + async getOrDefault( + employeeId: string, + year: number, + ): Promise { const existing = await this.prisma.employeeLeaveAllowance.findUnique({ where: { employeeId_year: { employeeId, year } }, }); @@ -39,7 +52,9 @@ export class LeaveAllowancesService { dto: UpsertLeaveAllowanceDto, ): Promise { await this.assertEmployee(employeeId); - const carryOverExpiresOn = dto.carryOverExpiresOn ? new Date(dto.carryOverExpiresOn) : null; + const carryOverExpiresOn = dto.carryOverExpiresOn + ? new Date(dto.carryOverExpiresOn) + : null; const row = await this.prisma.employeeLeaveAllowance.upsert({ where: { employeeId_year: { employeeId, year } }, create: { @@ -71,7 +86,9 @@ export class LeaveAllowancesService { * The reason — including the amount and the original expiry date — is * appended to `adjustmentReason` so the audit trail survives the write. */ - async expireCarryOvers(asOf: Date = new Date()): Promise<{ scanned: number; expired: number }> { + async expireCarryOvers( + asOf: Date = new Date(), + ): Promise<{ scanned: number; expired: number }> { const candidates = await this.prisma.employeeLeaveAllowance.findMany({ where: { carryOverExpiresOn: { not: null, lt: asOf }, @@ -81,9 +98,12 @@ export class LeaveAllowancesService { let expired = 0; for (const row of candidates) { const lost = Number(row.carryOverDays); - const dateStr = row.carryOverExpiresOn?.toISOString().slice(0, 10) ?? 'unknown'; + const dateStr = + row.carryOverExpiresOn?.toISOString().slice(0, 10) ?? 'unknown'; const note = `Carry-over of ${lost.toFixed(2)} days expired on ${dateStr}`; - const reason = row.adjustmentReason ? `${row.adjustmentReason}; ${note}` : note; + const reason = row.adjustmentReason + ? `${row.adjustmentReason}; ${note}` + : note; await this.prisma.employeeLeaveAllowance.update({ where: { id: row.id }, data: { carryOverDays: 0, adjustmentReason: reason }, @@ -94,8 +114,11 @@ export class LeaveAllowancesService { } private async assertEmployee(employeeId: string) { - const employee = await this.prisma.employee.findUnique({ where: { id: employeeId } }); - if (!employee) throw new NotFoundException(`Employee ${employeeId} not found`); + const employee = await this.prisma.employee.findUnique({ + where: { id: employeeId }, + }); + if (!employee) + throw new NotFoundException(`Employee ${employeeId} not found`); return employee; } } diff --git a/apps/api/src/app/violations/violations.controller.ts b/apps/api/src/app/violations/violations.controller.ts index f18e09e..984d8e4 100644 --- a/apps/api/src/app/violations/violations.controller.ts +++ b/apps/api/src/app/violations/violations.controller.ts @@ -1,8 +1,13 @@ -import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { JwtUser } from '../auth/jwt.strategy'; import { ViolationsService, type ViolationDto } from './violations.service'; @ApiTags('violations') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) @Controller('violations') export class ViolationsController { constructor(private readonly violations: ViolationsService) {} @@ -10,11 +15,13 @@ export class ViolationsController { @Get() list( @Query('employeeId') employeeId: string, + @CurrentUser() user: JwtUser, @Query('from') from?: string, @Query('to') to?: string, ): Promise { return this.violations.list( employeeId, + user, from ? new Date(from) : undefined, to ? new Date(to) : undefined, ); diff --git a/apps/api/src/app/violations/violations.service.ts b/apps/api/src/app/violations/violations.service.ts index 112bd56..c6d51e0 100644 --- a/apps/api/src/app/violations/violations.service.ts +++ b/apps/api/src/app/violations/violations.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { detectCoreTimeViolationsForDay } from 'shared'; +import type { JwtUser } from '../auth/jwt.strategy'; import { EmployeesService } from '../employees/employees.service'; import { PrismaService } from '../prisma/prisma.service'; import { WorkSchedulesService } from '../work-schedules/work-schedules.service'; @@ -28,7 +29,13 @@ export class ViolationsService { private readonly schedules: WorkSchedulesService, ) {} - async list(employeeId: string, from?: Date, to?: Date): Promise { + async list( + employeeId: string, + actor: JwtUser, + from?: Date, + to?: Date, + ): Promise { + await this.employees.assertCanRead(employeeId, actor); const employee = await this.employees.getById(employeeId); if (employee.timeModel === 'Vertrauensarbeitszeit') return []; @@ -84,7 +91,10 @@ export class ViolationsService { } } // Stable ordering: newest day first, then by window start. - out.sort((a, b) => b.date.localeCompare(a.date) || a.boundary.localeCompare(b.boundary)); + out.sort( + (a, b) => + b.date.localeCompare(a.date) || a.boundary.localeCompare(b.boundary), + ); return out; } } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3cf2bc7..7fc0692 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -81,6 +81,12 @@ export const BUNDESLAND_LABEL: Record = { export type HolidayCalendar = 'NONE' | `DE-${Bundesland}`; +export interface EmployeeDirectoryDto { + id: string; + firstName: string; + lastName: string; +} + export interface EmployeeDto { id: string; personalNo: string; @@ -844,6 +850,8 @@ export const api = { request( `/api/employees${includeInactive ? '?includeInactive=true' : ''}`, ), + employeeDirectory: () => + request('/api/employees/directory'), createEmployee: (payload: CreateEmployeePayload) => request('/api/employees', { method: 'POST', diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts index af9ee0c..ea7a5e3 100644 --- a/apps/web/src/api/generated.ts +++ b/apps/web/src/api/generated.ts @@ -324,6 +324,22 @@ export interface paths { patch?: never; trace?: never; }; + '/api/employees/directory': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['EmployeesController_directory']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/employees/{id}': { parameters: { query?: never; @@ -1699,6 +1715,12 @@ export interface components { /** Format: date-time */ utcTimestamp: string; }; + EmployeeDirectoryDto: { + /** Format: uuid */ + id: string; + firstName: string; + lastName: string; + }; CreateEmployeeDto: { /** @example 1001 */ personalNo: string; @@ -3230,6 +3252,25 @@ export interface operations { }; }; }; + EmployeesController_directory: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['EmployeeDirectoryDto'][]; + }; + }; + }; + }; EmployeesController_get: { parameters: { query?: never; diff --git a/apps/web/src/routes/AdminProjectsPage.spec.tsx b/apps/web/src/routes/AdminProjectsPage.spec.tsx index 3785f73..4316234 100644 --- a/apps/web/src/routes/AdminProjectsPage.spec.tsx +++ b/apps/web/src/routes/AdminProjectsPage.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { renderWithProviders } from '../test-utils'; const projectsMock = vi.fn(); -const employeesMock = vi.fn(); +const employeeDirectoryMock = vi.fn(); const projectAssignmentsMock = vi.fn(); const createProjectMock = vi.fn(); const updateProjectMock = vi.fn(); @@ -18,7 +18,7 @@ const projectReportMock = vi.fn(); vi.mock('../api/client', () => ({ api: { projects: (...args: unknown[]) => projectsMock(...args), - employees: (...args: unknown[]) => employeesMock(...args), + employeeDirectory: (...args: unknown[]) => employeeDirectoryMock(...args), projectAssignments: (...args: unknown[]) => projectAssignmentsMock(...args), createProject: (...args: unknown[]) => createProjectMock(...args), updateProject: (...args: unknown[]) => updateProjectMock(...args), @@ -90,19 +90,13 @@ const PROJECTS = [ const EMPLOYEES = [ { id: 'e-1', - personalNo: '1001', firstName: 'Anna', lastName: 'Müller', - role: 'Employee', - isActive: true, }, { id: 'e-2', - personalNo: '1002', firstName: 'Bernd', lastName: 'Schulz', - role: 'Employee', - isActive: true, }, ]; @@ -110,7 +104,7 @@ describe('AdminProjectsPage', () => { beforeEach(() => { role = 'HRAdmin'; projectsMock.mockClear().mockResolvedValue(PROJECTS); - employeesMock.mockClear().mockResolvedValue(EMPLOYEES); + employeeDirectoryMock.mockClear().mockResolvedValue(EMPLOYEES); projectAssignmentsMock .mockClear() .mockResolvedValue([{ employeeId: 'e-1', projectId: 'p-1' }]); @@ -194,28 +188,33 @@ describe('AdminProjectsPage', () => { }); }); - it('renders the assignment matrix and toggles assignments', async () => { - const { AdminProjectsPage } = await import('./AdminProjectsPage'); - renderWithProviders(); - await waitFor(() => { - expect(screen.getByText('Zuweisungsmatrix')).toBeDefined(); - }); + it.each(['HRAdmin', 'Manager'])( + 'lets %s assign projects using only directory names', + async (actorRole) => { + role = actorRole; + const { AdminProjectsPage } = await import('./AdminProjectsPage'); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Zuweisungsmatrix')).toBeDefined(); + }); - const annaP1 = await screen.findByLabelText('Anna Müller – PRJ-001'); - const berndP2 = screen.getByLabelText('Bernd Schulz – PRJ-002'); - expect((annaP1 as HTMLInputElement).checked).toBe(true); - expect((berndP2 as HTMLInputElement).checked).toBe(false); + const annaP1 = await screen.findByLabelText('Anna Müller – PRJ-001'); + const berndP2 = screen.getByLabelText('Bernd Schulz – PRJ-002'); + expect(employeeDirectoryMock).toHaveBeenCalled(); + expect((annaP1 as HTMLInputElement).checked).toBe(true); + expect((berndP2 as HTMLInputElement).checked).toBe(false); - // Unchecking an assigned cell unassigns; checking an empty one assigns. - fireEvent.click(annaP1); - await waitFor(() => { - expect(unassignProjectMock).toHaveBeenCalledWith('p-1', 'e-1'); - }); - fireEvent.click(berndP2); - await waitFor(() => { - expect(assignProjectMock).toHaveBeenCalledWith('p-2', 'e-2'); - }); - }); + // Unchecking an assigned cell unassigns; checking an empty one assigns. + fireEvent.click(annaP1); + await waitFor(() => { + expect(unassignProjectMock).toHaveBeenCalledWith('p-1', 'e-1'); + }); + fireEvent.click(berndP2); + await waitFor(() => { + expect(assignProjectMock).toHaveBeenCalledWith('p-2', 'e-2'); + }); + }, + ); it('flags overbooked plans in red and shows IST-only without a plan', async () => { const { AdminProjectsPage } = await import('./AdminProjectsPage'); diff --git a/apps/web/src/routes/AdminProjectsPage.tsx b/apps/web/src/routes/AdminProjectsPage.tsx index 0c819b4..c1b03c7 100644 --- a/apps/web/src/routes/AdminProjectsPage.tsx +++ b/apps/web/src/routes/AdminProjectsPage.tsx @@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { api, + type EmployeeDirectoryDto, type ProjectAssignmentDto, type ProjectDto, type ProjectReportDto, @@ -123,8 +124,8 @@ export function AdminProjectsPage() { enabled: isAuthorized, }); const employees = useQuery({ - queryKey: ['employees'], - queryFn: () => api.employees(), + queryKey: ['employee-directory'], + queryFn: () => api.employeeDirectory(), enabled: isAuthorized, }); const assignments = useQuery({ @@ -149,7 +150,7 @@ export function AdminProjectsPage() { ); } - const activeEmployees = (employees.data ?? []).filter((e) => e.isActive); + const activeEmployees = employees.data ?? []; return (
@@ -549,12 +550,7 @@ function ServiceOrderRow({ interface MatrixProps { projects: ProjectDto[]; - employees: { - id: string; - firstName: string; - lastName: string; - personalNo: string; - }[]; + employees: EmployeeDirectoryDto[]; assignments: ProjectAssignmentDto[]; } @@ -636,9 +632,6 @@ function AssignmentMatrix({ projects, employees, assignments }: MatrixProps) { {emp.firstName} {emp.lastName} - - ({emp.personalNo}) - {projects.map((p) => { const isAssigned = assigned.has(`${emp.id}:${p.id}`); diff --git a/apps/web/src/routes/RequestsPage.spec.tsx b/apps/web/src/routes/RequestsPage.spec.tsx new file mode 100644 index 0000000..933cab4 --- /dev/null +++ b/apps/web/src/routes/RequestsPage.spec.tsx @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import { renderWithProviders } from '../test-utils'; +import { RequestsPage } from './RequestsPage'; + +const employeeDirectoryMock = vi.fn(); +const employeesMock = vi.fn(); +const createVacationRequestMock = vi.fn(); + +vi.mock('../api/client', () => ({ + api: { + listRequests: vi.fn().mockResolvedValue([]), + employeeDirectory: (...args: unknown[]) => employeeDirectoryMock(...args), + employees: (...args: unknown[]) => employeesMock(...args), + vacationBalance: vi.fn().mockResolvedValue({ + remainingDays: 30, + totalEntitlement: 30, + approvedDays: 0, + pendingDays: 0, + }), + createVacationRequest: (...args: unknown[]) => + createVacationRequestMock(...args), + }, +})); + +vi.mock('../app/auth', () => ({ + useCurrentUser: () => ({ + id: 'requester', + firstName: 'Anna', + lastName: 'Example', + role: 'Employee', + }), +})); + +describe('vacation substitute selection', () => { + beforeEach(() => { + employeesMock.mockClear(); + employeeDirectoryMock.mockClear().mockResolvedValue([ + { id: 'requester', firstName: 'Anna', lastName: 'Example' }, + { id: 'substitute', firstName: 'Bernd', lastName: 'Example' }, + ]); + createVacationRequestMock.mockClear().mockResolvedValue({}); + }); + + it('lets an employee select a colleague and submit without reading personnel records', async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'Neuer Antrag' })); + + const selection = await screen.findByLabelText('Vertretung (optional)'); + expect( + await within(selection).findByRole('option', { + name: 'Bernd Example', + }), + ).toBeDefined(); + expect( + within(selection).queryByRole('option', { + name: 'Anna Example', + }), + ).toBeNull(); + expect(employeeDirectoryMock).toHaveBeenCalled(); + expect(employeesMock).not.toHaveBeenCalled(); + + fireEvent.change(selection, { target: { value: 'substitute' } }); + fireEvent.click(screen.getByRole('button', { name: 'Antrag stellen' })); + + await waitFor(() => { + expect(createVacationRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + employeeId: 'requester', + substituteId: 'substitute', + }), + ); + }); + }); +}); diff --git a/apps/web/src/routes/RequestsPage.tsx b/apps/web/src/routes/RequestsPage.tsx index e04d95c..ce0534b 100644 --- a/apps/web/src/routes/RequestsPage.tsx +++ b/apps/web/src/routes/RequestsPage.tsx @@ -172,8 +172,8 @@ function NewRequestForm({ onClose }: NewRequestFormProps) { const [error, setError] = useState(null); const employees = useQuery({ - queryKey: ['employees'], - queryFn: () => api.employees(), + queryKey: ['employee-directory'], + queryFn: () => api.employeeDirectory(), }); const fromDate = useMemo(() => new Date(`${from}T00:00:00.000Z`), [from]); @@ -319,7 +319,7 @@ function NewRequestForm({ onClose }: NewRequestFormProps) { .filter((e) => e.id !== employeeId) .map((e) => ( ))}