This commit is contained in:
@@ -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 { Throttle } from '@nestjs/throttler';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
|
||||||
@ApiTags('Authentication')
|
@ApiTags('Authentication')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -32,4 +34,14 @@ export class AuthController {
|
|||||||
await this.authService.logout(dto.refreshToken);
|
await this.authService.logout(dto.refreshToken);
|
||||||
return { ok: true };
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
@@ -28,6 +28,69 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
|
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 payload = { sub: user.id, email: user.email, role: user.role, venueId: user.venueId };
|
||||||
const accessToken = this.jwtService.sign(payload);
|
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 }> {
|
async generateDeviceToken(): Promise<{ rawToken: string; hash: string; prefix: string; expiresAt: Date }> {
|
||||||
const rawBytes = crypto.randomBytes(TOKEN_BYTE_LENGTH);
|
const rawBytes = crypto.randomBytes(TOKEN_BYTE_LENGTH);
|
||||||
const rawToken = 'dev_' + rawBytes.toString('base64url');
|
const rawToken = 'dev_' + rawBytes.toString('base64url');
|
||||||
@@ -195,6 +200,32 @@ export class AuthService {
|
|||||||
return user;
|
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> {
|
async validateDevice(deviceId: string, token: string): Promise<any> {
|
||||||
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
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;
|
||||||
|
}
|
||||||
@@ -91,10 +91,7 @@ export class VenueTabs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'tab_deleted':
|
case 'tab_deleted':
|
||||||
if (this.tabs.delete(tabId)) {
|
this.remove(tabId);
|
||||||
this.changed.delete(tabId);
|
|
||||||
this.deleted.add(tabId);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
case 'line_added': {
|
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. */
|
/** Opens a tab without an event: planned customers, and taking over a terminal's tabs. */
|
||||||
open(tabId: string, customerName: string, openedAt: Date): TabState {
|
open(tabId: string, customerName: string, openedAt: Date): TabState {
|
||||||
const tab: TabState = { id: tabId, customerName, openedAt, version: 0, lines: [] };
|
const tab: TabState = { id: tabId, customerName, openedAt, version: 0, lines: [] };
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Delete, Get, Param, ParseIntPipe, Query, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
import { TabsService } from './tabs.service';
|
import { TabsService } from './tabs.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { VenueAccessGuard } from '../common/guards/venue-access.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')
|
@ApiTags('Tabs')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||||
@Controller('venues/:venueId/tabs')
|
@Controller('venues/:venueId/tabs')
|
||||||
export class TabsController {
|
export class TabsController {
|
||||||
constructor(private tabsService: TabsService) {}
|
constructor(private tabsService: TabsService) {}
|
||||||
@@ -16,4 +18,17 @@ export class TabsController {
|
|||||||
async findAll(@Param('venueId') venueId: string) {
|
async findAll(@Param('venueId') venueId: string) {
|
||||||
return this.tabsService.findAll(venueId);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||||
import { tabSummary } from './tab-state';
|
import { tabSummary } from './tab-state';
|
||||||
|
import { VenueLedger } from './venue-ledger';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TabsService {
|
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. */
|
/** The venue's open tabs as the server holds them; every terminal shows the same. */
|
||||||
async findAll(venueId: string) {
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { VenuesService } from './venues.service';
|
import { VenuesService } from './venues.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
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);
|
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')
|
@Get(':venueId/devices')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { authApi } from '@/lib/api/client';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { toast } from '@/components/ui/toast';
|
||||||
|
import { useLang } from '@/lib/i18n';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 6;
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const { email, role } = useSession();
|
||||||
|
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const { t, te } = useLang();
|
||||||
|
|
||||||
|
const tooShort = form.next.length > 0 && form.next.length < MIN_PASSWORD_LENGTH;
|
||||||
|
const mismatch = form.confirm.length > 0 && form.next !== form.confirm;
|
||||||
|
const canSubmit = form.current.length > 0 && form.next.length >= MIN_PASSWORD_LENGTH && form.next === form.confirm;
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await authApi.changePassword(form.current, form.next);
|
||||||
|
toast({ title: t('account.password_changed'), description: t('account.password_changed_desc'), variant: 'success' });
|
||||||
|
setForm({ current: '', next: '', confirm: '' });
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: t('account.password_failed'), description: te(err), variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title={t('account.title')} description={`${email} · ${t(`users.role.${role}`)}`} />
|
||||||
|
|
||||||
|
<Card className="max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('account.change_password')}</CardTitle>
|
||||||
|
<CardDescription>{t('account.change_password_desc')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Lets password managers tie the new password to this account */}
|
||||||
|
<input type="email" name="username" autoComplete="username" value={email} readOnly hidden />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="current-password">{t('account.current_password')}</Label>
|
||||||
|
<Input
|
||||||
|
id="current-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={form.current}
|
||||||
|
onChange={(e) => setForm({ ...form, current: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-password">{t('account.new_password')}</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.next}
|
||||||
|
onChange={(e) => setForm({ ...form, next: e.target.value })}
|
||||||
|
placeholder={t('users.password_placeholder')}
|
||||||
|
aria-invalid={tooShort}
|
||||||
|
/>
|
||||||
|
{tooShort && <p className="text-xs text-destructive">{t('account.too_short')}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-password">{t('account.confirm_password')}</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.confirm}
|
||||||
|
onChange={(e) => setForm({ ...form, confirm: e.target.value })}
|
||||||
|
aria-invalid={mismatch}
|
||||||
|
/>
|
||||||
|
{mismatch && <p className="text-xs text-destructive">{t('account.mismatch')}</p>}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={submitting || !canSubmit}>
|
||||||
|
{submitting ? t('account.saving') : t('account.save')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Bell,
|
Bell,
|
||||||
Package,
|
Package,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
@@ -33,6 +34,7 @@ const navItems = [
|
|||||||
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
|
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
|
||||||
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
|
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
|
||||||
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
|
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
|
||||||
|
{ href: '/dashboard/account', key: 'nav.account', icon: UserCog, adminOnly: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { LoadingState } from '@/components/loading-state';
|
|||||||
import { ErrorState } from '@/components/error-state';
|
import { ErrorState } from '@/components/error-state';
|
||||||
import { toast } from '@/components/ui/toast';
|
import { toast } from '@/components/ui/toast';
|
||||||
import { formatCents } from '@/lib/utils';
|
import { formatCents } from '@/lib/utils';
|
||||||
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown, Search } from 'lucide-react';
|
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown, Search, Trash2 } from 'lucide-react';
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
||||||
import { useLang } from '@/lib/i18n';
|
import { useLang } from '@/lib/i18n';
|
||||||
import { useRealtime } from '@/lib/realtime';
|
import { useRealtime } from '@/lib/realtime';
|
||||||
@@ -39,6 +40,7 @@ export default function TabsPage() {
|
|||||||
const canManage = role === 'admin' || role === 'manager';
|
const canManage = role === 'admin' || role === 'manager';
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [closeTab, setCloseTab] = useState<Tab | null>(null);
|
const [closeTab, setCloseTab] = useState<Tab | null>(null);
|
||||||
|
const [deleteTab, setDeleteTab] = useState<Tab | null>(null);
|
||||||
const [paymentMethod, setPaymentMethod] = useState('');
|
const [paymentMethod, setPaymentMethod] = useState('');
|
||||||
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
|
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -92,6 +94,16 @@ export default function TabsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteTabMutation = useMutation({
|
||||||
|
mutationFn: (tab: Tab) => tabsApi.remove(venueId, tab.tabId, tab.version),
|
||||||
|
onSuccess: (_, tab) => toast({ title: t('tabs.deleted', { name: tab.customerName }), variant: 'success' }),
|
||||||
|
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||||
|
onSettled: () => {
|
||||||
|
setDeleteTab(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tabs'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function openCloseDialog(tab: Tab) {
|
function openCloseDialog(tab: Tab) {
|
||||||
setPaymentMethod('');
|
setPaymentMethod('');
|
||||||
setCloseTab(tab);
|
setCloseTab(tab);
|
||||||
@@ -304,12 +316,26 @@ export default function TabsPage() {
|
|||||||
<TableCell>{tab.itemCount}</TableCell>
|
<TableCell>{tab.itemCount}</TableCell>
|
||||||
<TableCell>{relativeTime(tab.lastChangedAt)}</TableCell>
|
<TableCell>{relativeTime(tab.lastChangedAt)}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{closingTabIds.has(tab.tabId) ? (
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Badge variant="warning">{t('tabs.closing')}</Badge>
|
{closingTabIds.has(tab.tabId) ? (
|
||||||
) : (
|
<Badge variant="warning">{t('tabs.closing')}</Badge>
|
||||||
// Closing settles the tab on the terminal; a tab without a balance has nothing to settle
|
) : (
|
||||||
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => openCloseDialog(tab)} aria-label={t('tabs.close_label', { name: tab.customerName })}>{t('tabs.close')}</Button>
|
// Closing settles the tab on the terminal; a tab without a balance has nothing to settle
|
||||||
)}
|
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => openCloseDialog(tab)} aria-label={t('tabs.close_label', { name: tab.customerName })}>{t('tabs.close')}</Button>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setDeleteTab(tab)}
|
||||||
|
title={t('tabs.delete_label', { name: tab.customerName })}
|
||||||
|
aria-label={t('tabs.delete_label', { name: tab.customerName })}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@@ -338,6 +364,20 @@ export default function TabsPage() {
|
|||||||
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
|
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTab !== null}
|
||||||
|
onOpenChange={(open) => !open && !deleteTabMutation.isPending && setDeleteTab(null)}
|
||||||
|
title={t('tabs.delete_title', { name: deleteTab?.customerName ?? '' })}
|
||||||
|
description={deleteTab && deleteTab.totalInCents > 0
|
||||||
|
? t('tabs.delete_desc_unpaid', { amount: formatCents(deleteTab.totalInCents) })
|
||||||
|
: t('tabs.delete_desc')}
|
||||||
|
confirmLabel={t('tabs.delete')}
|
||||||
|
cancelLabel={t('common.cancel')}
|
||||||
|
variant="destructive"
|
||||||
|
loading={deleteTabMutation.isPending}
|
||||||
|
onConfirm={() => deleteTab && deleteTabMutation.mutate(deleteTab)}
|
||||||
|
/>
|
||||||
|
|
||||||
{closeTab && (
|
{closeTab && (
|
||||||
<Dialog open onOpenChange={(open) => !open && !createCommandMutation.isPending && setCloseTab(null)}>
|
<Dialog open onOpenChange={(open) => !open && !createCommandMutation.isPending && setCloseTab(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { usersApi } from '@/lib/api/client';
|
import { usersApi, type User } from '@/lib/api/client';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -17,12 +17,14 @@ import { ErrorState } from '@/components/error-state';
|
|||||||
import { toast } from '@/components/ui/toast';
|
import { toast } from '@/components/ui/toast';
|
||||||
import { useLang } from '@/lib/i18n';
|
import { useLang } from '@/lib/i18n';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { RefreshCw, Plus } from 'lucide-react';
|
import { RefreshCw, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { venueId, role } = useSession();
|
const { venueId, role, userId } = useSession();
|
||||||
const [addDialog, setAddDialog] = useState(false);
|
const [addDialog, setAddDialog] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<User | null>(null);
|
||||||
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
|
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const { t, tn, te, relativeTime } = useLang();
|
const { t, tn, te, relativeTime } = useLang();
|
||||||
@@ -50,6 +52,16 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (user: User) => usersApi.remove(venueId, user.id),
|
||||||
|
onSuccess: (_, user) => toast({ title: t('users.deleted', { email: user.email }), variant: 'success' }),
|
||||||
|
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||||
|
onSettled: () => {
|
||||||
|
setDeleting(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) return <LoadingState />;
|
if (isLoading) return <LoadingState />;
|
||||||
if (error) return <ErrorState message={te(error, 'users.load_failed')} />;
|
if (error) return <ErrorState message={te(error, 'users.load_failed')} />;
|
||||||
|
|
||||||
@@ -83,6 +95,7 @@ export default function UsersPage() {
|
|||||||
<TableHead>{t('users.email')}</TableHead>
|
<TableHead>{t('users.email')}</TableHead>
|
||||||
<TableHead>{t('users.role')}</TableHead>
|
<TableHead>{t('users.role')}</TableHead>
|
||||||
<TableHead>{t('users.created')}</TableHead>
|
<TableHead>{t('users.created')}</TableHead>
|
||||||
|
{role === 'admin' && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -92,6 +105,23 @@ export default function UsersPage() {
|
|||||||
<TableCell>{u.email}</TableCell>
|
<TableCell>{u.email}</TableCell>
|
||||||
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
|
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
|
||||||
<TableCell>{relativeTime(u.createdAt)}</TableCell>
|
<TableCell>{relativeTime(u.createdAt)}</TableCell>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{/* You can't delete yourself, so the venue always keeps an admin */}
|
||||||
|
{u.id !== userId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setDeleting(u)}
|
||||||
|
title={t('users.delete_label', { name: u.name })}
|
||||||
|
aria-label={t('users.delete_label', { name: u.name })}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -99,6 +129,18 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleting !== null}
|
||||||
|
onOpenChange={(open) => !open && !deleteMutation.isPending && setDeleting(null)}
|
||||||
|
title={t('users.delete_title', { name: deleting?.name ?? '' })}
|
||||||
|
description={t('users.delete_desc', { email: deleting?.email ?? '' })}
|
||||||
|
confirmLabel={t('users.delete')}
|
||||||
|
cancelLabel={t('common.cancel')}
|
||||||
|
variant="destructive"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleting && deleteMutation.mutate(deleting)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={addDialog} onOpenChange={setAddDialog}>
|
<Dialog open={addDialog} onOpenChange={setAddDialog}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -262,12 +262,24 @@ export const authApi = {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Signs out every other session; this one carries on with the new tokens */
|
||||||
|
changePassword: async (currentPassword: string, newPassword: string) => {
|
||||||
|
const result = await api<{ accessToken: string; refreshToken: string }>('/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
setTokens(result.accessToken, result.refreshToken);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tabs API
|
// Tabs API
|
||||||
export const tabsApi = {
|
export const tabsApi = {
|
||||||
list: (venueId: string) =>
|
list: (venueId: string) =>
|
||||||
api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`),
|
api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`),
|
||||||
|
/** Deletes the tab without settling it; refused when it changed since [expectedVersion] */
|
||||||
|
remove: (venueId: string, tabId: string, expectedVersion: number) =>
|
||||||
|
api(`/venues/${venueId}/tabs/${encodeURIComponent(tabId)}?expectedVersion=${expectedVersion}`, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Devices API
|
// Devices API
|
||||||
@@ -285,6 +297,8 @@ export const usersApi = {
|
|||||||
api<{ users: User[] }>(`/venues/${venueId}/users`),
|
api<{ users: User[] }>(`/venues/${venueId}/users`),
|
||||||
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
|
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
|
||||||
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
|
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
remove: (venueId: string, userId: string) =>
|
||||||
|
api(`/venues/${venueId}/users/${userId}`, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Commands API
|
// Commands API
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'nav.users': 'Users',
|
'nav.users': 'Users',
|
||||||
'nav.commands': 'Command History',
|
'nav.commands': 'Command History',
|
||||||
'nav.settings': 'Settings',
|
'nav.settings': 'Settings',
|
||||||
|
'nav.account': 'My Account',
|
||||||
'nav.logout': 'Logout',
|
'nav.logout': 'Logout',
|
||||||
'realtime.live': 'Live updates on',
|
'realtime.live': 'Live updates on',
|
||||||
'realtime.offline': 'Live updates unavailable — refreshing periodically',
|
'realtime.offline': 'Live updates unavailable — refreshing periodically',
|
||||||
@@ -38,6 +39,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'errors.reminders_in_progress': 'Reminders are already being sent',
|
'errors.reminders_in_progress': 'Reminders are already being sent',
|
||||||
'errors.no_email': 'This customer has no email address',
|
'errors.no_email': 'This customer has no email address',
|
||||||
'errors.forbidden': "You don't have permission to do this",
|
'errors.forbidden': "You don't have permission to do this",
|
||||||
|
'errors.wrong_password': 'The current password is not correct',
|
||||||
|
'errors.cannot_delete_self': 'You cannot delete your own account',
|
||||||
|
'errors.user_not_found': 'This user no longer exists',
|
||||||
'errors.venue_forbidden': "You don't have permission to do this",
|
'errors.venue_forbidden': "You don't have permission to do this",
|
||||||
'errors.internal_error': 'Something went wrong on the server',
|
'errors.internal_error': 'Something went wrong on the server',
|
||||||
'errors.network_error': "Can't reach the server. Check the connection.",
|
'errors.network_error': "Can't reach the server. Check the connection.",
|
||||||
@@ -98,6 +102,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'tabs.send': 'Settle tab',
|
'tabs.send': 'Settle tab',
|
||||||
'tabs.sending': 'Settling…',
|
'tabs.sending': 'Settling…',
|
||||||
'tabs.close_label': "Close {name}'s tab",
|
'tabs.close_label': "Close {name}'s tab",
|
||||||
|
'tabs.delete': 'Delete',
|
||||||
|
'tabs.delete_label': "Delete {name}'s tab",
|
||||||
|
'tabs.delete_title': "Delete {name}'s tab?",
|
||||||
|
'tabs.delete_desc': 'The tab is removed from every terminal on its next sync.',
|
||||||
|
'tabs.delete_desc_unpaid': 'There is still {amount} on this tab. It is removed without being paid, from every terminal on its next sync. To record a payment, close the tab instead.',
|
||||||
|
'tabs.deleted': "{name}'s tab is deleted",
|
||||||
'tabs.settled': 'Tab settled',
|
'tabs.settled': 'Tab settled',
|
||||||
'tabs.settled_desc': '{name}: {amount}',
|
'tabs.settled_desc': '{name}: {amount}',
|
||||||
'tabs.send_test': 'Send test email',
|
'tabs.send_test': 'Send test email',
|
||||||
@@ -160,6 +170,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'users.created_toast': 'User created',
|
'users.created_toast': 'User created',
|
||||||
'users.created_toast_desc': '{email} added as {role}',
|
'users.created_toast_desc': '{email} added as {role}',
|
||||||
'users.create_failed': 'Failed to create user',
|
'users.create_failed': 'Failed to create user',
|
||||||
|
'users.delete': 'Delete',
|
||||||
|
'users.delete_label': 'Delete {name}',
|
||||||
|
'users.delete_title': 'Delete {name}?',
|
||||||
|
'users.delete_desc': '{email} can no longer sign in to the dashboard and is signed out within a few minutes.',
|
||||||
|
'users.deleted': '{email} is deleted',
|
||||||
'commands.title': 'Command History',
|
'commands.title': 'Command History',
|
||||||
'commands.count': '{count} command',
|
'commands.count': '{count} command',
|
||||||
'commands.count_plural': '{count} commands',
|
'commands.count_plural': '{count} commands',
|
||||||
@@ -207,6 +222,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'settings.pair_step5': 'Complete registration',
|
'settings.pair_step5': 'Complete registration',
|
||||||
'settings.copy': 'Copy Code',
|
'settings.copy': 'Copy Code',
|
||||||
'settings.copied_desc': 'Registration code copied to clipboard',
|
'settings.copied_desc': 'Registration code copied to clipboard',
|
||||||
|
'account.title': 'My Account',
|
||||||
|
'account.change_password': 'Change Password',
|
||||||
|
'account.change_password_desc': 'Your other sessions are signed out; this one stays signed in.',
|
||||||
|
'account.current_password': 'Current password',
|
||||||
|
'account.new_password': 'New password',
|
||||||
|
'account.confirm_password': 'Repeat new password',
|
||||||
|
'account.too_short': 'Use at least 6 characters',
|
||||||
|
'account.mismatch': "The passwords don't match",
|
||||||
|
'account.save': 'Change Password',
|
||||||
|
'account.saving': 'Saving...',
|
||||||
|
'account.password_changed': 'Password changed',
|
||||||
|
'account.password_changed_desc': 'Use your new password the next time you sign in.',
|
||||||
|
'account.password_failed': 'Failed to change password',
|
||||||
'overview.title': 'Overview',
|
'overview.title': 'Overview',
|
||||||
'overview.open_tabs': 'Open Tabs',
|
'overview.open_tabs': 'Open Tabs',
|
||||||
'overview.device': 'Terminal',
|
'overview.device': 'Terminal',
|
||||||
@@ -372,6 +400,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'nav.users': 'Gebruikers',
|
'nav.users': 'Gebruikers',
|
||||||
'nav.commands': 'Opdrachten',
|
'nav.commands': 'Opdrachten',
|
||||||
'nav.settings': 'Instellingen',
|
'nav.settings': 'Instellingen',
|
||||||
|
'nav.account': 'Mijn account',
|
||||||
'nav.logout': 'Uitloggen',
|
'nav.logout': 'Uitloggen',
|
||||||
'realtime.live': 'Live updates aan',
|
'realtime.live': 'Live updates aan',
|
||||||
'realtime.offline': 'Live updates niet beschikbaar — wordt regelmatig vernieuwd',
|
'realtime.offline': 'Live updates niet beschikbaar — wordt regelmatig vernieuwd',
|
||||||
@@ -399,6 +428,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'errors.reminders_in_progress': 'Herinneringen worden al verstuurd',
|
'errors.reminders_in_progress': 'Herinneringen worden al verstuurd',
|
||||||
'errors.no_email': 'Deze klant heeft geen e-mailadres',
|
'errors.no_email': 'Deze klant heeft geen e-mailadres',
|
||||||
'errors.forbidden': 'Je hebt geen toestemming om dit te doen',
|
'errors.forbidden': 'Je hebt geen toestemming om dit te doen',
|
||||||
|
'errors.wrong_password': 'Het huidige wachtwoord klopt niet',
|
||||||
|
'errors.cannot_delete_self': 'Je kan je eigen account niet verwijderen',
|
||||||
|
'errors.user_not_found': 'Deze gebruiker bestaat niet meer',
|
||||||
'errors.venue_forbidden': 'Je hebt geen toestemming om dit te doen',
|
'errors.venue_forbidden': 'Je hebt geen toestemming om dit te doen',
|
||||||
'errors.internal_error': 'Er ging iets mis op de server',
|
'errors.internal_error': 'Er ging iets mis op de server',
|
||||||
'errors.network_error': 'Kan de server niet bereiken. Controleer de verbinding.',
|
'errors.network_error': 'Kan de server niet bereiken. Controleer de verbinding.',
|
||||||
@@ -459,6 +491,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'tabs.send': 'Poef afrekenen',
|
'tabs.send': 'Poef afrekenen',
|
||||||
'tabs.sending': 'Bezig met afrekenen…',
|
'tabs.sending': 'Bezig met afrekenen…',
|
||||||
'tabs.close_label': 'Poef van {name} sluiten',
|
'tabs.close_label': 'Poef van {name} sluiten',
|
||||||
|
'tabs.delete': 'Verwijderen',
|
||||||
|
'tabs.delete_label': 'Poef van {name} verwijderen',
|
||||||
|
'tabs.delete_title': 'Poef van {name} verwijderen?',
|
||||||
|
'tabs.delete_desc': 'De poef verdwijnt van elke terminal bij de volgende synchronisatie.',
|
||||||
|
'tabs.delete_desc_unpaid': 'Er staat nog {amount} op deze poef. Ze wordt verwijderd zonder betaling, van elke terminal bij de volgende synchronisatie. Wil je een betaling registreren, sluit de poef dan.',
|
||||||
|
'tabs.deleted': 'Poef van {name} is verwijderd',
|
||||||
'tabs.settled': 'Poef afgerekend',
|
'tabs.settled': 'Poef afgerekend',
|
||||||
'tabs.settled_desc': '{name}: {amount}',
|
'tabs.settled_desc': '{name}: {amount}',
|
||||||
'tabs.send_test': 'Testmail versturen',
|
'tabs.send_test': 'Testmail versturen',
|
||||||
@@ -521,6 +559,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'users.created_toast': 'Gebruiker aangemaakt',
|
'users.created_toast': 'Gebruiker aangemaakt',
|
||||||
'users.created_toast_desc': '{email} toegevoegd als {role}',
|
'users.created_toast_desc': '{email} toegevoegd als {role}',
|
||||||
'users.create_failed': 'Gebruiker aanmaken mislukt',
|
'users.create_failed': 'Gebruiker aanmaken mislukt',
|
||||||
|
'users.delete': 'Verwijderen',
|
||||||
|
'users.delete_label': '{name} verwijderen',
|
||||||
|
'users.delete_title': '{name} verwijderen?',
|
||||||
|
'users.delete_desc': '{email} kan niet meer inloggen op het dashboard en wordt binnen enkele minuten uitgelogd.',
|
||||||
|
'users.deleted': '{email} is verwijderd',
|
||||||
'commands.title': 'Opdrachtgeschiedenis',
|
'commands.title': 'Opdrachtgeschiedenis',
|
||||||
'commands.count': '{count} opdracht',
|
'commands.count': '{count} opdracht',
|
||||||
'commands.count_plural': '{count} opdrachten',
|
'commands.count_plural': '{count} opdrachten',
|
||||||
@@ -568,6 +611,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
'settings.pair_step5': 'Rond de registratie af',
|
'settings.pair_step5': 'Rond de registratie af',
|
||||||
'settings.copy': 'Code kopiëren',
|
'settings.copy': 'Code kopiëren',
|
||||||
'settings.copied_desc': 'Registratiecode gekopieerd naar het klembord',
|
'settings.copied_desc': 'Registratiecode gekopieerd naar het klembord',
|
||||||
|
'account.title': 'Mijn account',
|
||||||
|
'account.change_password': 'Wachtwoord wijzigen',
|
||||||
|
'account.change_password_desc': 'Je andere sessies worden uitgelogd; deze blijft ingelogd.',
|
||||||
|
'account.current_password': 'Huidig wachtwoord',
|
||||||
|
'account.new_password': 'Nieuw wachtwoord',
|
||||||
|
'account.confirm_password': 'Herhaal nieuw wachtwoord',
|
||||||
|
'account.too_short': 'Gebruik minstens 6 tekens',
|
||||||
|
'account.mismatch': 'De wachtwoorden komen niet overeen',
|
||||||
|
'account.save': 'Wachtwoord wijzigen',
|
||||||
|
'account.saving': 'Opslaan...',
|
||||||
|
'account.password_changed': 'Wachtwoord gewijzigd',
|
||||||
|
'account.password_changed_desc': 'Gebruik je nieuwe wachtwoord de volgende keer dat je inlogt.',
|
||||||
|
'account.password_failed': 'Wachtwoord wijzigen mislukt',
|
||||||
'overview.title': 'Overzicht',
|
'overview.title': 'Overzicht',
|
||||||
'overview.open_tabs': 'Open poefs',
|
'overview.open_tabs': 'Open poefs',
|
||||||
'overview.device': 'Terminal',
|
'overview.device': 'Terminal',
|
||||||
|
|||||||
@@ -24,11 +24,16 @@ Swagger UI: `http://localhost:3001/api/docs`
|
|||||||
`POST /auth/login` - Login with email/password
|
`POST /auth/login` - Login with email/password
|
||||||
`POST /auth/refresh` - Refresh access token
|
`POST /auth/refresh` - Refresh access token
|
||||||
`POST /auth/logout` - Logout and revoke refresh token
|
`POST /auth/logout` - Logout and revoke refresh token
|
||||||
|
`POST /auth/change-password` - Change the signed-in user's password `{ currentPassword, newPassword }`. Revokes all
|
||||||
|
their refresh tokens and returns new tokens, like a login. `400 wrong_password` when the current one is wrong.
|
||||||
|
|
||||||
### Venues
|
### Venues
|
||||||
`GET /venues` - List venues
|
`GET /venues` - List venues
|
||||||
`GET /venues/:venueId` - Get venue details
|
`GET /venues/:venueId` - Get venue details
|
||||||
`GET /venues/:venueId/devices` - The venue's paired terminals `{ devices: [...] }`
|
`GET /venues/:venueId/devices` - The venue's paired terminals `{ devices: [...] }`
|
||||||
|
`GET /venues/:venueId/users` - List dashboard users (admin)
|
||||||
|
`POST /venues/:venueId/users` - Create a user (admin)
|
||||||
|
`DELETE /venues/:venueId/users/:userId` - Delete a user and revoke their refresh tokens (admin; not yourself)
|
||||||
|
|
||||||
### Devices
|
### Devices
|
||||||
`POST /devices/register` - Pair a terminal; every pairing adds one
|
`POST /devices/register` - Pair a terminal; every pairing adds one
|
||||||
@@ -84,6 +89,8 @@ and `docs/server-stock-source-of-truth.md`.
|
|||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
`GET /venues/:venueId/tabs` - List open tabs
|
`GET /venues/:venueId/tabs` - List open tabs
|
||||||
|
`DELETE /venues/:venueId/tabs/:tabId?expectedVersion=` - Delete a tab without settling it, like `tab_deleted` on a
|
||||||
|
terminal (admins and managers). `409 version_mismatch` when it changed since `expectedVersion`.
|
||||||
|
|
||||||
### Planned Tabs (customer list)
|
### Planned Tabs (customer list)
|
||||||
`GET /venues/:venueId/planned-tabs` - List planned customers
|
`GET /venues/:venueId/planned-tabs` - List planned customers
|
||||||
|
|||||||
Reference in New Issue
Block a user