diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 49fc645..e681525 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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); + } } \ No newline at end of file diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index f16d4bc..5656fc5 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { const hash = crypto.createHash('sha256').update(token).digest('hex'); const device = await this.prisma.device.findUnique({ where: { id: deviceId } }); diff --git a/apps/api/src/auth/dto/change-password.dto.ts b/apps/api/src/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..2f8998c --- /dev/null +++ b/apps/api/src/auth/dto/change-password.dto.ts @@ -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; +} diff --git a/apps/api/src/tabs/tab-state.ts b/apps/api/src/tabs/tab-state.ts index 235e26e..6ef19ad 100644 --- a/apps/api/src/tabs/tab-state.ts +++ b/apps/api/src/tabs/tab-state.ts @@ -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: [] }; diff --git a/apps/api/src/tabs/tabs.controller.ts b/apps/api/src/tabs/tabs.controller.ts index 58ecc82..df42818 100644 --- a/apps/api/src/tabs/tabs.controller.ts +++ b/apps/api/src/tabs/tabs.controller.ts @@ -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); } -} \ No newline at end of file + + @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); + } +} diff --git a/apps/api/src/tabs/tabs.service.ts b/apps/api/src/tabs/tabs.service.ts index 9025331..d91b85a 100644 --- a/apps/api/src/tabs/tabs.service.ts +++ b/apps/api/src/tabs/tabs.service.ts @@ -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 }; + } } diff --git a/apps/api/src/venues/venues.controller.ts b/apps/api/src/venues/venues.controller.ts index 857ebfc..bb7a16f 100644 --- a/apps/api/src/venues/venues.controller.ts +++ b/apps/api/src/venues/venues.controller.ts @@ -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) diff --git a/apps/dashboard/src/app/dashboard/account/page.tsx b/apps/dashboard/src/app/dashboard/account/page.tsx new file mode 100644 index 0000000..671b0ea --- /dev/null +++ b/apps/dashboard/src/app/dashboard/account/page.tsx @@ -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 ( +
+ + + + + {t('account.change_password')} + {t('account.change_password_desc')} + + +
+ {/* Lets password managers tie the new password to this account */} + +
+ + setForm({ ...form, current: e.target.value })} + /> +
+
+ + setForm({ ...form, next: e.target.value })} + placeholder={t('users.password_placeholder')} + aria-invalid={tooShort} + /> + {tooShort &&

{t('account.too_short')}

} +
+
+ + setForm({ ...form, confirm: e.target.value })} + aria-invalid={mismatch} + /> + {mismatch &&

{t('account.mismatch')}

} +
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx index dc76f89..d1b1003 100644 --- a/apps/dashboard/src/app/dashboard/layout.tsx +++ b/apps/dashboard/src/app/dashboard/layout.tsx @@ -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({ diff --git a/apps/dashboard/src/app/dashboard/tabs/page.tsx b/apps/dashboard/src/app/dashboard/tabs/page.tsx index cecac57..4c876d3 100644 --- a/apps/dashboard/src/app/dashboard/tabs/page.tsx +++ b/apps/dashboard/src/app/dashboard/tabs/page.tsx @@ -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(null); + const [deleteTab, setDeleteTab] = useState(null); const [paymentMethod, setPaymentMethod] = useState(''); const [expandedTabs, setExpandedTabs] = useState>(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() { {tab.itemCount} {relativeTime(tab.lastChangedAt)} - {closingTabIds.has(tab.tabId) ? ( - {t('tabs.closing')} - ) : ( - // Closing settles the tab on the terminal; a tab without a balance has nothing to settle - - )} +
+ {closingTabIds.has(tab.tabId) ? ( + {t('tabs.closing')} + ) : ( + // Closing settles the tab on the terminal; a tab without a balance has nothing to settle + + )} + {canManage && ( + + )} +
{expanded && ( @@ -338,6 +364,20 @@ export default function TabsPage() { onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })} /> + !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 && ( !open && !createCommandMutation.isPending && setCloseTab(null)}> diff --git a/apps/dashboard/src/app/dashboard/users/page.tsx b/apps/dashboard/src/app/dashboard/users/page.tsx index c1d09ef..a19d397 100644 --- a/apps/dashboard/src/app/dashboard/users/page.tsx +++ b/apps/dashboard/src/app/dashboard/users/page.tsx @@ -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(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 ; if (error) return ; @@ -83,6 +95,7 @@ export default function UsersPage() { {t('users.email')} {t('users.role')} {t('users.created')} + {role === 'admin' && {t('tabs.actions')}} @@ -92,6 +105,23 @@ export default function UsersPage() { {u.email} {t(`users.role.${u.role}`)} {relativeTime(u.createdAt)} + {role === 'admin' && ( + + {/* You can't delete yourself, so the venue always keeps an admin */} + {u.id !== userId && ( + + )} + + )} ))} @@ -99,6 +129,18 @@ export default function UsersPage() { )} + !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)} + /> + diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index f7f7a08..049c6d9 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -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 diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx index 34e2c2c..33ed355 100644 --- a/apps/dashboard/src/lib/i18n.tsx +++ b/apps/dashboard/src/lib/i18n.tsx @@ -11,6 +11,7 @@ const translations: Record> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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', diff --git a/docs/api.md b/docs/api.md index e5acc3c..33d319a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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