feat: add delete user and change password functioality
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-22 23:19:56 +02:00
parent 48ad82acd1
commit 383ba32935
14 changed files with 477 additions and 82 deletions
+14 -2
View File
@@ -1,9 +1,11 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { Controller, Post, Body, HttpCode, HttpStatus, Req, UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@ApiTags('Authentication')
@Controller('auth')
@@ -32,4 +34,14 @@ export class AuthController {
await this.authService.logout(dto.refreshToken);
return { ok: true };
}
@Post('change-password')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: "Change the signed-in user's password; signs out their other sessions and returns new tokens" })
async changePassword(@Body() dto: ChangePasswordDto, @Req() req: any) {
return this.authService.changePassword(req.user.id, dto.currentPassword, dto.newPassword);
}
}
+90 -59
View File
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException, ConflictException, NotFoundException } from '@nestjs/common';
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
@@ -28,6 +28,69 @@ export class AuthService {
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
}
return this.issueTokens(user);
}
async refresh(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: hash } });
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
// Revoke first and atomically: of two requests with the same token, only one gets new tokens
const claimed = await this.prisma.refreshToken.updateMany({
where: { id: stored.id, revokedAt: null },
data: { revokedAt: new Date() },
});
if (claimed.count === 0) {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
const user = await this.prisma.clientUser.findUnique({ where: { id: stored.userId } });
if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
}
return this.issueTokens(user);
}
async logout(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
await this.prisma.refreshToken.updateMany({
where: { tokenHash: hash, revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Changes the signed-in user's password. Every session of the user is signed out, also this one, which gets new
* tokens instead.
*/
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
}
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
throw new BadRequestException({ code: 'wrong_password', message: 'The current password is not correct' });
}
const passwordHash = await bcrypt.hash(newPassword, 10);
await this.prisma.$transaction([
this.prisma.clientUser.update({ where: { id: user.id }, data: { passwordHash } }),
this.prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } }),
]);
await this.auditService.log(user.venueId, 'client_user', user.id, 'password_changed');
return this.issueTokens(user);
}
private async issueTokens(user: { id: string; email: string; name: string; role: string; venueId: string }) {
const payload = { sub: user.id, email: user.email, role: user.role, venueId: user.venueId };
const accessToken = this.jwtService.sign(payload);
@@ -56,64 +119,6 @@ export class AuthService {
};
}
async refresh(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: hash } });
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
// Revoke first and atomically: of two requests with the same token, only one gets new tokens
const claimed = await this.prisma.refreshToken.updateMany({
where: { id: stored.id, revokedAt: null },
data: { revokedAt: new Date() },
});
if (claimed.count === 0) {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
const user = await this.prisma.clientUser.findUnique({ where: { id: stored.userId } });
if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
}
const payload = { sub: user.id, email: user.email, role: user.role, venueId: user.venueId };
const accessToken = this.jwtService.sign(payload);
const newRefreshToken = crypto.randomBytes(32).toString('hex');
const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await this.prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: newHash,
expiresAt,
},
});
return {
accessToken,
refreshToken: newRefreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
venueId: user.venueId,
},
};
}
async logout(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
await this.prisma.refreshToken.updateMany({
where: { tokenHash: hash, revokedAt: null },
data: { revokedAt: new Date() },
});
}
async generateDeviceToken(): Promise<{ rawToken: string; hash: string; prefix: string; expiresAt: Date }> {
const rawBytes = crypto.randomBytes(TOKEN_BYTE_LENGTH);
const rawToken = 'dev_' + rawBytes.toString('base64url');
@@ -195,6 +200,32 @@ export class AuthService {
return user;
}
/**
* Deletes a dashboard user and signs them out: their refresh tokens are revoked, so they are out once their access
* token runs out. An admin cannot delete themself, so a venue always keeps an admin.
*/
async deleteUser(venueId: string, userId: string, actor: { id: string }) {
if (userId === actor.id) {
throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' });
}
const user = await this.prisma.clientUser.findFirst({ where: { id: userId, venueId } });
if (!user) {
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
}
await this.prisma.$transaction([
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
this.prisma.clientUser.delete({ where: { id: userId } }),
]);
await this.auditService.log(venueId, 'client_user', actor.id, 'user_deleted', { userId, email: user.email, role: user.role });
this.realtime.notify(venueId, 'users');
return { ok: true };
}
async validateDevice(deviceId: string, token: string): Promise<any> {
const hash = crypto.createHash('sha256').update(token).digest('hex');
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
@@ -0,0 +1,13 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChangePasswordDto {
@ApiProperty({ example: 'OldPass123!' })
@IsString()
currentPassword: string;
@ApiProperty({ example: 'NewPass123!' })
@IsString()
@MinLength(6)
newPassword: string;
}
+9 -4
View File
@@ -91,10 +91,7 @@ export class VenueTabs {
}
case 'tab_deleted':
if (this.tabs.delete(tabId)) {
this.changed.delete(tabId);
this.deleted.add(tabId);
}
this.remove(tabId);
return null;
case 'line_added': {
@@ -195,6 +192,14 @@ export class VenueTabs {
};
}
/** Deletes a tab, from a terminal or the dashboard. False when it is not open. */
remove(tabId: string): boolean {
if (!this.tabs.delete(tabId)) return false;
this.changed.delete(tabId);
this.deleted.add(tabId);
return true;
}
/** Opens a tab without an event: planned customers, and taking over a terminal's tabs. */
open(tabId: string, customerName: string, openedAt: Date): TabState {
const tab: TabState = { id: tabId, customerName, openedAt, version: 0, lines: [] };
+19 -4
View File
@@ -1,12 +1,14 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Delete, Get, Param, ParseIntPipe, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { TabsService } from './tabs.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@ApiTags('Tabs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Controller('venues/:venueId/tabs')
export class TabsController {
constructor(private tabsService: TabsService) {}
@@ -16,4 +18,17 @@ export class TabsController {
async findAll(@Param('venueId') venueId: string) {
return this.tabsService.findAll(venueId);
}
}
@Delete(':tabId')
@Roles('admin', 'manager')
@ApiOperation({ summary: 'Delete an open tab without settling it' })
@ApiQuery({ name: 'expectedVersion', required: false, description: 'Refuse when the tab changed since this version' })
async remove(
@Param('venueId') venueId: string,
@Param('tabId') tabId: string,
@Req() req: any,
@Query('expectedVersion', new ParseIntPipe({ optional: true })) expectedVersion?: number,
) {
return this.tabsService.remove(venueId, tabId, expectedVersion, req.user);
}
}
+50 -2
View File
@@ -1,11 +1,17 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { customerNameKey } from '../planned-tabs/customer-name';
import { tabSummary } from './tab-state';
import { VenueLedger } from './venue-ledger';
@Injectable()
export class TabsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
) {}
/** The venue's open tabs as the server holds them; every terminal shows the same. */
async findAll(venueId: string) {
@@ -30,4 +36,46 @@ export class TabsService {
}),
};
}
/**
* Deletes a tab without settling it, as tab_deleted does on a terminal: what is still on it is not paid and stays
* out of the stock. The terminals drop it on their next sync; one that still has unsent drinks for it brings it
* back, so no sale is lost. [expectedVersion] refuses a tab that changed since it was shown.
*/
async remove(venueId: string, tabId: string, expectedVersion: number | undefined, user: { id: string }) {
await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
const tab = ledger.tabs.get(tabId);
if (!tab) {
throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
}
if (expectedVersion !== undefined && tab.version !== expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const { customerName, totalInCents, items } = tabSummary({ ...tab, updatedAt: now });
ledger.tabs.remove(tabId);
await ledger.save(tx, now);
await tx.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType: 'client_user',
actorId: user.id,
action: 'tab_deleted',
details: JSON.stringify({ tabId, customerName, amountInCents: totalInCents, items }),
},
});
});
this.realtime.notify(venueId, 'tabs');
return { ok: true };
}
}
+14 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Param, UseGuards, Post, Body } from '@nestjs/common';
import { Controller, Get, Param, UseGuards, Post, Body, Delete, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { VenuesService } from './venues.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -58,6 +58,19 @@ export class VenuesController {
return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role);
}
@Delete(':venueId/users/:userId')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'Delete a user of the venue (not yourself)' })
async deleteUser(
@Param('venueId') venueId: string,
@Param('userId') userId: string,
@Req() req: any,
) {
return this.authService.deleteUser(venueId, userId, req.user);
}
@Get(':venueId/devices')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard)