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: [] };
+18 -3
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)
@@ -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,
Bell,
Package,
UserCog,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
@@ -33,6 +34,7 @@ const navItems = [
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, 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({
+47 -7
View File
@@ -18,7 +18,8 @@ import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
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 { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
@@ -39,6 +40,7 @@ export default function TabsPage() {
const canManage = role === 'admin' || role === 'manager';
const queryClient = useQueryClient();
const [closeTab, setCloseTab] = useState<Tab | null>(null);
const [deleteTab, setDeleteTab] = useState<Tab | null>(null);
const [paymentMethod, setPaymentMethod] = useState('');
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
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) {
setPaymentMethod('');
setCloseTab(tab);
@@ -304,12 +316,26 @@ export default function TabsPage() {
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{relativeTime(tab.lastChangedAt)}</TableCell>
<TableCell className="text-right">
{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>
)}
<div className="flex items-center justify-end gap-1">
{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>
)}
{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>
</TableRow>
{expanded && (
@@ -338,6 +364,20 @@ export default function TabsPage() {
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 && (
<Dialog open onOpenChange={(open) => !open && !createCommandMutation.isPending && setCloseTab(null)}>
<DialogContent>
@@ -2,7 +2,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
@@ -17,12 +17,14 @@ import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
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() {
const queryClient = useQueryClient();
const { venueId, role } = useSession();
const { venueId, role, userId } = useSession();
const [addDialog, setAddDialog] = useState(false);
const [deleting, setDeleting] = useState<User | null>(null);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
const [submitting, setSubmitting] = useState(false);
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 (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.role')}</TableHead>
<TableHead>{t('users.created')}</TableHead>
{role === 'admin' && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@@ -92,6 +105,23 @@ export default function UsersPage() {
<TableCell>{u.email}</TableCell>
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{t(`users.role.${u.role}`)}</Badge></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>
))}
</TableBody>
@@ -99,6 +129,18 @@ export default function UsersPage() {
</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}>
<DialogContent>
<DialogHeader>
+14
View File
@@ -262,12 +262,24 @@ export const authApi = {
}).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
export const tabsApi = {
list: (venueId: string) =>
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
@@ -285,6 +297,8 @@ export const usersApi = {
api<{ users: User[] }>(`/venues/${venueId}/users`),
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
remove: (venueId: string, userId: string) =>
api(`/venues/${venueId}/users/${userId}`, { method: 'DELETE' }),
};
// Commands API
+56
View File
@@ -11,6 +11,7 @@ const translations: Record<Lang, Record<string, string>> = {
'nav.users': 'Users',
'nav.commands': 'Command History',
'nav.settings': 'Settings',
'nav.account': 'My Account',
'nav.logout': 'Logout',
'realtime.live': 'Live updates on',
'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.no_email': 'This customer has no email address',
'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.internal_error': 'Something went wrong on the server',
'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.sending': 'Settling…',
'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_desc': '{name}: {amount}',
'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_desc': '{email} added as {role}',
'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.count': '{count} command',
'commands.count_plural': '{count} commands',
@@ -207,6 +222,19 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.pair_step5': 'Complete registration',
'settings.copy': 'Copy Code',
'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.open_tabs': 'Open Tabs',
'overview.device': 'Terminal',
@@ -372,6 +400,7 @@ const translations: Record<Lang, Record<string, string>> = {
'nav.users': 'Gebruikers',
'nav.commands': 'Opdrachten',
'nav.settings': 'Instellingen',
'nav.account': 'Mijn account',
'nav.logout': 'Uitloggen',
'realtime.live': 'Live updates aan',
'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.no_email': 'Deze klant heeft geen e-mailadres',
'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.internal_error': 'Er ging iets mis op de server',
'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.sending': 'Bezig met afrekenen…',
'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_desc': '{name}: {amount}',
'tabs.send_test': 'Testmail versturen',
@@ -521,6 +559,11 @@ const translations: Record<Lang, Record<string, string>> = {
'users.created_toast': 'Gebruiker aangemaakt',
'users.created_toast_desc': '{email} toegevoegd als {role}',
'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.count': '{count} opdracht',
'commands.count_plural': '{count} opdrachten',
@@ -568,6 +611,19 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.pair_step5': 'Rond de registratie af',
'settings.copy': 'Code kopiëren',
'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.open_tabs': 'Open poefs',
'overview.device': 'Terminal',
+7
View File
@@ -24,11 +24,16 @@ Swagger UI: `http://localhost:3001/api/docs`
`POST /auth/login` - Login with email/password
`POST /auth/refresh` - Refresh access 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
`GET /venues` - List venues
`GET /venues/:venueId` - Get venue details
`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
`POST /devices/register` - Pair a terminal; every pairing adds one
@@ -84,6 +89,8 @@ and `docs/server-stock-source-of-truth.md`.
### 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)
`GET /venues/:venueId/planned-tabs` - List planned customers