From b0e05daa420b189f7fbe4e1f54d42ab624c162da Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 19 Sep 2026 08:13:00 +0200 Subject: [PATCH] feat: open the last locked fields for editing, keeping their history intact Four fields were still frozen because changing them would have bypassed the record that makes the change traceable, and a user's login data could not be corrected at all. Locking a field is the wrong trade: a wrong value stays in the system forever. The fix is to let the edit write the same history entry the dedicated endpoint writes. - A colony's apiary is editable through PATCH and writes the same ColonyApiaryHistory entry POST /colonies/:id/move writes, so the location history never skips a stand the colony was recorded on. - A honey batch's storage container is editable through PATCH and writes the same HoneyBatchRelocation entry POST /honey-batches/:id/relocate writes. - A stock item's quantity is editable and books the difference as an ADJUSTMENT movement, so the stored level stays the sum of its movements. - A role's key is editable (system roles were already rejected outright by RolesService.update, which keeps the seed's ADMIN lookup intact). - A user's e-mail - the login identity - is editable. A typo there previously locked the account out for good. - New POST /users/:id/reset-password lets an administrator set someone else's password: there is no self-service reset flow, so a forgotten password was the one thing no edit could repair. It revokes every refresh token and clears the permission cache exactly as a self-service change does, refuses to act on your own account (that goes through /profile/password, which proves ownership by asking for the current one), and never writes the password to the log. Frontend: - Users had no edit UI at all, although PATCH /users/:id existed. New EditUserDialog covers name, e-mail, role and an optional new password. - Colony, honey batch and stock item forms gained the newly editable fields, each with a note saying the correction is recorded as a move, a relocation or a stock adjustment. - The role dialog gained the key field. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/colonies/colonies.service.spec.ts | 28 +++ .../src/colonies/colonies.service.ts | 28 ++- .../src/colonies/dto/update-colony.dto.ts | 9 +- .../dto/update-honey-batch.dto.ts | 9 +- .../honey-batches.service.spec.ts | 44 +++++ .../honey-batches/honey-batches.service.ts | 26 ++- .../src/roles/dto/update-role.dto.ts | 12 ++ ApiCore-Backend/src/roles/roles.service.ts | 2 +- .../src/storage/dto/update-stock-item.dto.ts | 18 +- .../src/storage/storage.service.spec.ts | 24 +++ .../src/storage/storage.service.ts | 26 ++- .../src/users/dto/reset-user-password.dto.ts | 15 ++ .../src/users/dto/update-user.dto.ts | 14 +- ApiCore-Backend/src/users/users.controller.ts | 11 ++ .../src/users/users.service.spec.ts | 51 ++++++ ApiCore-Backend/src/users/users.service.ts | 45 +++++ .../(app)/colonies/[id]/colony-edit-card.tsx | 36 +++- .../[id]/honey-batch-edit-dialog.tsx | 32 ++++ .../settings/users-roles/edit-role-dialog.tsx | 19 +- .../settings/users-roles/edit-user-dialog.tsx | 170 ++++++++++++++++++ .../(app)/settings/users-roles/users-tab.tsx | 2 + .../(app)/storage/edit-stock-item-dialog.tsx | 19 ++ ApiCore-Frontend/src/lib/colonies/types.ts | 2 + .../src/lib/honey-batches/types.ts | 2 + ApiCore-Frontend/src/lib/roles/types.ts | 1 + ApiCore-Frontend/src/lib/storage/types.ts | 2 + ApiCore-Frontend/src/lib/users/api.ts | 19 +- ApiCore-Frontend/src/lib/users/types.ts | 1 + 28 files changed, 633 insertions(+), 34 deletions(-) create mode 100644 ApiCore-Backend/src/users/dto/reset-user-password.dto.ts create mode 100644 ApiCore-Frontend/src/app/(app)/settings/users-roles/edit-user-dialog.tsx diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index 3dc8b44..fb85209 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -336,4 +336,32 @@ describe('ColoniesService', () => { expect.objectContaining({ action: 'UPDATE' }), ); }); + + it('writes an apiary-history entry when an edit corrects the apiary', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.update.mockResolvedValue({ + ...parentColony, + apiaryId: 'apiary-2', + }); + + await service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'); + + expect(tx.colonyApiaryHistory.create).toHaveBeenCalledWith({ + data: { + colonyId: 'colony-1', + apiaryId: 'apiary-2', + movedByUserId: 'user-1', + notes: 'Korrektur über Volk bearbeiten', + }, + }); + }); + + it('writes no apiary-history entry when an edit leaves the apiary untouched', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.update.mockResolvedValue(parentColony); + + await service.update('colony-1', { notes: 'geändert' }, 'user-1'); + + expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled(); + }); }); diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index dfbd0ae..603c4db 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -90,9 +90,31 @@ export class ColoniesService { actingUserId: string, ): Promise { const before = await this.findById(id); - const colony = await this.prisma.colony.update({ - where: { id }, - data: { ...dto }, + const movesApiary = + dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId; + if (movesApiary) { + await this.assertApiaryActive(dto.apiaryId!); + } + + const colony = await this.prisma.$transaction(async (tx) => { + const updated = await tx.colony.update({ + where: { id }, + data: { ...dto }, + }); + // Correcting the apiary has to leave the same trace a deliberate move + // does - otherwise the colony's location history would silently skip a + // stand it was recorded on. + if (movesApiary) { + await tx.colonyApiaryHistory.create({ + data: { + colonyId: id, + apiaryId: updated.apiaryId, + movedByUserId: actingUserId, + notes: 'Korrektur über Volk bearbeiten', + }, + }); + } + return updated; }); await this.auditLog.record({ entityType: 'Colony', diff --git a/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts index d3e4258..11833e1 100644 --- a/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts +++ b/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts @@ -4,13 +4,14 @@ import { ColonyStatus } from '../../../generated/prisma'; import { CreateColonyDto } from './create-colony.dto'; /** - * `apiaryId` is deliberately absent: moving a colony to another apiary runs - * through POST /colonies/:id/move, which also writes the ColonyApiaryHistory - * entry the traceability chain depends on. A plain PATCH would move the - * colony without that record. + * `apiaryId` is editable here too, not only through POST /colonies/:id/move: + * a colony recorded on the wrong apiary is a data-entry error and must be + * correctable. The service writes the same ColonyApiaryHistory entry the move + * endpoint writes, so the traceability chain stays intact either way. */ class UpdatableColonyFieldsDto extends PartialType( PickType(CreateColonyDto, [ + 'apiaryId', 'code', 'hiveType', 'originText', diff --git a/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts b/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts index 39a4aa7..ec163c0 100644 --- a/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts +++ b/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts @@ -4,10 +4,10 @@ import { HoneyBatchStatus } from '../../../generated/prisma'; import { CreateHoneyBatchDto } from './create-honey-batch.dto'; /** - * `storageContainerId` is deliberately absent: moving a batch to another - * container runs through POST /honey-batches/:id/relocate, which also writes - * the HoneyBatchRelocation entry. A plain PATCH would move the honey without - * that record. + * `storageContainerId` is editable here too, not only through + * POST /honey-batches/:id/relocate: a batch recorded in the wrong container is + * a data-entry error and must be correctable. The service writes the same + * HoneyBatchRelocation entry the relocate endpoint writes. */ class UpdatableHoneyBatchFieldsDto extends PartialType( PickType(CreateHoneyBatchDto, [ @@ -16,6 +16,7 @@ class UpdatableHoneyBatchFieldsDto extends PartialType( 'honeyType', 'amount', 'waterContent', + 'storageContainerId', ] as const), ) {} diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts index 12d587e..35b03f6 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts @@ -354,4 +354,48 @@ describe('HoneyBatchesService', () => { ); }); }); + + describe('update', () => { + beforeEach(() => { + prisma.$transaction.mockImplementation( + async ( + callback: (tx: typeof prisma) => Promise, + ): Promise => callback(prisma), + ); + }); + + it('records a relocation when an edit corrects the storage container', async () => { + prisma.honeyBatch.findUnique.mockResolvedValue({ + ...honeyBatch, + storageContainerId: 'container-1', + }); + + await service.update( + 'batch-1', + { storageContainerId: 'container-2' }, + 'user-1', + ); + + expect(prisma.honeyBatchRelocation.create).toHaveBeenCalledWith({ + data: { + honeyBatchId: 'batch-1', + fromStorageContainerId: 'container-1', + toStorageContainerId: 'container-2', + userId: 'user-1', + notes: 'Korrektur über Honigcharge bearbeiten', + }, + }); + }); + + it('records no relocation when an edit leaves the container untouched', async () => { + prisma.honeyBatch.findUnique.mockResolvedValue({ + ...honeyBatch, + storageContainerId: 'container-1', + }); + + await service.update('batch-1', { honeyType: 'Waldhonig' }, 'user-1'); + + expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts index 185b5db..6b1449b 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts @@ -86,9 +86,29 @@ export class HoneyBatchesService { async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) { const before = await this.findById(id); - await this.prisma.honeyBatch.update({ - where: { id }, - data: { ...dto }, + const movesContainer = + dto.storageContainerId !== undefined && + dto.storageContainerId !== before.storageContainerId; + + await this.prisma.$transaction(async (tx) => { + await tx.honeyBatch.update({ + where: { id }, + data: { ...dto }, + }); + // Correcting the container has to leave the same trace a deliberate + // relocation does - otherwise the batch's storage history would skip a + // container it was recorded in. + if (movesContainer) { + await tx.honeyBatchRelocation.create({ + data: { + honeyBatchId: id, + fromStorageContainerId: before.storageContainerId, + toStorageContainerId: dto.storageContainerId!, + userId: actingUserId, + notes: 'Korrektur über Honigcharge bearbeiten', + }, + }); + } }); const updated = await this.findById(id); await this.auditLog.record({ diff --git a/ApiCore-Backend/src/roles/dto/update-role.dto.ts b/ApiCore-Backend/src/roles/dto/update-role.dto.ts index 5309ec3..42ef38b 100644 --- a/ApiCore-Backend/src/roles/dto/update-role.dto.ts +++ b/ApiCore-Backend/src/roles/dto/update-role.dto.ts @@ -15,6 +15,18 @@ import { const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key); export class UpdateRoleDto { + /** + * Correctable like every other field. System roles are out of reach anyway: + * RolesService.update rejects them outright, which also keeps the seed's + * lookup of the ADMIN role by key intact. + */ + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(50) + key?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/ApiCore-Backend/src/roles/roles.service.ts b/ApiCore-Backend/src/roles/roles.service.ts index f82257c..b5f6abb 100644 --- a/ApiCore-Backend/src/roles/roles.service.ts +++ b/ApiCore-Backend/src/roles/roles.service.ts @@ -158,7 +158,7 @@ export class RolesService { const role = await this.prisma.$transaction(async (tx) => { await tx.role.update({ where: { id }, - data: { name: dto.name, description: dto.description }, + data: { key: dto.key, name: dto.name, description: dto.description }, }); if (dto.permissionKeys) { await this.replacePermissions(tx, id, dto.permissionKeys); diff --git a/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts b/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts index 9445e9f..3d7a302 100644 --- a/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts +++ b/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts @@ -1,12 +1,10 @@ -import { PartialType, PickType } from '@nestjs/swagger'; +import { PartialType } from '@nestjs/swagger'; import { CreateStockItemDto } from './create-stock-item.dto'; -export class UpdateStockItemDto extends PartialType( - PickType(CreateStockItemDto, [ - 'name', - 'category', - 'unit', - 'minThreshold', - 'notes', - ] as const), -) {} +/** + * `quantity` is editable here, not only through stock movements: a stock level + * typed in wrong has to be correctable directly. The service books the + * difference as an ADJUSTMENT movement, so the movement history still adds up + * to the stored quantity and nothing is corrected behind its back. + */ +export class UpdateStockItemDto extends PartialType(CreateStockItemDto) {} diff --git a/ApiCore-Backend/src/storage/storage.service.spec.ts b/ApiCore-Backend/src/storage/storage.service.spec.ts index d0946d2..a13e599 100644 --- a/ApiCore-Backend/src/storage/storage.service.spec.ts +++ b/ApiCore-Backend/src/storage/storage.service.spec.ts @@ -132,4 +132,28 @@ describe('StorageService', () => { ]; expect(updateArg.data.quantity.increment).toBe(-3); }); + + it('books the difference as an ADJUSTMENT movement when the stock quantity is corrected', async () => { + prisma.stockItem.findUnique.mockResolvedValue(stockItem); + + await service.updateStockItem('item-1', { quantity: 7 }, 'user-1'); + + expect(tx.stockMovement.create).toHaveBeenCalledWith({ + data: { + stockItemId: 'item-1', + type: 'ADJUSTMENT', + quantity: -3, + userId: 'user-1', + notes: 'Korrektur über Artikel bearbeiten', + }, + }); + }); + + it('books no movement when an edit leaves the stock quantity untouched', async () => { + prisma.stockItem.findUnique.mockResolvedValue(stockItem); + + await service.updateStockItem('item-1', { name: 'Futtersirup' }, 'user-1'); + + expect(tx.stockMovement.create).not.toHaveBeenCalled(); + }); }); diff --git a/ApiCore-Backend/src/storage/storage.service.ts b/ApiCore-Backend/src/storage/storage.service.ts index 2f3f812..0cfec3f 100644 --- a/ApiCore-Backend/src/storage/storage.service.ts +++ b/ApiCore-Backend/src/storage/storage.service.ts @@ -106,9 +106,29 @@ export class StorageService { actingUserId: string, ): Promise { const before = await this.findStockItemById(id); - const stockItem = await this.prisma.stockItem.update({ - where: { id }, - data: { ...dto }, + const correction = + dto.quantity === undefined ? 0 : dto.quantity - before.quantity; + + const stockItem = await this.prisma.$transaction(async (tx) => { + const updated = await tx.stockItem.update({ + where: { id }, + data: { ...dto }, + }); + // Booking the difference keeps the movement history consistent with the + // stored quantity - without it, a corrected stock level would no longer + // be the sum of its movements. + if (correction !== 0) { + await tx.stockMovement.create({ + data: { + stockItemId: id, + type: 'ADJUSTMENT', + quantity: correction, + userId: actingUserId, + notes: 'Korrektur über Artikel bearbeiten', + }, + }); + } + return updated; }); await this.auditLog.record({ entityType: 'StockItem', diff --git a/ApiCore-Backend/src/users/dto/reset-user-password.dto.ts b/ApiCore-Backend/src/users/dto/reset-user-password.dto.ts new file mode 100644 index 0000000..aac8fab --- /dev/null +++ b/ApiCore-Backend/src/users/dto/reset-user-password.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength } from 'class-validator'; + +/** + * An administrator setting a new password for someone else. There is no + * self-service password-reset flow, so without this a user who forgets their + * password is locked out of the account for good - the one correction the + * generic user update cannot make, since the password is never readable. + */ +export class ResetUserPasswordDto { + @ApiProperty({ minLength: 8 }) + @IsString() + @MinLength(8) + password!: string; +} diff --git a/ApiCore-Backend/src/users/dto/update-user.dto.ts b/ApiCore-Backend/src/users/dto/update-user.dto.ts index 9f236b5..b760703 100644 --- a/ApiCore-Backend/src/users/dto/update-user.dto.ts +++ b/ApiCore-Backend/src/users/dto/update-user.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; export class UpdateUserDto { @ApiPropertyOptional() @@ -7,6 +7,18 @@ export class UpdateUserDto { @IsString() name?: string; + /** + * The e-mail is the login identity, so changing it changes which address + * signs in as this account. It is still correctable: a typo would otherwise + * lock the account out for good, and there is no password-reset flow that a + * changed address could be abused to trigger. A duplicate is rejected by the + * unique constraint and surfaces as a 409. + */ + @ApiPropertyOptional() + @IsOptional() + @IsEmail() + email?: string; + @ApiPropertyOptional({ description: 'Id of an active Role' }) @IsOptional() @IsString() diff --git a/ApiCore-Backend/src/users/users.controller.ts b/ApiCore-Backend/src/users/users.controller.ts index 59e191d..7eaba24 100644 --- a/ApiCore-Backend/src/users/users.controller.ts +++ b/ApiCore-Backend/src/users/users.controller.ts @@ -5,6 +5,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; +import { ResetUserPasswordDto } from './dto/reset-user-password.dto'; import { UsersService } from './users.service'; @ApiTags('users') @@ -53,6 +54,16 @@ export class UsersController { return this.usersService.activate(id, actor.id); } + @RequirePermission('users:update') + @Post(':id/reset-password') + resetPassword( + @Param('id') id: string, + @Body() dto: ResetUserPasswordDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.usersService.resetPassword(id, dto, actor.id); + } + @RequirePermission('users:update') @Post(':id/mfa/reset') resetMfa(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { diff --git a/ApiCore-Backend/src/users/users.service.spec.ts b/ApiCore-Backend/src/users/users.service.spec.ts index 841bf7f..e73e938 100644 --- a/ApiCore-Backend/src/users/users.service.spec.ts +++ b/ApiCore-Backend/src/users/users.service.spec.ts @@ -21,6 +21,7 @@ describe('UsersService', () => { role: { findUnique: jest.Mock; }; + refreshToken: { updateMany: jest.Mock }; }; let auditLog: { record: jest.Mock }; let permissionResolver: { @@ -77,6 +78,7 @@ describe('UsersService', () => { role: { findUnique: jest.fn().mockResolvedValue(staffRole), }, + refreshToken: { updateMany: jest.fn() }, }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; permissionResolver = { @@ -379,4 +381,53 @@ describe('UsersService', () => { ).rejects.toBeInstanceOf(ForbiddenException); expect(prisma.user.update).not.toHaveBeenCalled(); }); + + describe('resetPassword', () => { + it('revokes every refresh token and clears the permission cache', async () => { + prisma.user.findUnique.mockResolvedValue(existingUser); + prisma.user.update.mockResolvedValue(existingUser); + + await usersService.resetPassword( + 'user-1', + { password: 'neuesPasswort1' }, + 'user-admin', + ); + + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', revokedAt: null }, + data: { revokedAt: expect.any(Date) as Date }, + }); + expect(permissionResolver.invalidateUser).toHaveBeenCalledWith('user-1'); + }); + + it('stores a hash, never the password itself', async () => { + prisma.user.findUnique.mockResolvedValue(existingUser); + prisma.user.update.mockResolvedValue(existingUser); + + await usersService.resetPassword( + 'user-1', + { password: 'neuesPasswort1' }, + 'user-admin', + ); + + const [[updateArgs]] = prisma.user.update.mock.calls as [ + [{ data: { passwordHash: string } }], + ]; + expect(updateArgs.data.passwordHash).not.toBe('neuesPasswort1'); + expect(updateArgs.data.passwordHash).toMatch(/^\$2[aby]\$/); + }); + + it('refuses to reset your own password, which belongs to /profile/password', async () => { + prisma.user.findUnique.mockResolvedValue(existingUser); + + await expect( + usersService.resetPassword( + 'user-1', + { password: 'neuesPasswort1' }, + 'user-1', + ), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/users/users.service.ts b/ApiCore-Backend/src/users/users.service.ts index c0f3665..9409ef5 100644 --- a/ApiCore-Backend/src/users/users.service.ts +++ b/ApiCore-Backend/src/users/users.service.ts @@ -11,6 +11,7 @@ import { PermissionResolverService } from '../auth/permissions/permission-resolv import { Prisma } from '../../generated/prisma'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; +import { ResetUserPasswordDto } from './dto/reset-user-password.dto'; const BCRYPT_SALT_ROUNDS = 12; @@ -194,6 +195,7 @@ export class UsersService { where: { id }, data: { name: dto.name, + email: dto.email, roleId: dto.roleId, }, ...USER_WITH_ROLE, @@ -240,6 +242,49 @@ export class UsersService { * a stolen access token alone (no password) would be enough to strip the * victim's own MFA protection via this admin route. */ + /** + * Sets a new password for another user. Mirrors ProfileService.changePassword + * in what it invalidates: every refresh token is revoked and the permission + * cache is cleared, so sessions opened with the old password stop working at + * once rather than living on until their access token expires. + * + * Your own password goes through /profile/password, which requires the + * current one - otherwise anyone holding users:update could silently take + * over their own account's credentials without proving they own it. + */ + async resetPassword( + id: string, + dto: ResetUserPasswordDto, + actingUserId?: string, + ): Promise { + if (actingUserId && id === actingUserId) { + throw new ForbiddenException( + 'Use /profile/password to change your own password', + ); + } + await this.findById(id); + const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS); + const user = await this.prisma.user.update({ + where: { id }, + data: { passwordHash, passwordChangedAt: new Date() }, + ...USER_WITH_ROLE, + }); + await this.prisma.refreshToken.updateMany({ + where: { userId: id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + this.permissionResolver.invalidateUser(id); + // The new password never reaches the audit log - only the fact that it + // was replaced, and by whom. + await this.auditLog.record({ + entityType: 'User', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + }); + return this.toSummary(user); + } + async resetMfa(id: string, actingUserId?: string): Promise { if (actingUserId && id === actingUserId) { throw new ForbiddenException( diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx index 14a6dad..b763be9 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx @@ -2,9 +2,10 @@ import { FormEvent, useState } from "react"; import { Layers } from "lucide-react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateColony } from "@/lib/colonies/api"; +import { fetchApiaries } from "@/lib/apiaries/api"; import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "@/lib/colonies/types"; import { COLONY_CONDITIONS, @@ -76,6 +77,7 @@ function ConditionSelect({ export function ColonyEditCard({ colony }: { colony: Colony }) { const queryClient = useQueryClient(); + const [apiaryId, setApiaryId] = useState(colony.apiaryId); const [code, setCode] = useState(colony.code); const [status, setStatus] = useState(colony.status); const [colonyType, setColonyType] = useState(colony.colonyType); @@ -93,9 +95,15 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { ); const [notes, setNotes] = useState(colony.notes ?? ""); + const { data: apiaries } = useQuery({ + queryKey: ["apiaries"], + queryFn: () => fetchApiaries(), + }); + const updateMutation = useMutation({ mutationFn: () => updateColony(colony.id, { + apiaryId, code, status, colonyType, @@ -110,8 +118,10 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["colonies"] }); void queryClient.invalidateQueries({ queryKey: ["colonies", colony.id] }); + void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); }, - onError: () => toast.error("Volk konnte nicht gespeichert werden"), + onError: (error: Error) => + toast.error(error.message || "Volk konnte nicht gespeichert werden"), }); function handleSubmit(event: FormEvent) { @@ -135,6 +145,28 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
+
+ + +

+ Eine Korrektur wird wie ein Umsetzen in der Standort-Historie + festgehalten. +

+
(honeyBatch.status); + const [storageContainerId, setStorageContainerId] = useState( + honeyBatch.storageContainerId ?? "", + ); const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? ""); const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? ""); const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? ""); @@ -41,6 +45,10 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) queryKey: ["extractions"], queryFn: () => fetchExtractions(), }); + const { data: containers } = useQuery({ + queryKey: ["storage-containers"], + queryFn: () => fetchStorageContainers(), + }); const mutation = useMutation({ mutationFn: () => @@ -48,6 +56,7 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) batchNumber, extractionId, status, + storageContainerId: storageContainerId || undefined, honeyType: honeyType || undefined, amount: amount ? Number(amount) : undefined, waterContent: waterContent ? Number(waterContent) : undefined, @@ -108,6 +117,29 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
+
+ + +

+ Eine Korrektur wird wie eine Umlagerung in der Lagerhistorie + festgehalten. +

+
setKey(event.target.value)} + /> +
void }) { + const queryClient = useQueryClient(); + const [name, setName] = useState(user.name); + const [email, setEmail] = useState(user.email); + const [roleId, setRoleId] = useState(user.roleId); + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(null); + + const { data: roles } = useQuery({ queryKey: ["roles"], queryFn: fetchRoles }); + // A deactivated role cannot be assigned, but the one already held stays + // listed so opening the dialog does not silently reassign the user. + const selectableRoles = (roles ?? []).filter( + (role) => role.status === "ACTIVE" || role.id === user.roleId, + ); + + const mutation = useMutation({ + mutationFn: async () => { + await updateUser(user.id, { name, email, roleId }); + // Only sent when actually filled in - an empty field means "leave the + // password alone", not "clear it". + if (newPassword) { + await resetUserPassword(user.id, newPassword); + } + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["users"] }); + onSaved(); + }, + onError: (mutationError: Error) => + setError( + mutationError.message || "Benutzer konnte nicht gespeichert werden.", + ), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate(); + } + + return ( + +
+ + setName(event.target.value)} + /> +
+
+ + setEmail(event.target.value)} + /> +

+ Zugleich die Anmeldeadresse - nach dem Speichern meldet sich der + Benutzer damit an. +

+
+
+ + +
+
+ + setNewPassword(event.target.value)} + /> +

+ Setzt das Passwort neu und meldet den Benutzer auf allen Geräten ab. + Das eigene Passwort wird stattdessen im eigenen Profil geändert. +

+
+ {error ? ( +

+ {error} +

+ ) : null} + + + ); +} + +export function EditUserDialog({ user }: { user: User }) { + const [open, setOpen] = useState(false); + + return ( + + + + Bearbeiten + + } + /> + + + Benutzer bearbeiten + + {open ? ( + setOpen(false)} /> + ) : null} + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/users-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/users-tab.tsx index 2319035..500e7b0 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/users-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/users-tab.tsx @@ -17,6 +17,7 @@ import { TableRow, } from "@/components/ui/table"; import { CreateUserDialog } from "./create-user-dialog"; +import { EditUserDialog } from "./edit-user-dialog"; export function UsersTab() { const queryClient = useQueryClient(); @@ -101,6 +102,7 @@ export function UsersTab() {
+ {user.mfaEnabled ? ( (item.category); const [unit, setUnit] = useState(item.unit); + const [quantity, setQuantity] = useState(item.quantity.toString()); const [minThreshold, setMinThreshold] = useState(item.minThreshold?.toString() ?? ""); const [notes, setNotes] = useState(item.notes ?? ""); @@ -42,11 +43,13 @@ export function EditStockItemDialog({ item }: { item: StockItem }) { name, category, unit, + quantity: Number(quantity), minThreshold: minThreshold ? Number(minThreshold) : undefined, notes: notes || undefined, }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["stock-items"] }); + void queryClient.invalidateQueries({ queryKey: ["stock-movements"] }); setOpen(false); }, onError: () => toast.error("Lagerartikel konnte nicht gespeichert werden"), @@ -108,6 +111,22 @@ export function EditStockItemDialog({ item }: { item: StockItem }) { onChange={(event) => setUnit(event.target.value)} />
+
+ + setQuantity(event.target.value)} + /> +

+ Eine Korrektur wird als Bestandskorrektur gebucht und bleibt in der + Bewegungshistorie sichtbar. +

+
{ return apiFetch("/users"); @@ -12,6 +12,23 @@ export function createUser(input: CreateUserInput): Promise { }); } +export function updateUser(id: string, input: UpdateUserInput): Promise { + return apiFetch(`/users/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export function resetUserPassword( + id: string, + password: string, +): Promise { + return apiFetch(`/users/${id}/reset-password`, { + method: "POST", + body: JSON.stringify({ password }), + }); +} + export function deactivateUser(id: string): Promise { return apiFetch(`/users/${id}/deactivate`, { method: "POST" }); } diff --git a/ApiCore-Frontend/src/lib/users/types.ts b/ApiCore-Frontend/src/lib/users/types.ts index 64e421a..0244dea 100644 --- a/ApiCore-Frontend/src/lib/users/types.ts +++ b/ApiCore-Frontend/src/lib/users/types.ts @@ -20,5 +20,6 @@ export interface CreateUserInput { export interface UpdateUserInput { name?: string; + email?: string; roleId?: string; }