From 4e43521da7eb35842f44200fb132417167a7ef67 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Mon, 26 Oct 2026 11:58:10 +0100 Subject: [PATCH] feat: add superadmin --- README.md | 4 + apps/api/package.json | 1 + .../migration.sql | 2 + apps/api/prisma/schema.prisma | 5 +- apps/api/prisma/seed.ts | 30 + apps/api/prisma/superadmin.ts | 31 + apps/api/src/admin/admin.controller.ts | 226 +++++++ apps/api/src/admin/admin.module.ts | 14 + apps/api/src/admin/admin.service.ts | 613 ++++++++++++++++++ apps/api/src/admin/consistency.spec.ts | 97 +++ apps/api/src/admin/consistency.ts | 146 +++++ apps/api/src/admin/dto/admin.dto.ts | 95 +++ apps/api/src/admin/venue-reset.ts | 131 ++++ apps/api/src/app.module.ts | 2 + .../src/auth/strategies/jwt.strategy.spec.ts | 25 +- apps/api/src/auth/strategies/jwt.strategy.ts | 17 +- .../api/src/common/guards/roles.guard.spec.ts | 7 + apps/api/src/common/guards/roles.guard.ts | 3 +- .../common/guards/venue-access.guard.spec.ts | 7 +- .../src/common/guards/venue-access.guard.ts | 8 +- apps/api/src/devices/devices.controller.ts | 6 +- apps/api/src/jobs/command-expiry.job.ts | 3 + apps/api/src/jobs/jobs.module.ts | 1 + apps/api/src/realtime/realtime.gateway.ts | 10 + apps/api/src/realtime/realtime.service.ts | 5 + apps/api/src/reminders/reminders.module.ts | 1 + apps/api/src/reminders/reminders.service.ts | 43 +- .../app/dashboard/admin/devices-section.tsx | 150 +++++ .../app/dashboard/admin/insight-sections.tsx | 184 ++++++ .../dashboard/admin/maintenance-section.tsx | 220 +++++++ .../src/app/dashboard/admin/page.tsx | 82 +++ .../src/app/dashboard/admin/shared.tsx | 124 ++++ .../src/app/dashboard/admin/users-section.tsx | 207 ++++++ .../app/dashboard/admin/venues-section.tsx | 463 +++++++++++++ apps/dashboard/src/app/dashboard/layout.tsx | 40 +- .../src/app/dashboard/reminders/page.tsx | 4 +- .../src/app/dashboard/settings/page.tsx | 11 +- .../src/app/dashboard/stock/page.tsx | 4 +- .../dashboard/src/app/dashboard/tabs/page.tsx | 4 +- .../src/app/dashboard/users/page.tsx | 11 +- apps/dashboard/src/lib/api/client.ts | 156 ++++- apps/dashboard/src/lib/i18n.tsx | 338 ++++++++++ apps/dashboard/src/lib/realtime.tsx | 9 +- apps/dashboard/src/lib/session.tsx | 49 +- docs/security.md | 7 +- 45 files changed, 3531 insertions(+), 65 deletions(-) create mode 100644 apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql create mode 100644 apps/api/prisma/superadmin.ts create mode 100644 apps/api/src/admin/admin.controller.ts create mode 100644 apps/api/src/admin/admin.module.ts create mode 100644 apps/api/src/admin/admin.service.ts create mode 100644 apps/api/src/admin/consistency.spec.ts create mode 100644 apps/api/src/admin/consistency.ts create mode 100644 apps/api/src/admin/dto/admin.dto.ts create mode 100644 apps/api/src/admin/venue-reset.ts create mode 100644 apps/dashboard/src/app/dashboard/admin/devices-section.tsx create mode 100644 apps/dashboard/src/app/dashboard/admin/insight-sections.tsx create mode 100644 apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx create mode 100644 apps/dashboard/src/app/dashboard/admin/page.tsx create mode 100644 apps/dashboard/src/app/dashboard/admin/shared.tsx create mode 100644 apps/dashboard/src/app/dashboard/admin/users-section.tsx create mode 100644 apps/dashboard/src/app/dashboard/admin/venues-section.tsx diff --git a/README.md b/README.md index 8cd4659..87f1cf5 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ Swagger docs at http://localhost:3001/api/docs |--------|--------------------|------------| | Admin | admin@example.com | Admin123! | | Manager | viewer@example.com | Viewer123! | +| Superadmin | superadmin@example.com | Super123! | + +The seed also adds a second venue, KoolTap Festival Bar (festival@example.com / Admin123!). To make an existing +user a superadmin elsewhere, run `pnpm --filter @kooltap/api run superadmin ` (`--revoke` makes them an admin again). ## Device Simulator diff --git a/apps/api/package.json b/apps/api/package.json index da1d448..ab631bf 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "db:migrate": "prisma migrate dev", "db:seed": "ts-node prisma/seed.ts", "db:reset": "prisma migrate reset --force && ts-node prisma/seed.ts", + "superadmin": "ts-node prisma/superadmin.ts", "clean": "rimraf dist" }, "dependencies": { diff --git a/apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql b/apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql new file mode 100644 index 0000000..cdb4f8b --- /dev/null +++ b/apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ClientUser" ADD COLUMN "sessionsRevokedAt" TIMESTAMP(3); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d6f24b0..c84bed9 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -56,9 +56,12 @@ model ClientUser { email String @unique passwordHash String name String - // admin (the owner: users and terminals) | manager (runs the venue: products, stock, tabs, reminders) + // admin (the owner: users and terminals) | manager (runs the venue: products, stock, tabs, reminders) | + // superadmin (runs KoolTap itself: every venue, and the admin page) role String @default("manager") createdAt DateTime @default(now()) + // Access tokens issued before this are refused, so a forced logout applies at once instead of when they expire + sessionsRevokedAt DateTime? venue Venue @relation(fields: [venueId], references: [id]) diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 021e860..4bc6566 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -61,6 +61,35 @@ async function main() { }); console.log('Created viewer user: viewer@example.com / Viewer123!'); + // Runs KoolTap itself: every venue, and the admin page + const superadminHash = await bcrypt.hash('Super123!', 10); + await prisma.clientUser.create({ + data: { + id: uuidv4(), + venueId, + email: 'superadmin@example.com', + passwordHash: superadminHash, + name: 'KoolTap', + role: 'superadmin', + }, + }); + console.log('Created superadmin user: superadmin@example.com / Super123!'); + + // A second venue, so the admin page has more than one + const secondVenueId = uuidv4(); + await prisma.venue.create({ data: { id: secondVenueId, name: 'KoolTap Festival Bar' } }); + await prisma.clientUser.create({ + data: { + id: uuidv4(), + venueId: secondVenueId, + email: 'festival@example.com', + passwordHash: adminHash, + name: 'Festival', + role: 'admin', + }, + }); + console.log('Created venue: KoolTap Festival Bar, admin festival@example.com / Admin123!'); + // Create one device const deviceId = uuidv4(); const token = 'dev_' + crypto.randomBytes(48).toString('base64url'); @@ -208,6 +237,7 @@ async function main() { console.log('Demo credentials:'); console.log(' Admin: admin@example.com / Admin123!'); console.log(' Viewer: viewer@example.com / Viewer123!'); + console.log(' Superadmin: superadmin@example.com / Super123!'); console.log(`\nDevice token (for simulator): ${token}`); } diff --git a/apps/api/prisma/superadmin.ts b/apps/api/prisma/superadmin.ts new file mode 100644 index 0000000..5bdcdf9 --- /dev/null +++ b/apps/api/prisma/superadmin.ts @@ -0,0 +1,31 @@ +import { PrismaClient } from '@prisma/client'; + +/** + * Makes an existing dashboard user a superadmin, or an admin again with --revoke. There is no other way to become + * the first superadmin: the dashboard only lets a superadmin hand out the role. + * + * pnpm --filter @kooltap/api run superadmin user@example.com [--revoke] + */ +async function main() { + const [email, flag] = process.argv.slice(2); + if (!email) { + console.error('Usage: superadmin [--revoke]'); + process.exit(1); + } + const role = flag === '--revoke' ? 'admin' : 'superadmin'; + + const prisma = new PrismaClient(); + try { + const user = await prisma.clientUser.findUnique({ where: { email } }); + if (!user) { + console.error(`No user with email ${email}`); + process.exit(1); + } + await prisma.clientUser.update({ where: { id: user.id }, data: { role } }); + console.log(`${email} is now ${role === 'superadmin' ? 'a superadmin' : 'an admin'}`); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts new file mode 100644 index 0000000..34edac7 --- /dev/null +++ b/apps/api/src/admin/admin.controller.ts @@ -0,0 +1,226 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { AdminService } from './admin.service'; +import { + CopyCatalogDto, + DryRunDto, + PurgeDto, + ResetPasswordDto, + ResetVenueDto, + TestEmailDto, + UpdateRoleDto, + VenueNameDto, +} from './dto/admin.dto'; + +/** The superadmin page: every venue, terminal and user of KoolTap. Destructive actions take a dryRun to preview. */ +@ApiTags('Admin') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('superadmin') +@Controller('admin') +export class AdminController { + constructor(private admin: AdminService) {} + + // ---- Venues + + @Get('venues') + @ApiOperation({ summary: 'Every venue with what it holds' }) + listVenues() { + return this.admin.listVenues(); + } + + @Post('venues') + @ApiOperation({ summary: 'Create a venue' }) + createVenue(@Body() dto: VenueNameDto, @Req() req: any) { + return this.admin.createVenue(dto.name, req.user); + } + + @Patch('venues/:venueId') + @ApiOperation({ summary: 'Rename a venue' }) + renameVenue(@Param('venueId') venueId: string, @Body() dto: VenueNameDto, @Req() req: any) { + return this.admin.renameVenue(venueId, dto.name, req.user); + } + + @Delete('venues/:venueId') + @ApiOperation({ summary: 'Delete a venue with everything in it, users and terminals included' }) + @ApiQuery({ name: 'confirmName', description: "The venue's name" }) + deleteVenue(@Param('venueId') venueId: string, @Query('confirmName') confirmName: string | undefined, @Req() req: any) { + return this.admin.deleteVenue(venueId, confirmName, req.user); + } + + @Post('venues/:venueId/view') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Start viewing the dashboard as the venue; recorded in the venue's audit log" }) + viewVenue(@Param('venueId') venueId: string, @Req() req: any) { + return this.admin.viewVenue(venueId, req.user); + } + + @Post('venues/:venueId/copy-catalog') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Copy another venue's products that this venue doesn't have yet" }) + copyCatalog(@Param('venueId') venueId: string, @Body() dto: CopyCatalogDto, @Req() req: any) { + return this.admin.copyCatalog(venueId, dto.fromVenueId, req.user); + } + + @Post('venues/:venueId/reset') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Wipe parts of a venue's data; users, terminals and products stay" }) + resetVenue(@Param('venueId') venueId: string, @Body() dto: ResetVenueDto, @Req() req: any) { + return this.admin.resetVenue(venueId, dto.scopes, { stockValue: dto.stockValue, dryRun: dto.dryRun }, req.user); + } + + @Get('venues/:venueId/consistency') + @ApiOperation({ summary: "Look for data in the venue that doesn't add up" }) + consistency(@Param('venueId') venueId: string) { + return this.admin.consistency(venueId); + } + + @Get('venues/:venueId/export') + @ApiOperation({ summary: 'Everything of the venue as JSON, without passwords and pictures' }) + exportVenue(@Param('venueId') venueId: string, @Req() req: any) { + return this.admin.exportVenue(venueId, req.user); + } + + @Post('venues/:venueId/registration-codes') + @ApiOperation({ summary: 'A pairing code for a terminal of the venue' }) + createRegistrationCode(@Param('venueId') venueId: string, @Req() req: any) { + return this.admin.createRegistrationCode(venueId, req.user); + } + + // ---- Terminals + + @Get('devices') + @ApiOperation({ summary: 'Every terminal, unpaired ones included' }) + listDevices() { + return this.admin.listDevices(); + } + + @Post('devices/:deviceId/revoke') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Unpair a terminal' }) + revokeDevice(@Param('deviceId') deviceId: string, @Req() req: any) { + return this.admin.revokeDevice(deviceId, req.user); + } + + @Get('registration-codes') + @ApiOperation({ summary: 'Pairing codes that can still be used' }) + listRegistrationCodes() { + return this.admin.listRegistrationCodes(); + } + + @Post('registration-codes/:id/revoke') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Make a pairing code unusable' }) + revokeRegistrationCode(@Param('id') id: string) { + return this.admin.revokeRegistrationCode(id); + } + + @Post('registration-codes/cleanup') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Delete used and expired pairing codes' }) + cleanupRegistrationCodes() { + return this.admin.cleanupRegistrationCodes(); + } + + @Post('commands/expire-stuck') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Expire every pending command' }) + expireStuckCommands(@Body() dto: DryRunDto, @Req() req: any) { + return this.admin.expireStuckCommands(dto.dryRun ?? false, req.user); + } + + // ---- Users + + @Get('users') + @ApiOperation({ summary: 'Every dashboard user' }) + listUsers() { + return this.admin.listUsers(); + } + + @Patch('users/:userId/role') + @ApiOperation({ summary: "Change a user's role (not your own)" }) + updateRole(@Param('userId') userId: string, @Body() dto: UpdateRoleDto, @Req() req: any) { + return this.admin.updateRole(userId, dto.role, req.user); + } + + @Post('users/:userId/password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Set a user's password; signs them out everywhere" }) + resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @Req() req: any) { + return this.admin.resetPassword(userId, dto.password, req.user); + } + + @Post('users/:userId/logout') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Sign a user out everywhere, at once' }) + logoutUser(@Param('userId') userId: string, @Req() req: any) { + return this.admin.logoutUser(userId, req.user); + } + + @Delete('users/:userId') + @ApiOperation({ summary: 'Delete a user (not yourself)' }) + deleteUser(@Param('userId') userId: string, @Req() req: any) { + return this.admin.deleteUser(userId, req.user); + } + + @Post('sessions/revoke-all') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Sign out every user but yourself' }) + logoutEveryone(@Req() req: any) { + return this.admin.logoutEveryone(req.user); + } + + // ---- Maintenance + + @Post('purge') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Delete old terminal events, audit entries or finished commands' }) + purge(@Body() dto: PurgeDto, @Req() req: any) { + return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun ?? false, req.user); + } + + @Post('images/cleanup') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Delete pictures no product uses (older than a week)' }) + cleanupImages(@Body() dto: DryRunDto, @Req() req: any) { + return this.admin.cleanupImages(dto.dryRun ?? false, req.user); + } + + @Post('email/test') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send the test email to an address' }) + sendTestEmail(@Body() dto: TestEmailDto, @Req() req: any) { + return this.admin.sendTestEmail(dto.to, req.user); + } + + // ---- Insight + + @Get('audit') + @ApiOperation({ summary: 'The audit log of every venue' }) + @ApiQuery({ name: 'venueId', required: false }) + @ApiQuery({ name: 'action', required: false, description: 'Part of the action name' }) + @ApiQuery({ name: 'limit', required: false }) + @ApiQuery({ name: 'offset', required: false }) + auditLog( + @Query('venueId') venueId?: string, + @Query('action') action?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.admin.auditLog({ + venueId: venueId || undefined, + action: action || undefined, + limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200), + offset: Math.max(parseInt(offset ?? '', 10) || 0, 0), + }); + } + + @Get('health') + @ApiOperation({ summary: 'Database, migrations, live connections and background jobs' }) + health() { + return this.admin.health(); + } +} diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts new file mode 100644 index 0000000..ab44c45 --- /dev/null +++ b/apps/api/src/admin/admin.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { DevicesModule } from '../devices/devices.module'; +import { RemindersModule } from '../reminders/reminders.module'; +import { JobsModule } from '../jobs/jobs.module'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; + +@Module({ + imports: [AuthModule, DevicesModule, RemindersModule, JobsModule], + controllers: [AdminController], + providers: [AdminService], +}) +export class AdminModule {} diff --git a/apps/api/src/admin/admin.service.ts b/apps/api/src/admin/admin.service.ts new file mode 100644 index 0000000..b1de315 --- /dev/null +++ b/apps/api/src/admin/admin.service.ts @@ -0,0 +1,613 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service'; +import { AuthService } from '../auth/auth.service'; +import { DevicesService } from '../devices/devices.service'; +import { RemindersService } from '../reminders/reminders.service'; +import { CommandExpiryJob } from '../jobs/command-expiry.job'; +import { createdFieldTimes } from '../stock/product-fields'; +import { checkConsistency } from './consistency'; +import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset'; +import { PurgeTarget } from './dto/admin.dto'; + +/** The superadmin doing it. */ +export type Actor = { id: string; email: string; name: string; venueId: string }; + +// What each reset scope changes on the dashboard +const SCOPE_TOPICS: Record = { + tabs: ['tabs'], + settlements: ['tabs'], + tabEvents: [], + plannedTabs: ['planned-tabs', 'reminders'], + stock: ['stock'], + stockHistory: ['stock'], + commands: ['commands'], + auditLog: [], +}; + +// A terminal uploads a picture before the event that links it to a product, possibly much later when it was offline +const ORPHAN_IMAGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Everything the superadmin page does. Global actions (purging, signing everyone out) are written to the audit log + * of the superadmin's own venue, since the log is kept per venue. + */ +@Injectable() +export class AdminService { + constructor( + private prisma: PrismaService, + private audit: AuditService, + private realtime: RealtimeService, + private authService: AuthService, + private devicesService: DevicesService, + private reminders: RemindersService, + private commandExpiry: CommandExpiryJob, + ) {} + + // ---- Venues + + async listVenues() { + const venues = await this.prisma.venue.findMany({ + orderBy: { name: 'asc' }, + select: { + id: true, + name: true, + createdAt: true, + _count: { + select: { + clientUsers: true, + devices: { where: { revokedAt: null } }, + tabs: true, + products: { where: { archivedAt: null } }, + plannedTabs: true, + settlements: true, + }, + }, + }, + }); + const lastSeen = await this.prisma.device.groupBy({ + by: ['venueId'], + where: { revokedAt: null }, + _max: { lastSeenAt: true }, + }); + const lastSeenByVenue = new Map(lastSeen.map((row) => [row.venueId, row._max.lastSeenAt])); + + return { + venues: venues.map(({ _count, ...venue }) => ({ + ...venue, + users: _count.clientUsers, + devices: _count.devices, + openTabs: _count.tabs, + products: _count.products, + plannedTabs: _count.plannedTabs, + settlements: _count.settlements, + lastSyncAt: lastSeenByVenue.get(venue.id) ?? null, + })), + }; + } + + async createVenue(name: string, actor: Actor) { + const venue = await this.prisma.venue.create({ data: { name: name.trim() } }); + await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email }); + return venue; + } + + async renameVenue(venueId: string, name: string, actor: Actor) { + const before = await this.findVenue(venueId); + const venue = await this.prisma.venue.update({ where: { id: venueId }, data: { name: name.trim() } }); + await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_renamed', { from: before.name, to: venue.name, by: actor.email }); + return venue; + } + + /** [confirmName] must be the venue's name, so a wrong id can't delete a venue by accident. */ + async deleteVenue(venueId: string, confirmName: string | undefined, actor: Actor) { + const venue = await this.findVenue(venueId); + if (venueId === actor.venueId) { + throw new BadRequestException({ code: 'cannot_delete_own_venue', message: 'You cannot delete the venue your account belongs to' }); + } + if (confirmName !== venue.name) { + throw new BadRequestException({ code: 'confirm_name_mismatch', message: 'Type the venue name to confirm' }); + } + + await this.prisma.$transaction(async (tx) => { + await lockVenue(tx, venueId); + await deleteVenue(tx, venueId); + }, { timeout: 60000 }); + + await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_venue_deleted', { venueId, name: venue.name, by: actor.email }); + return { ok: true }; + } + + /** Recorded in the venue's own audit log, so its owner can see that KoolTap looked. */ + async viewVenue(venueId: string, actor: Actor) { + const venue = await this.findVenue(venueId); + await this.audit.log(venueId, 'client_user', actor.id, 'admin_viewed_venue', { by: actor.email }); + return { id: venue.id, name: venue.name }; + } + + /** + * Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but without + * stock: stock is counted per venue. + */ + async copyCatalog(venueId: string, fromVenueId: string, actor: Actor) { + if (venueId === fromVenueId) { + throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' }); + } + await this.findVenue(venueId); + await this.findVenue(fromVenueId); + + const created = await this.prisma.$transaction(async (tx) => { + const now = new Date(); + await lockVenue(tx, venueId); + const [source, existing] = await Promise.all([ + tx.product.findMany({ where: { venueId: fromVenueId, archivedAt: null } }), + tx.product.findMany({ where: { venueId, archivedAt: null }, select: { name: true } }), + ]); + const taken = new Set(existing.map((p) => p.name.trim().toLowerCase())); + const copies = source.filter((p) => !taken.has(p.name.trim().toLowerCase())); + if (copies.length > 0) { + await tx.product.createMany({ + data: copies.map((p) => { + const fields = { name: p.name, category: p.category, priceInCents: p.priceInCents, lowStockThreshold: p.lowStockThreshold }; + return { + venueId, + productId: uuidv4(), + ...fields, + stock: null, + countedAt: null, + reportedAt: now, + imageId: p.imageId, + fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false, imageId: p.imageId }, now), + }; + }), + }); + } + return copies.length; + }); + + await this.audit.log(venueId, 'client_user', actor.id, 'admin_catalog_copied', { fromVenueId, created, by: actor.email }); + if (created > 0) this.realtime.notify(venueId, 'stock'); + return { created }; + } + + async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue?: number; dryRun?: boolean }, actor: Actor) { + await this.findVenue(venueId); + const unique = [...new Set(scopes)]; + + if (options.dryRun) { + return { dryRun: true, counts: await this.prisma.$transaction((tx) => countReset(tx, venueId, unique)) }; + } + + const stockValue = options.stockValue ?? 0; + const counts: ResetCounts = await this.prisma.$transaction(async (tx) => { + await lockVenue(tx, venueId); + return resetVenue(tx, venueId, unique, { stockValue, actor: { id: actor.id, name: actor.name || actor.email }, now: new Date() }); + }, { timeout: 60000 }); + + // After the reset, so wiping the audit log keeps the entry that says who wiped it + await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_reset', { scopes: unique, stockValue, counts, by: actor.email }); + + const topics = new Set(unique.flatMap((scope) => SCOPE_TOPICS[scope])); + if (topics.size > 0) this.realtime.notify(venueId, ...topics); + + return { dryRun: false, counts }; + } + + async consistency(venueId: string) { + await this.findVenue(venueId); + const where = { venueId }; + const [settlements, tabs, products, pendingCommands, devices] = await Promise.all([ + this.prisma.settlement.findMany({ + where, + select: { id: true, customerName: true, amountInCents: true, lines: true, commandId: true }, + }), + this.prisma.tab.findMany({ where, select: { id: true, customerName: true, lines: true } }), + this.prisma.product.findMany({ + where: { venueId, archivedAt: null }, + select: { productId: true, name: true, stock: true, countedAt: true }, + }), + this.prisma.remoteCommand.findMany({ + where: { venueId, status: 'pending' }, + select: { id: true, expiresAt: true, device: { select: { name: true, revokedAt: true } } }, + }), + this.prisma.device.findMany({ where, select: { id: true, name: true, revokedAt: true, tokenExpiresAt: true } }), + ]); + + const findings = checkConsistency({ settlements, tabs, products, pendingCommands, devices }, new Date()); + return { checkedAt: new Date(), findings }; + } + + /** Everything of a venue except password hashes and picture bytes, for a backup before a reset. */ + async exportVenue(venueId: string, actor: Actor) { + const venue = await this.findVenue(venueId); + const where = { venueId }; + const [users, devices, products, tabs, settlements, plannedTabs, reminders, stockAdjustments, commands, tabEvents, auditLog] = + await Promise.all([ + this.prisma.clientUser.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }), + this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }), + this.prisma.product.findMany({ where }), + this.prisma.tab.findMany({ where }), + this.prisma.settlement.findMany({ where, orderBy: { sequence: 'asc' } }), + this.prisma.plannedTab.findMany({ where, orderBy: { position: 'asc' } }), + this.prisma.tabReminder.findMany({ where, orderBy: { sentAt: 'asc' } }), + this.prisma.stockAdjustment.findMany({ where, orderBy: { createdAt: 'asc' } }), + this.prisma.remoteCommand.findMany({ where, orderBy: { createdAt: 'asc' } }), + this.prisma.tabEvent.findMany({ where, orderBy: { occurredAt: 'asc' } }), + this.prisma.auditLog.findMany({ where, orderBy: { createdAt: 'asc' } }), + ]); + + await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_exported', { by: actor.email }); + + return { + exportedAt: new Date(), + venue, + users, + devices, + products, + tabs, + settlements, + plannedTabs, + reminders, + stockAdjustments, + commands, + tabEvents, + auditLog, + }; + } + + // ---- Terminals + + async listDevices() { + const devices = await this.prisma.device.findMany({ + orderBy: [{ revokedAt: { sort: 'desc', nulls: 'first' } }, { lastSeenAt: 'desc' }], + select: { + id: true, + name: true, + venueId: true, + tokenPrefix: true, + tokenExpiresAt: true, + lastSeenAt: true, + revokedAt: true, + createdAt: true, + venue: { select: { name: true } }, + _count: { select: { remoteCommands: { where: { status: 'pending' } } } }, + }, + }); + return { + devices: devices.map(({ venue, _count, ...device }) => ({ + ...device, + venueName: venue.name, + pendingCommands: _count.remoteCommands, + })), + }; + } + + async revokeDevice(deviceId: string, actor: Actor) { + const device = await this.prisma.device.findUnique({ where: { id: deviceId } }); + if (!device) { + throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' }); + } + await this.devicesService.revoke(device.venueId, deviceId); + await this.audit.log(device.venueId, 'client_user', actor.id, 'admin_device_revoked', { deviceId, deviceName: device.name, by: actor.email }); + return { ok: true }; + } + + async listRegistrationCodes() { + const codes = await this.prisma.registrationCode.findMany({ + where: { usedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: 'desc' }, + select: { id: true, code: true, venueId: true, expiresAt: true, createdAt: true, venue: { select: { name: true } } }, + }); + const stale = await this.prisma.registrationCode.count({ + where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] }, + }); + return { codes: codes.map(({ venue, ...code }) => ({ ...code, venueName: venue.name })), stale }; + } + + async createRegistrationCode(venueId: string, actor: Actor) { + await this.findVenue(venueId); + const code = await this.authService.generateRegistrationCode(venueId); + await this.audit.log(venueId, 'client_user', actor.id, 'admin_registration_code_created', { by: actor.email }); + return code; + } + + async revokeRegistrationCode(id: string) { + const { count } = await this.prisma.registrationCode.updateMany({ + where: { id, usedAt: null, expiresAt: { gt: new Date() } }, + data: { expiresAt: new Date() }, + }); + return { ok: true, revoked: count > 0 }; + } + + /** Deletes used and expired codes; they are only kept so a code can't be used twice, which expiry already ensures. */ + async cleanupRegistrationCodes() { + const { count } = await this.prisma.registrationCode.deleteMany({ + where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] }, + }); + return { deleted: count }; + } + + /** + * Expires every pending command. The server settles tabs itself now, so a pending command is one from before that + * whose terminal never picked it up. + */ + async expireStuckCommands(dryRun: boolean, actor: Actor) { + const where = { status: 'pending' }; + if (dryRun) { + return { dryRun: true, count: await this.prisma.remoteCommand.count({ where }) }; + } + + const stuck = await this.prisma.remoteCommand.findMany({ where, select: { id: true, venueId: true } }); + if (stuck.length > 0) { + await this.prisma.remoteCommand.updateMany({ + where: { id: { in: stuck.map((c) => c.id) }, status: 'pending' }, + data: { status: 'expired', result: 'Expired by a KoolTap admin' }, + }); + } + const venues = new Set(stuck.map((c) => c.venueId)); + for (const venueId of venues) { + const count = stuck.filter((c) => c.venueId === venueId).length; + await this.audit.log(venueId, 'client_user', actor.id, 'admin_commands_expired', { count, by: actor.email }); + this.realtime.notify(venueId, 'commands'); + } + return { dryRun: false, count: stuck.length }; + } + + // ---- Users + + async listUsers() { + const now = new Date(); + const [users, sessions] = await Promise.all([ + this.prisma.clientUser.findMany({ + orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }], + select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true, venue: { select: { name: true } } }, + }), + this.prisma.refreshToken.groupBy({ + by: ['userId'], + where: { revokedAt: null, expiresAt: { gt: now } }, + _count: { _all: true }, + }), + ]); + const sessionsByUser = new Map(sessions.map((row) => [row.userId, row._count._all])); + return { + users: users.map(({ venue, ...user }) => ({ + ...user, + role: user.role === 'viewer' ? 'manager' : user.role, + venueName: venue.name, + activeSessions: sessionsByUser.get(user.id) ?? 0, + })), + }; + } + + async updateRole(userId: string, role: string, actor: Actor) { + // Demoting yourself could leave nobody who can open this page + if (userId === actor.id) { + throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' }); + } + const user = await this.findUser(userId); + await this.prisma.clientUser.update({ where: { id: userId }, data: { role } }); + await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_role_changed', { userId, email: user.email, from: user.role, to: role, by: actor.email }); + this.realtime.notify(user.venueId, 'users'); + return { ok: true }; + } + + /** Sets a new password and signs the user out everywhere. */ + async resetPassword(userId: string, password: string, actor: Actor) { + // That would sign you out too; My Account changes your own password and keeps you signed in + if (userId === actor.id) { + throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' }); + } + const user = await this.findUser(userId); + const passwordHash = await bcrypt.hash(password, 10); + const now = new Date(); + await this.prisma.$transaction([ + this.prisma.clientUser.update({ where: { id: userId }, data: { passwordHash, sessionsRevokedAt: now } }), + this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }), + ]); + await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_password_reset', { userId, email: user.email, by: actor.email }); + return { ok: true }; + } + + /** Signs the user out everywhere, at once: their access tokens are refused too. */ + async logoutUser(userId: string, actor: Actor) { + if (userId === actor.id) { + throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' }); + } + const user = await this.findUser(userId); + const now = new Date(); + await this.prisma.$transaction([ + this.prisma.clientUser.update({ where: { id: userId }, data: { sessionsRevokedAt: now } }), + this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }), + ]); + await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_user_logged_out', { userId, email: user.email, by: actor.email }); + return { ok: true }; + } + + /** Signs out every user but the superadmin doing it. */ + async logoutEveryone(actor: Actor) { + const now = new Date(); + const others = { id: { not: actor.id } }; + const [users] = await this.prisma.$transaction([ + this.prisma.clientUser.updateMany({ where: others, data: { sessionsRevokedAt: now } }), + this.prisma.refreshToken.updateMany({ where: { userId: { not: actor.id }, revokedAt: null }, data: { revokedAt: now } }), + ]); + await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_everyone_logged_out', { users: users.count, by: actor.email }); + return { users: users.count }; + } + + async deleteUser(userId: string, actor: Actor) { + const user = await this.findUser(userId); + return this.authService.deleteUser(user.venueId, userId, actor); + } + + // ---- Maintenance + + /** Removes old history. Pending commands are never purged: a terminal may still pick them up. */ + async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: Actor) { + if (venueId) await this.findVenue(venueId); + const before = new Date(Date.now() - olderThanDays * DAY_MS); + const venue = venueId ? { venueId } : {}; + const wheres = { + tabEvents: { ...venue, receivedAt: { lt: before } }, + auditLog: { ...venue, createdAt: { lt: before } }, + commands: { ...venue, createdAt: { lt: before }, status: { not: 'pending' } }, + }; + const run = { + tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: wheres.tabEvents }) : this.prisma.tabEvent.deleteMany({ where: wheres.tabEvents }).then((r) => r.count)), + auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: wheres.auditLog }) : this.prisma.auditLog.deleteMany({ where: wheres.auditLog }).then((r) => r.count)), + commands: () => (dryRun ? this.prisma.remoteCommand.count({ where: wheres.commands }) : this.prisma.remoteCommand.deleteMany({ where: wheres.commands }).then((r) => r.count)), + }; + + const counts: Partial> = {}; + for (const target of new Set(targets)) counts[target] = await run[target](); + + if (!dryRun) { + await this.audit.log(venueId ?? actor.venueId, 'client_user', actor.id, 'admin_purged', { + targets, olderThanDays, venueId: venueId ?? 'all', counts, by: actor.email, + }); + if (venueId && counts.commands) this.realtime.notify(venueId, 'commands'); + } + return { dryRun, counts }; + } + + /** Deletes pictures no product uses, after a grace period for terminals that upload before they link. */ + async cleanupImages(dryRun: boolean, actor: Actor) { + const where = { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } }; + if (dryRun) { + const images = await this.prisma.productImage.findMany({ where, select: { id: true } }); + const [{ bytes }] = images.length === 0 ? [{ bytes: 0n }] : await this.prisma.$queryRaw<{ bytes: bigint }[]>` + SELECT COALESCE(SUM(OCTET_LENGTH("bytes")), 0)::bigint AS bytes FROM "ProductImage" WHERE "id" = ANY(${images.map((i) => i.id)})`; + return { dryRun: true, count: images.length, bytes: Number(bytes) }; + } + const { count } = await this.prisma.productImage.deleteMany({ where }); + await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_images_cleaned', { count, by: actor.email }); + return { dryRun: false, count }; + } + + async sendTestEmail(to: string, actor: Actor) { + const result = await this.reminders.sendTestEmailTo(to); + await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_test_email_sent', { to, by: actor.email }); + return result; + } + + // ---- Insight + + async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }) { + const where = { + ...(options.venueId ? { venueId: options.venueId } : {}), + ...(options.action ? { action: { contains: options.action, mode: 'insensitive' as const } } : {}), + }; + const [entries, total] = await Promise.all([ + this.prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: options.limit, + skip: options.offset, + include: { venue: { select: { name: true } } }, + }), + this.prisma.auditLog.count({ where }), + ]); + + // Actors are stored as ids; show who they are + const actorIds = [...new Set(entries.map((e) => e.actorId))]; + const [users, devices] = await Promise.all([ + this.prisma.clientUser.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }), + this.prisma.device.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } }), + ]); + const actorNames = new Map([ + ...users.map((u): [string, string] => [u.id, u.email]), + ...devices.map((d): [string, string] => [d.id, d.name]), + ]); + + return { + total, + entries: entries.map(({ venue, details, ...entry }) => ({ + ...entry, + venueName: venue.name, + actorName: actorNames.get(entry.actorId) ?? null, + details: parseDetails(details), + })), + }; + } + + async health() { + const [[size], migrations, tables, stuckCommands] = await Promise.all([ + this.prisma.$queryRaw<{ bytes: bigint }[]>`SELECT pg_database_size(current_database())::bigint AS bytes`, + this.prisma.$queryRaw<{ migration_name: string; finished_at: Date | null; rolled_back_at: Date | null }[]>` + SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations" ORDER BY "started_at" ASC`, + this.tableCounts(), + this.prisma.remoteCommand.count({ where: { status: 'pending' } }), + ]); + const applied = migrations.filter((m) => m.finished_at && !m.rolled_back_at); + + return { + checkedAt: new Date(), + uptimeSeconds: Math.round(process.uptime()), + nodeVersion: process.version, + databaseBytes: Number(size.bytes), + tables, + migrations: { + applied: applied.length, + latest: applied.at(-1)?.migration_name ?? null, + // Started but never finished: the database may be half migrated + failed: migrations.filter((m) => !m.finished_at && !m.rolled_back_at).map((m) => m.migration_name), + }, + realtimeConnections: this.realtime.connectionCount(), + commandExpiryLastRunAt: this.commandExpiry.lastRunAt, + stuckCommands, + emailConfigured: this.reminders.configured, + }; + } + + private async tableCounts() { + const p = this.prisma; + const entries = await Promise.all(Object.entries({ + Venue: () => p.venue.count(), + ClientUser: () => p.clientUser.count(), + Device: () => p.device.count(), + RefreshToken: () => p.refreshToken.count(), + RegistrationCode: () => p.registrationCode.count(), + Tab: () => p.tab.count(), + TabEvent: () => p.tabEvent.count(), + Settlement: () => p.settlement.count(), + PlannedTab: () => p.plannedTab.count(), + TabReminder: () => p.tabReminder.count(), + Product: () => p.product.count(), + ProductImage: () => p.productImage.count(), + StockAdjustment: () => p.stockAdjustment.count(), + RemoteCommand: () => p.remoteCommand.count(), + AuditLog: () => p.auditLog.count(), + }).map(async ([table, count]) => ({ table, rows: await count() }))); + return entries; + } + + private async findVenue(venueId: string) { + const venue = await this.prisma.venue.findUnique({ where: { id: venueId } }); + if (!venue) { + throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' }); + } + return venue; + } + + private async findUser(userId: string) { + const user = await this.prisma.clientUser.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException({ code: 'user_not_found', message: 'User not found' }); + } + return user; + } +} + +function parseDetails(details: string | null): unknown { + if (!details) return null; + try { + return JSON.parse(details); + } catch { + return details; + } +} diff --git a/apps/api/src/admin/consistency.spec.ts b/apps/api/src/admin/consistency.spec.ts new file mode 100644 index 0000000..e68cda8 --- /dev/null +++ b/apps/api/src/admin/consistency.spec.ts @@ -0,0 +1,97 @@ +import { checkConsistency, VenueSnapshot } from './consistency'; + +const now = new Date('2026-10-26T12:00:00Z'); +const later = new Date('2026-10-27T12:00:00Z'); +const earlier = new Date('2026-10-25T12:00:00Z'); + +const unit = (id: string, removedAt: string | null = null) => ({ id, addedAt: earlier.toISOString(), removedAt }); + +function snapshot(overrides: Partial = {}): VenueSnapshot { + return { settlements: [], tabs: [], products: [], pendingCommands: [], devices: [], ...overrides }; +} + +const codes = (venue: VenueSnapshot) => checkConsistency(venue, now).map((f) => f.code); + +describe('checkConsistency', () => { + it('finds nothing in a venue whose data adds up', () => { + const venue = snapshot({ + settlements: [{ + id: 's1', customerName: 'Jan', amountInCents: 500, commandId: null, + lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }], + }], + tabs: [{ id: 't1', customerName: 'Jan', lines: [{ id: 'l2', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, units: [unit('u1')] }] }], + products: [{ productId: 'p1', name: 'Pils', stock: 10, countedAt: earlier }], + pendingCommands: [{ id: 'c1', expiresAt: later, device: { name: 'Bar', revokedAt: null } }], + devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: later }], + }); + expect(checkConsistency(venue, now)).toEqual([]); + }); + + it('flags a settlement whose amount differs from its drinks', () => { + const venue = snapshot({ + settlements: [{ + id: 's1', customerName: 'Jan', amountInCents: 750, commandId: null, + lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }], + }], + }); + const [finding] = checkConsistency(venue, now); + expect(finding).toMatchObject({ severity: 'warning', code: 'settlement_amount_mismatch', subjectId: 's1' }); + expect(finding.message).toContain('7.50'); + expect(finding.message).toContain('5.00'); + }); + + it('flags one close request paid twice as an error, before anything else', () => { + const settlement = { customerName: 'Jan', amountInCents: 0, lines: [], commandId: 'c1' }; + const venue = snapshot({ + settlements: [{ ...settlement, id: 's1' }, { ...settlement, id: 's2' }], + products: [{ productId: 'p1', name: 'Pils', stock: -2, countedAt: earlier }], + }); + expect(codes(venue)).toEqual(['settlement_duplicate_command', 'stock_negative']); + }); + + it('flags two open tabs that only differ in case or spacing', () => { + const venue = snapshot({ + tabs: [ + { id: 't1', customerName: 'Jan Peeters', lines: [] }, + { id: 't2', customerName: ' jan peeters ', lines: [] }, + ], + }); + expect(codes(venue)).toEqual(['tab_duplicate_customer']); + }); + + it('notes drinks of a product that is gone, but not ones already taken off the tab', () => { + const line = { id: 'l1', productId: 'gone', productName: 'Cava', unitPriceInCents: 500 }; + expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1')] }] }] }))) + .toEqual(['tab_unknown_product']); + expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1', now.toISOString())] }] }] }))) + .toEqual([]); + }); + + it('notes tracked stock that was never counted', () => { + expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: 4, countedAt: null }] }))).toEqual(['stock_never_counted']); + expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: null, countedAt: null }] }))).toEqual([]); + }); + + it('flags pending commands that expired or wait for an unpaired terminal', () => { + const venue = snapshot({ + pendingCommands: [ + { id: 'c1', expiresAt: earlier, device: { name: 'Bar', revokedAt: null } }, + { id: 'c2', expiresAt: later, device: { name: 'Old', revokedAt: earlier } }, + ], + }); + expect(checkConsistency(venue, now).map((f) => [f.code, f.subjectId])).toEqual([ + ['command_stuck', 'c1'], + ['command_revoked_device', 'c2'], + ]); + }); + + it('notes a paired terminal whose token expired, but not an unpaired one', () => { + const venue = snapshot({ + devices: [ + { id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: earlier }, + { id: 'd2', name: 'Old', revokedAt: earlier, tokenExpiresAt: earlier }, + ], + }); + expect(checkConsistency(venue, now).map((f) => f.subjectId)).toEqual(['d1']); + }); +}); diff --git a/apps/api/src/admin/consistency.ts b/apps/api/src/admin/consistency.ts new file mode 100644 index 0000000..6cf4516 --- /dev/null +++ b/apps/api/src/admin/consistency.ts @@ -0,0 +1,146 @@ +import { customerNameKey } from '../planned-tabs/customer-name'; +import { Line, SettledLine, isActive } from '../tabs/tab-state'; + +export type Severity = 'error' | 'warning' | 'info'; + +export type Finding = { + severity: Severity; + // settlement_amount_mismatch | settlement_duplicate_command | tab_duplicate_customer | tab_unknown_product | + // stock_negative | stock_never_counted | command_stuck | command_revoked_device | device_token_expired + code: string; + message: string; + // What it is about: a settlement, tab, product, command or terminal id + subjectId: string; +}; + +export type VenueSnapshot = { + settlements: { id: string; customerName: string; amountInCents: number; lines: unknown; commandId: string | null }[]; + tabs: { id: string; customerName: string; lines: unknown }[]; + products: { productId: string; name: string; stock: number | null; countedAt: Date | null }[]; + pendingCommands: { id: string; expiresAt: Date; device: { name: string; revokedAt: Date | null } | null }[]; + devices: { id: string; name: string; revokedAt: Date | null; tokenExpiresAt: Date }[]; +}; + +const euro = (cents: number) => (cents / 100).toFixed(2); + +/** + * Looks for data that doesn't add up in one venue. Pure: the caller loads the snapshot. Stock is not recomputed from + * the events, because which events count depends on the order terminals synced in; only what is visibly off is shown. + */ +export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] { + const findings: Finding[] = []; + + // A settlement's amount is what the terminal charged; its lines are what the server had on the tab. A difference + // means the terminal and the server disagreed about the tab when it was paid + for (const settlement of venue.settlements) { + const lines = (settlement.lines as SettledLine[] | null) ?? []; + const linesTotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0); + if (linesTotal !== settlement.amountInCents) { + findings.push({ + severity: 'warning', + code: 'settlement_amount_mismatch', + message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(linesTotal)}`, + subjectId: settlement.id, + }); + } + } + + const byCommand = new Map(); + for (const settlement of venue.settlements) { + if (!settlement.commandId) continue; + byCommand.set(settlement.commandId, [...(byCommand.get(settlement.commandId) ?? []), settlement.id]); + } + for (const [commandId, ids] of byCommand) { + if (ids.length > 1) { + findings.push({ + severity: 'error', + code: 'settlement_duplicate_command', + message: `One close request was paid ${ids.length} times`, + subjectId: commandId, + }); + } + } + + // Customers are matched on their name everywhere, so two open tabs with one name get mixed up + const tabsByName = new Map(); + for (const tab of venue.tabs) { + const key = customerNameKey(tab.customerName); + tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab.id]); + } + for (const ids of tabsByName.values()) { + if (ids.length > 1) { + const name = venue.tabs.find((tab) => tab.id === ids[0])!.customerName; + findings.push({ + severity: 'warning', + code: 'tab_duplicate_customer', + message: `${ids.length} open tabs are named ${name}`, + subjectId: ids[0], + }); + } + } + + const productIds = new Set(venue.products.map((p) => p.productId)); + for (const tab of venue.tabs) { + for (const line of (tab.lines as Line[] | null) ?? []) { + if (line.productId && !productIds.has(line.productId) && line.units.some(isActive)) { + findings.push({ + severity: 'info', + code: 'tab_unknown_product', + message: `${tab.customerName}'s tab has ${line.productName}, which is not in the catalog`, + subjectId: tab.id, + }); + } + } + } + + for (const product of venue.products) { + if (product.stock !== null && product.stock < 0) { + findings.push({ + severity: 'warning', + code: 'stock_negative', + message: `${product.name} has a stock of ${product.stock}: more was sold than counted`, + subjectId: product.productId, + }); + } + if (product.stock !== null && product.countedAt === null) { + findings.push({ + severity: 'info', + code: 'stock_never_counted', + message: `${product.name}'s stock is tracked but was never counted`, + subjectId: product.productId, + }); + } + } + + for (const command of venue.pendingCommands) { + if (command.device?.revokedAt) { + findings.push({ + severity: 'warning', + code: 'command_revoked_device', + message: `A close request waits for ${command.device.name}, which is unpaired`, + subjectId: command.id, + }); + } else if (command.expiresAt <= now) { + findings.push({ + severity: 'warning', + code: 'command_stuck', + message: 'A close request expired but is still pending', + subjectId: command.id, + }); + } + } + + for (const device of venue.devices) { + if (!device.revokedAt && device.tokenExpiresAt <= now) { + findings.push({ + severity: 'info', + code: 'device_token_expired', + message: `${device.name}'s token expired: it has to be paired again`, + subjectId: device.id, + }); + } + } + + const order: Record = { error: 0, warning: 1, info: 2 }; + return findings.sort((a, b) => order[a.severity] - order[b.severity]); +} diff --git a/apps/api/src/admin/dto/admin.dto.ts b/apps/api/src/admin/dto/admin.dto.ts new file mode 100644 index 0000000..482cd00 --- /dev/null +++ b/apps/api/src/admin/dto/admin.dto.ts @@ -0,0 +1,95 @@ +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsEmail, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { RESET_SCOPES, ResetScope } from '../venue-reset'; + +export const PURGE_TARGETS = ['tabEvents', 'auditLog', 'commands'] as const; +export type PurgeTarget = (typeof PURGE_TARGETS)[number]; + +export class VenueNameDto { + @ApiProperty({ example: 'De Kroeg' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string; +} + +export class DryRunDto { + @ApiPropertyOptional({ description: 'Only count what would change' }) + @IsOptional() + @IsBoolean() + dryRun?: boolean; +} + +export class ResetVenueDto extends DryRunDto { + @ApiProperty({ enum: RESET_SCOPES, isArray: true }) + @IsArray() + @ArrayMinSize(1) + @IsIn(RESET_SCOPES, { each: true }) + scopes: ResetScope[]; + + @ApiPropertyOptional({ description: 'The count every tracked product gets with the stock scope', default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(100000) + stockValue?: number; +} + +export class PurgeDto extends DryRunDto { + @ApiProperty({ example: 90 }) + @IsInt() + @Min(1) + @Max(3650) + olderThanDays: number; + + @ApiProperty({ enum: PURGE_TARGETS, isArray: true }) + @IsArray() + @ArrayMinSize(1) + @IsIn(PURGE_TARGETS, { each: true }) + targets: PurgeTarget[]; + + @ApiPropertyOptional({ description: 'Only this venue; every venue when left out' }) + @IsOptional() + @IsString() + venueId?: string; +} + +export class CopyCatalogDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + fromVenueId: string; +} + +export class UpdateRoleDto { + @ApiProperty({ enum: ['admin', 'manager', 'superadmin'] }) + @IsIn(['admin', 'manager', 'superadmin']) + role: string; +} + +export class ResetPasswordDto { + @ApiProperty({ example: 'SecurePass123!' }) + @IsString() + @MinLength(6) + password: string; +} + +export class TestEmailDto { + @ApiProperty({ example: 'you@example.com' }) + @IsEmail() + to: string; +} diff --git a/apps/api/src/admin/venue-reset.ts b/apps/api/src/admin/venue-reset.ts new file mode 100644 index 0000000..faa4c4a --- /dev/null +++ b/apps/api/src/admin/venue-reset.ts @@ -0,0 +1,131 @@ +import { Prisma } from '@prisma/client'; + +type Tx = Prisma.TransactionClient; + +/** + * What a superadmin can wipe from a venue. Users, terminals and the product catalog always stay: a reset is for + * starting a new event or season with the same bar. + */ +export const RESET_SCOPES = [ + // The open tabs. Terminals drop them on their next sync; drinks a terminal still has to send bring a tab back + 'tabs', + // Paid tabs: the sales history + 'settlements', + // What the terminals reported. Also the record of which events were applied, so a terminal that re-sends an old + // event would apply it again: only wipe these when every terminal has synced + 'tabEvents', + // Planned customers and the reminders sent to them + 'plannedTabs', + // Every tracked product's stock is set to one count + 'stock', + // Stock deliveries, breakage and counts made on the dashboard + 'stockHistory', + // Command history + 'commands', + 'auditLog', +] as const; + +export type ResetScope = (typeof RESET_SCOPES)[number]; + +export type ResetCounts = Partial>; + +/** Locks the venue like a sync does, so no terminal changes it halfway through. */ +export async function lockVenue(tx: Tx, venueId: string) { + await tx.$queryRaw`SELECT "id" FROM "Venue" WHERE "id" = ${venueId} FOR UPDATE`; +} + +/** How many rows each scope would remove (or, for stock, how many products it would set). */ +export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]): Promise { + const where = { venueId }; + const counters: Record Promise> = { + tabs: () => tx.tab.count({ where }), + settlements: () => tx.settlement.count({ where }), + tabEvents: () => tx.tabEvent.count({ where }), + plannedTabs: () => tx.plannedTab.count({ where }), + stock: () => tx.product.count({ where: { venueId, stock: { not: null } } }), + stockHistory: () => tx.stockAdjustment.count({ where }), + commands: () => tx.remoteCommand.count({ where }), + auditLog: () => tx.auditLog.count({ where }), + }; + const counts: ResetCounts = {}; + for (const scope of scopes) counts[scope] = await counters[scope](); + return counts; +} + +/** + * Wipes [scopes] of a venue inside [tx], which must have locked it. [stockValue] is the count every tracked product + * gets; it is recorded as an applied stock count, after the stock history is wiped, so the history explains it. + */ +export async function resetVenue( + tx: Tx, + venueId: string, + scopes: ResetScope[], + options: { stockValue: number; actor: { id: string; name: string }; now: Date }, +): Promise { + const where = { venueId }; + const counts: ResetCounts = {}; + const has = (scope: ResetScope) => scopes.includes(scope); + + if (has('tabs')) counts.tabs = (await tx.tab.deleteMany({ where })).count; + if (has('settlements')) counts.settlements = (await tx.settlement.deleteMany({ where })).count; + if (has('tabEvents')) counts.tabEvents = (await tx.tabEvent.deleteMany({ where })).count; + if (has('plannedTabs')) { + await tx.tabReminder.deleteMany({ where }); + counts.plannedTabs = (await tx.plannedTab.deleteMany({ where })).count; + } + if (has('stockHistory')) counts.stockHistory = (await tx.stockAdjustment.deleteMany({ where })).count; + if (has('stock')) { + const { now, actor, stockValue } = options; + const tracked = await tx.product.findMany({ + where: { venueId, stock: { not: null } }, + select: { productId: true, name: true }, + }); + await tx.product.updateMany({ + where: { venueId, stock: { not: null } }, + data: { stock: stockValue, countedAt: now }, + }); + if (tracked.length > 0) { + await tx.stockAdjustment.createMany({ + data: tracked.map((product) => ({ + venueId, + productId: product.productId, + productName: product.name, + type: 'set', + quantity: stockValue, + note: 'Stock reset', + requestedBy: actor.name, + requestedById: actor.id, + status: 'applied', + stockAfter: stockValue, + createdAt: now, + processedAt: now, + })), + }); + } + counts.stock = tracked.length; + } + if (has('commands')) counts.commands = (await tx.remoteCommand.deleteMany({ where })).count; + if (has('auditLog')) counts.auditLog = (await tx.auditLog.deleteMany({ where })).count; + + return counts; +} + +/** Deletes a venue and everything in it, users and terminals included. */ +export async function deleteVenue(tx: Tx, venueId: string) { + const where = { venueId }; + const users = await tx.clientUser.findMany({ where, select: { id: true } }); + await tx.auditLog.deleteMany({ where }); + await tx.tabEvent.deleteMany({ where }); + await tx.tabReminder.deleteMany({ where }); + await tx.plannedTab.deleteMany({ where }); + await tx.remoteCommand.deleteMany({ where }); + await tx.settlement.deleteMany({ where }); + await tx.tab.deleteMany({ where }); + await tx.stockAdjustment.deleteMany({ where }); + await tx.product.deleteMany({ where }); + await tx.registrationCode.deleteMany({ where }); + await tx.device.deleteMany({ where }); + await tx.refreshToken.deleteMany({ where: { userId: { in: users.map((u) => u.id) } } }); + await tx.clientUser.deleteMany({ where }); + await tx.venue.delete({ where: { id: venueId } }); +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ea34652..97b90e4 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -16,6 +16,7 @@ import { StockModule } from './stock/stock.module'; import { ImagesModule } from './images/images.module'; import { RealtimeModule } from './realtime/realtime.module'; import { EmailModule } from './email/email.module'; +import { AdminModule } from './admin/admin.module'; import { HealthController } from './health.controller'; @Module({ @@ -36,6 +37,7 @@ import { HealthController } from './health.controller'; RemindersModule, StockModule, ImagesModule, + AdminModule, ], controllers: [HealthController], providers: [ diff --git a/apps/api/src/auth/strategies/jwt.strategy.spec.ts b/apps/api/src/auth/strategies/jwt.strategy.spec.ts index 2ae9f0a..0ab8c4b 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.spec.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.spec.ts @@ -1,9 +1,9 @@ import { UnauthorizedException } from '@nestjs/common'; -import { JwtStrategy } from './jwt.strategy'; +import { issuedBefore, JwtStrategy } from './jwt.strategy'; const mona = { id: 'user-1', email: 'mona@example.com', name: 'Mona', role: 'manager', venueId: 'venue-1' }; -function strategy(users: (typeof mona)[]) { +function strategy(users: (typeof mona & { sessionsRevokedAt?: Date | null })[]) { const prisma = { clientUser: { findUnique: async ({ where }: any) => users.find((u) => u.id === where.id) ?? null } }; return new JwtStrategy(prisma as any); } @@ -17,4 +17,25 @@ describe('JwtStrategy', () => { it('refuses the token of a deleted user', async () => { await expect(strategy([]).validate({ sub: 'user-1', role: 'admin', venueId: 'venue-1' })).rejects.toBeInstanceOf(UnauthorizedException); }); + + it('refuses a token issued before the user was signed out everywhere, and accepts a newer one', async () => { + const signedOutAt = new Date('2026-10-26T12:00:00Z'); + const users = [{ ...mona, sessionsRevokedAt: signedOutAt }]; + const before = Math.floor(signedOutAt.getTime() / 1000) - 60; + const after = Math.floor(signedOutAt.getTime() / 1000) + 60; + + await expect(strategy(users).validate({ sub: 'user-1', iat: before })).rejects.toBeInstanceOf(UnauthorizedException); + expect(await strategy(users).validate({ sub: 'user-1', iat: after })).toEqual(mona); + }); +}); + +describe('issuedBefore', () => { + it('only refuses tokens from before the sign-out', () => { + const at = new Date('2026-10-26T12:00:00.500Z'); + const second = Math.floor(at.getTime() / 1000); + expect(issuedBefore({ iat: second - 1 }, at)).toBe(true); + expect(issuedBefore({ iat: second }, at)).toBe(false); + expect(issuedBefore({ iat: second - 1 }, null)).toBe(false); + expect(issuedBefore({ iat: second - 1 }, undefined)).toBe(false); + }); }); diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index 8eab809..7f42f56 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -14,16 +14,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - // Looked up on every request: a deleted user loses access at once instead of when the token expires, and a - // changed role or name applies right away + // Looked up on every request: a deleted or signed-out user loses access at once instead of when the token expires, + // and a changed role or name applies right away async validate(payload: any) { const user = await this.prisma.clientUser.findUnique({ where: { id: payload.sub }, - select: { id: true, email: true, name: true, role: true, venueId: true }, + select: { id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true }, }); - if (!user) { + if (!user || issuedBefore(payload, user.sessionsRevokedAt)) { throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' }); } - return user; + const { sessionsRevokedAt, ...rest } = user; + return rest; } -} \ No newline at end of file +} +/** Whether the token was issued before the user's sessions were revoked. iat is in whole seconds. */ +export function issuedBefore(payload: { iat?: number }, revokedAt: Date | null | undefined): boolean { + return revokedAt != null && (payload.iat ?? 0) < Math.floor(revokedAt.getTime() / 1000); +} diff --git a/apps/api/src/common/guards/roles.guard.spec.ts b/apps/api/src/common/guards/roles.guard.spec.ts index f156d87..9f872e7 100644 --- a/apps/api/src/common/guards/roles.guard.spec.ts +++ b/apps/api/src/common/guards/roles.guard.spec.ts @@ -24,6 +24,13 @@ describe('RolesGuard', () => { expect(guard(['admin']).canActivate(context('viewer'))).toBe(false); }); + it('lets a superadmin do everything, and keeps the admin page for superadmins', () => { + expect(guard(['admin']).canActivate(context('superadmin'))).toBe(true); + expect(guard(['admin', 'manager']).canActivate(context('superadmin'))).toBe(true); + expect(guard(['superadmin']).canActivate(context('superadmin'))).toBe(true); + expect(guard(['superadmin']).canActivate(context('admin'))).toBe(false); + }); + it('refuses a request without a user when a role is required', () => { expect(guard(['manager']).canActivate(context(undefined))).toBe(false); expect(guard(undefined).canActivate(context(undefined))).toBe(true); diff --git a/apps/api/src/common/guards/roles.guard.ts b/apps/api/src/common/guards/roles.guard.ts index 49886c7..7e09272 100644 --- a/apps/api/src/common/guards/roles.guard.ts +++ b/apps/api/src/common/guards/roles.guard.ts @@ -20,6 +20,7 @@ export class RolesGuard { } // "viewer" was the old name of a manager; tokens issued before the rename still carry it const role = user.role === 'viewer' ? 'manager' : user.role; - return requiredRoles.includes(role); + // A superadmin may do everything an admin or manager may, in every venue + return role === 'superadmin' || requiredRoles.includes(role); } } \ No newline at end of file diff --git a/apps/api/src/common/guards/venue-access.guard.spec.ts b/apps/api/src/common/guards/venue-access.guard.spec.ts index cbebe01..98cc471 100644 --- a/apps/api/src/common/guards/venue-access.guard.spec.ts +++ b/apps/api/src/common/guards/venue-access.guard.spec.ts @@ -1,7 +1,7 @@ import { ExecutionContext, ForbiddenException } from '@nestjs/common'; import { VenueAccessGuard } from './venue-access.guard'; -function contextFor(params: Record, user?: { venueId: string }): ExecutionContext { +function contextFor(params: Record, user?: { venueId: string; role?: string }): ExecutionContext { return { switchToHttp: () => ({ getRequest: () => ({ params, user }) }), } as unknown as ExecutionContext; @@ -22,6 +22,11 @@ describe('VenueAccessGuard', () => { expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a' }))).toThrow(ForbiddenException); }); + it('lets a superadmin into every venue', () => { + expect(guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'superadmin' }))).toBe(true); + expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'admin' }))).toThrow(ForbiddenException); + }); + it('rejects when no user is attached', () => { expect(() => guard.canActivate(contextFor({ venueId: 'a' }))).toThrow(ForbiddenException); }); diff --git a/apps/api/src/common/guards/venue-access.guard.ts b/apps/api/src/common/guards/venue-access.guard.ts index ad87d9f..8987f06 100644 --- a/apps/api/src/common/guards/venue-access.guard.ts +++ b/apps/api/src/common/guards/venue-access.guard.ts @@ -1,8 +1,8 @@ import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common'; /** - * Ensures the authenticated user can only access the venue they belong to. - * Must run after JwtAuthGuard so that `request.user` is populated. + * Ensures the authenticated user can only access the venue they belong to. A superadmin can access every venue: that + * is how they view the dashboard as a venue. Must run after JwtAuthGuard so that `request.user` is populated. */ @Injectable() export class VenueAccessGuard implements CanActivate { @@ -14,6 +14,10 @@ export class VenueAccessGuard implements CanActivate { return true; } + if (request.user?.role === 'superadmin') { + return true; + } + if (!request.user || request.user.venueId !== venueId) { throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' }); } diff --git a/apps/api/src/devices/devices.controller.ts b/apps/api/src/devices/devices.controller.ts index 25f3dba..e8f0ad6 100644 --- a/apps/api/src/devices/devices.controller.ts +++ b/apps/api/src/devices/devices.controller.ts @@ -8,6 +8,7 @@ import { HttpCode, HttpStatus, Req, + Query, UploadedFile, UseInterceptors, } from '@nestjs/common'; @@ -86,8 +87,9 @@ export class DevicesController { @ApiBearerAuth() @ApiOperation({ summary: 'Generate a registration code for pairing' }) @ApiResponse({ status: 200, description: 'Registration code generated' }) - async generateCode(@Req() req: any) { - const venueId = req.user.venueId; + async generateCode(@Req() req: any, @Query('venueId') viewing?: string) { + // A superadmin pairs terminals for the venue they're viewing + const venueId = req.user.role === 'superadmin' && viewing ? viewing : req.user.venueId; return this.authService.generateRegistrationCode(venueId); } } \ No newline at end of file diff --git a/apps/api/src/jobs/command-expiry.job.ts b/apps/api/src/jobs/command-expiry.job.ts index 955a0b6..2fefb82 100644 --- a/apps/api/src/jobs/command-expiry.job.ts +++ b/apps/api/src/jobs/command-expiry.job.ts @@ -7,6 +7,8 @@ import { v4 as uuidv4 } from 'uuid'; @Injectable() export class CommandExpiryJob { private readonly logger = new Logger(CommandExpiryJob.name); + /** Null until it ran since the server started; shown on the admin page. */ + lastRunAt: Date | null = null; constructor( private prisma: PrismaService, @@ -18,6 +20,7 @@ export class CommandExpiryJob { this.logger.log('Running command expiry job...'); const now = new Date(); + this.lastRunAt = now; const expiredCommands = await this.prisma.remoteCommand.findMany({ where: { diff --git a/apps/api/src/jobs/jobs.module.ts b/apps/api/src/jobs/jobs.module.ts index 82f1ef9..ec1fe92 100644 --- a/apps/api/src/jobs/jobs.module.ts +++ b/apps/api/src/jobs/jobs.module.ts @@ -3,5 +3,6 @@ import { CommandExpiryJob } from './command-expiry.job'; @Module({ providers: [CommandExpiryJob], + exports: [CommandExpiryJob], }) export class JobsModule {} \ No newline at end of file diff --git a/apps/api/src/realtime/realtime.gateway.ts b/apps/api/src/realtime/realtime.gateway.ts index 69c641b..9616d81 100644 --- a/apps/api/src/realtime/realtime.gateway.ts +++ b/apps/api/src/realtime/realtime.gateway.ts @@ -1,4 +1,5 @@ import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../prisma/prisma.service'; import { OnGatewayConnection, OnGatewayInit, WebSocketGateway } from '@nestjs/websockets'; import { Namespace, Socket } from 'socket.io'; import { RealtimeService, venueRoom } from './realtime.service'; @@ -11,6 +12,7 @@ export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection { constructor( private jwtService: JwtService, private realtime: RealtimeService, + private prisma: PrismaService, ) {} afterInit(namespace: Namespace) { @@ -22,6 +24,14 @@ export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection { try { const payload = await this.jwtService.verifyAsync(String(socket.handshake.auth?.token ?? '')); socket.data.venueId = payload.venueId; + // A superadmin viewing another venue follows that venue. The role is checked in the database, not the token, + // so a demoted superadmin can't keep following other venues + const viewing = socket.handshake.auth?.venueId; + if (typeof viewing === 'string' && viewing && viewing !== payload.venueId) { + const user = await this.prisma.clientUser.findUnique({ where: { id: payload.sub }, select: { role: true } }); + if (user?.role !== 'superadmin') return next(new Error('unauthorized')); + socket.data.venueId = viewing; + } next(); } catch { next(new Error('unauthorized')); diff --git a/apps/api/src/realtime/realtime.service.ts b/apps/api/src/realtime/realtime.service.ts index 76dd34c..2bec40d 100644 --- a/apps/api/src/realtime/realtime.service.ts +++ b/apps/api/src/realtime/realtime.service.ts @@ -21,4 +21,9 @@ export class RealtimeService { notify(venueId: string, ...topics: RealtimeTopic[]) { this.namespace?.to(venueRoom(venueId)).emit('invalidate', { topics }); } + + /** Dashboards connected right now. */ + connectionCount(): number { + return this.namespace?.sockets.size ?? 0; + } } diff --git a/apps/api/src/reminders/reminders.module.ts b/apps/api/src/reminders/reminders.module.ts index 90d4af0..0b78425 100644 --- a/apps/api/src/reminders/reminders.module.ts +++ b/apps/api/src/reminders/reminders.module.ts @@ -5,5 +5,6 @@ import { RemindersService } from './reminders.service'; @Module({ controllers: [RemindersController], providers: [RemindersService], + exports: [RemindersService], }) export class RemindersModule {} \ No newline at end of file diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts index e387fae..e8fcd6b 100644 --- a/apps/api/src/reminders/reminders.service.ts +++ b/apps/api/src/reminders/reminders.service.ts @@ -191,12 +191,7 @@ export class RemindersService { } async sendTestEmail(venueId: string, plannedTabId: string) { - if (!this.resendApiKey) { - throw new BadRequestException({ - code: 'resend_not_configured', - message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.', - }); - } + this.ensureConfigured(); const tab = await this.prisma.plannedTab.findFirst({ where: { id: plannedTabId, venueId }, @@ -210,12 +205,36 @@ export class RemindersService { throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' }); } + await this.sendTest(tab.email, tab.customerName, await this.venueName(venueId)); + + return { ok: true, email: tab.email, customerName: tab.customerName }; + } + + /** The test email to any address: for a superadmin checking that sending works. */ + async sendTestEmailTo(to: string) { + this.ensureConfigured(); + await this.sendTest(to, to, 'KoolTap'); + return { ok: true, email: to }; + } + + /** Whether sending is set up, without sending anything. */ + get configured(): boolean { + return this.resendApiKey !== ''; + } + + private ensureConfigured() { + if (!this.resendApiKey) { + throw new BadRequestException({ + code: 'resend_not_configured', + message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.', + }); + } + } + + private async sendTest(to: string, customerName: string, venueName: string) { let email: RenderedEmail; try { - email = await this.emailTemplates.render('test-email', { - customerName: tab.customerName, - venueName: await this.venueName(venueId), - }); + email = await this.emailTemplates.render('test-email', { customerName, venueName }); } catch (error) { // Template mistakes are shown to the admin instead of a generic server error if (error instanceof EmailTemplateError) { @@ -224,15 +243,13 @@ export class RemindersService { throw error; } - const error = await this.resend('/emails', { from: this.fromEmail, to: tab.email, ...email }); + const error = await this.resend('/emails', { from: this.fromEmail, to, ...email }); if (error) { throw new BadRequestException({ code: 'email_failed', message: `Failed to send test email: ${error}`, }); } - - return { ok: true, email: tab.email, customerName: tab.customerName }; } private renderReminder(candidate: Recipient, venueName: string): Promise { diff --git a/apps/dashboard/src/app/dashboard/admin/devices-section.tsx b/apps/dashboard/src/app/dashboard/admin/devices-section.tsx new file mode 100644 index 0000000..2456cd0 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/admin/devices-section.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { RefreshCw } from 'lucide-react'; +import { adminApi, type AdminDevice } from '@/lib/api/client'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { DeviceStatusBadge } from '@/components/device-status-badge'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { LoadingState } from '@/components/loading-state'; +import { ErrorState } from '@/components/error-state'; +import { toast } from '@/components/ui/toast'; +import { useLang } from '@/lib/i18n'; +import { Checkbox } from './shared'; + +export function DevicesSection() { + const queryClient = useQueryClient(); + const { t, te, relativeTime, dateTime } = useLang(); + const [showUnpaired, setShowUnpaired] = useState(false); + const [unpairing, setUnpairing] = useState(null); + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'devices'], + queryFn: adminApi.devices, + // A terminal going offline sends no event, so its status is polled + refetchInterval: 15000, + }); + const codes = useQuery({ queryKey: ['admin', 'registration-codes'], queryFn: adminApi.registrationCodes }); + + const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] }); + const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); + + const unpair = useMutation({ + mutationFn: (device: AdminDevice) => adminApi.revokeDevice(device.id), + onSuccess: (_, device) => toast({ title: t('settings.unpaired', { name: device.name }), variant: 'success' }), + onError, + onSettled: () => { + setUnpairing(null); + refresh(); + }, + }); + const revokeCode = useMutation({ mutationFn: adminApi.revokeRegistrationCode, onError, onSettled: refresh }); + const cleanupCodes = useMutation({ + mutationFn: adminApi.cleanupRegistrationCodes, + onSuccess: ({ deleted }) => toast({ title: t('admin.codes.cleaned', { count: deleted }), variant: 'success' }), + onError, + onSettled: refresh, + }); + + if (isLoading) return ; + if (error) return ; + + const all = data?.devices ?? []; + const devices = showUnpaired ? all : all.filter((d) => !d.revokedAt); + const now = Date.now(); + + return ( +
+
+ !d.revokedAt).length })} /> + +
+ +
+ + + + {t('admin.devices.name')} + {t('admin.devices.venue')} + {t('admin.devices.status')} + {t('admin.devices.last_seen')} + {t('admin.devices.token_expires')} + {t('admin.devices.pending')} + + + + + {devices.map((device) => ( + + + {device.name} + {device.tokenPrefix}… + + {device.venueName} + + {device.revokedAt ? {t('admin.devices.unpaired')} : } + + {relativeTime(device.lastSeenAt)} + + {dateTime(device.tokenExpiresAt)} + + {device.pendingCommands || ''} + + {!device.revokedAt && ( + + )} + + + ))} + +
+
+ + + + {t('admin.codes.title')} + {t('admin.codes.desc')} + + + {(codes.data?.codes ?? []).length === 0 ? ( +

{t('admin.codes.none')}

+ ) : ( +
    + {codes.data!.codes.map((code) => ( +
  • + {code.code} + {code.venueName} + {t('admin.venues.pair_expires', { time: dateTime(code.expiresAt) })} + +
  • + ))} +
+ )} + +
+
+ + !open && !unpair.isPending && setUnpairing(null)} + title={t('settings.unpair_title', { name: unpairing?.name ?? '' })} + description={t('settings.unpair_desc')} + confirmLabel={t('settings.unpair')} + cancelLabel={t('common.cancel')} + variant="destructive" + loading={unpair.isPending} + onConfirm={() => unpairing && unpair.mutate(unpairing)} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx b/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx new file mode 100644 index 0000000..f14e5d0 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { RefreshCw } from 'lucide-react'; +import { adminApi } from '@/lib/api/client'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { LoadingState } from '@/components/loading-state'; +import { ErrorState } from '@/components/error-state'; +import { useLang } from '@/lib/i18n'; +import { formatBytes } from './shared'; + +const PAGE_SIZE = 50; +const ALL_VENUES = 'all'; + +export function AuditSection() { + const { t, te, dateTime } = useLang(); + const [venueId, setVenueId] = useState(ALL_VENUES); + const [action, setAction] = useState(''); + const [page, setPage] = useState(0); + + const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues }); + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['admin', 'audit', venueId, action.trim(), page], + queryFn: () => adminApi.audit({ + venueId: venueId === ALL_VENUES ? undefined : venueId, + action: action.trim() || undefined, + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }), + placeholderData: (previous) => previous, + }); + + const total = data?.total ?? 0; + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( +
+
+
+ +
+ { setAction(e.target.value); setPage(0); }} className="max-w-xs" /> + +
+ + {isLoading ? : error ? : ( +
+ + + + {t('admin.audit.when')} + {t('admin.devices.venue')} + {t('admin.audit.who')} + {t('admin.audit.action')} + {t('admin.audit.details')} + + + + {(data?.entries ?? []).map((entry) => { + const details = entry.details === null ? '' : typeof entry.details === 'string' ? entry.details : JSON.stringify(entry.details); + return ( + + {dateTime(entry.createdAt)} + {entry.venueName} + + {entry.actorName ?? {entry.actorId.slice(0, 8)}} + {entry.actorType} + + + {entry.action} + + + {details} + + + ); + })} + +
+
+ )} + +
+ {t('admin.audit.page', { page: page + 1, pages, total })} + + +
+
+ ); +} + +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +} + +export function HealthSection() { + const { t, te, relativeTime } = useLang(); + const { data, isLoading, error, refetch, isFetching } = useQuery({ + queryKey: ['admin', 'health'], + queryFn: adminApi.health, + refetchInterval: 30000, + }); + + if (isLoading) return ; + if (error || !data) return ; + + const stats: { label: string; value: string; bad?: boolean }[] = [ + { label: t('admin.health.database'), value: formatBytes(data.databaseBytes) }, + { label: t('admin.health.uptime'), value: formatUptime(data.uptimeSeconds) }, + { label: t('admin.health.connections'), value: String(data.realtimeConnections) }, + { + label: t('admin.health.migrations'), + value: data.migrations.failed.length > 0 ? t('admin.health.migrations_failed', { count: data.migrations.failed.length }) : String(data.migrations.applied), + bad: data.migrations.failed.length > 0, + }, + { + label: t('admin.health.expiry_job'), + value: data.commandExpiryLastRunAt ? relativeTime(data.commandExpiryLastRunAt) : t('admin.health.not_yet'), + }, + { label: t('admin.health.stuck'), value: String(data.stuckCommands), bad: data.stuckCommands > 0 }, + { label: t('admin.health.email'), value: data.emailConfigured ? t('admin.health.configured') : t('admin.health.not_configured'), bad: !data.emailConfigured }, + { label: t('admin.health.node'), value: data.nodeVersion }, + ]; + + return ( +
+
+ +
+
+ {stats.map((stat) => ( + + +

{stat.label}

+

{stat.value}

+
+
+ ))} +
+ + {data.migrations.failed.length > 0 && ( +

{t('admin.health.failed_list', { names: data.migrations.failed.join(', ') })}

+ )} + {data.migrations.latest && ( +

{t('admin.health.latest_migration')} {data.migrations.latest}

+ )} + + + + {t('admin.health.tables')} + + +
    + {data.tables.map(({ table, rows }) => ( +
  • + {table} + {rows.toLocaleString()} +
  • + ))} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx b/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx new file mode 100644 index 0000000..4774fa1 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { adminApi, PURGE_TARGETS, type AdminVenue, type PurgeTarget } from '@/lib/api/client'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { toast } from '@/components/ui/toast'; +import { useLang } from '@/lib/i18n'; +import { Checkbox, CountList, DangerDialog, formatBytes } from './shared'; + +const ALL_VENUES = 'all'; + +export function MaintenanceSection() { + return ( +
+ + + + +
+ ); +} + +function useFeedback() { + const queryClient = useQueryClient(); + const { t, te } = useLang(); + return { + onError: (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }), + refresh: () => queryClient.invalidateQueries({ queryKey: ['admin'] }), + }; +} + +function StuckCommandsCard() { + const { t } = useLang(); + const { onError, refresh } = useFeedback(); + const preview = useQuery({ queryKey: ['admin', 'stuck-commands'], queryFn: () => adminApi.expireStuckCommands(true) }); + const expire = useMutation({ + mutationFn: () => adminApi.expireStuckCommands(false), + onSuccess: ({ count }) => toast({ title: t('admin.stuck.done', { count }), variant: 'success' }), + onError, + onSettled: refresh, + }); + const count = preview.data?.count ?? 0; + + return ( + + + {t('admin.stuck.title')} + {t('admin.stuck.desc')} + + +

{preview.isLoading ? t('common.loading') : t('admin.stuck.count', { count })}

+ +
+
+ ); +} + +function ImagesCard() { + const { t } = useLang(); + const { onError, refresh } = useFeedback(); + const [confirming, setConfirming] = useState(false); + const preview = useQuery({ queryKey: ['admin', 'orphan-images'], queryFn: () => adminApi.cleanupImages(true) }); + const cleanup = useMutation({ + mutationFn: () => adminApi.cleanupImages(false), + onSuccess: ({ count }) => { + toast({ title: t('admin.images.done', { count }), variant: 'success' }); + setConfirming(false); + }, + onError, + onSettled: refresh, + }); + const count = preview.data?.count ?? 0; + + return ( + + + {t('admin.images.title')} + {t('admin.images.desc')} + + +

+ {preview.isLoading ? t('common.loading') : t('admin.images.count', { count, size: formatBytes(preview.data?.bytes ?? 0) })} +

+ +
+ cleanup.mutate()} + /> +
+ ); +} + +function PurgeCard() { + const { t, te } = useLang(); + const { onError, refresh } = useFeedback(); + const [targets, setTargets] = useState(['tabEvents', 'commands']); + const [days, setDays] = useState('90'); + const [venueId, setVenueId] = useState(ALL_VENUES); + const [confirming, setConfirming] = useState(false); + + const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues }); + const olderThanDays = parseInt(days, 10); + const valid = targets.length > 0 && Number.isInteger(olderThanDays) && olderThanDays >= 1; + const request = { targets, olderThanDays, venueId: venueId === ALL_VENUES ? undefined : venueId }; + + const preview = useQuery({ + queryKey: ['admin', 'purge-preview', [...targets].sort().join(','), olderThanDays, venueId], + queryFn: () => adminApi.purge({ ...request, dryRun: true }), + enabled: confirming && valid, + }); + const purge = useMutation({ + mutationFn: () => adminApi.purge({ ...request, dryRun: false }), + onSuccess: ({ counts }) => { + const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0); + toast({ title: t('admin.purge.done', { count: total }), variant: 'success' }); + setConfirming(false); + }, + onError, + onSettled: refresh, + }); + + function toggle(target: PurgeTarget, on: boolean) { + setTargets((current) => (on ? [...current, target] : current.filter((x) => x !== target))); + } + + return ( + + + {t('admin.purge.title')} + {t('admin.purge.desc')} + + +
+ {PURGE_TARGETS.map((target) => ( + toggle(target, on)} + label={t(`admin.purge.target.${target}`)} hint={t(`admin.purge.target.${target}_hint`)} /> + ))} +
+
+
+ + setDays(e.target.value)} className="w-28" /> +
+
+ + +
+
+ +
+ purge.mutate()} + > + {preview.data ? ( + t(`admin.purge.target.${target}`)} /> + ) : ( +

{preview.error ? te(preview.error) : t('common.loading')}

+ )} +
+
+ ); +} + +function TestEmailCard() { + const { t } = useLang(); + const { onError } = useFeedback(); + const [to, setTo] = useState(''); + const send = useMutation({ + mutationFn: () => adminApi.testEmail(to.trim()), + onSuccess: ({ email }) => toast({ title: t('admin.email.sent', { email }), variant: 'success' }), + onError, + }); + + return ( + + + {t('admin.email.title')} + {t('admin.email.desc')} + + +
{ e.preventDefault(); send.mutate(); }}> + setTo(e.target.value)} /> + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/admin/page.tsx b/apps/dashboard/src/app/dashboard/admin/page.tsx new file mode 100644 index 0000000..bae0b6e --- /dev/null +++ b/apps/dashboard/src/app/dashboard/admin/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; +import { Activity, Building2, Monitor, ScrollText, Users, Wrench } from 'lucide-react'; +import { PageHeader } from '@/components/page-header'; +import { ErrorState } from '@/components/error-state'; +import { cn } from '@/lib/cn'; +import { useLang } from '@/lib/i18n'; +import { useSession } from '@/lib/session'; +import { VenuesSection } from './venues-section'; +import { DevicesSection } from './devices-section'; +import { UsersSection } from './users-section'; +import { MaintenanceSection } from './maintenance-section'; +import { AuditSection, HealthSection } from './insight-sections'; + +const SECTIONS = [ + { key: 'venues', icon: Building2, Component: VenuesSection }, + { key: 'devices', icon: Monitor, Component: DevicesSection }, + { key: 'users', icon: Users, Component: UsersSection }, + { key: 'maintenance', icon: Wrench, Component: MaintenanceSection }, + { key: 'audit', icon: ScrollText, Component: AuditSection }, + { key: 'health', icon: Activity, Component: HealthSection }, +] as const; + +type SectionKey = (typeof SECTIONS)[number]['key']; + +const SECTION_KEY = 'kooltap-admin-section'; + +function storedSection(): SectionKey { + try { + const stored = localStorage.getItem(SECTION_KEY); + return SECTIONS.some((s) => s.key === stored) ? (stored as SectionKey) : 'venues'; + } catch { + return 'venues'; + } +} + +/** KoolTap's own admin page: every venue, terminal and user. Only for superadmins. */ +export default function AdminPage() { + const { role } = useSession(); + const { t } = useLang(); + const [section, setSection] = useState(storedSection); + + if (role !== 'superadmin') return ; + + function select(key: SectionKey) { + setSection(key); + try { + localStorage.setItem(SECTION_KEY, key); + } catch { + // Not remembered, but the section still opens + } + } + + const { Component } = SECTIONS.find((s) => s.key === section)!; + + return ( +
+ + +
+ {SECTIONS.map(({ key, icon: Icon }) => ( + + ))} +
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/admin/shared.tsx b/apps/dashboard/src/app/dashboard/admin/shared.tsx new file mode 100644 index 0000000..5e1090b --- /dev/null +++ b/apps/dashboard/src/app/dashboard/admin/shared.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { ReactNode, useEffect, useState } from 'react'; +import { adminApi } from '@/lib/api/client'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useLang } from '@/lib/i18n'; + +/** + * A confirmation for something that can't be undone. With [confirmText] the button only works once that text is + * typed, so a misclick or the wrong row can't wipe a venue. + */ +export function DangerDialog({ + open, + onOpenChange, + title, + description, + children, + confirmText, + confirmLabel, + loading, + disabled, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + children?: ReactNode; + confirmText?: string; + confirmLabel: string; + loading: boolean; + disabled?: boolean; + onConfirm: () => void; +}) { + const { t } = useLang(); + const [typed, setTyped] = useState(''); + + useEffect(() => { + if (open) setTyped(''); + }, [open]); + + const matches = !confirmText || typed.trim() === confirmText; + + return ( + !loading && onOpenChange(next)}> + + + {title} + {description} + + {children} + {confirmText && ( +
+ + setTyped(e.target.value)} autoComplete="off" /> +
+ )} + + + + +
+
+ ); +} + +export function Checkbox({ checked, onChange, label, hint }: { checked: boolean; onChange: (checked: boolean) => void; label: string; hint?: string }) { + return ( + + ); +} + +/** Downloads everything of the venue as a JSON file: the backup taken before a reset or delete. */ +export async function downloadVenueExport(venue: { id: string; name: string }) { + const data = await adminApi.exportVenue(venue.id); + const slug = venue.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'venue'; + const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-'); + const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); + const link = document.createElement('a'); + link.href = url; + link.download = `kooltap-${slug}-${stamp}.json`; + link.click(); + URL.revokeObjectURL(url); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; +} + +/** Counts from a dry run, one line each. */ +export function CountList({ counts, label }: { counts: Record; label: (key: string) => string }) { + const entries = Object.entries(counts).filter((entry): entry is [string, number] => entry[1] !== undefined); + return ( +
    + {entries.map(([key, count]) => ( +
  • + {label(key)} + {count.toLocaleString()} +
  • + ))} +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/admin/users-section.tsx b/apps/dashboard/src/app/dashboard/admin/users-section.tsx new file mode 100644 index 0000000..3bff47f --- /dev/null +++ b/apps/dashboard/src/app/dashboard/admin/users-section.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { KeyRound, LogOut, RefreshCw, Trash2 } from 'lucide-react'; +import { adminApi, type AdminUser, type Role } from '@/lib/api/client'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { LoadingState } from '@/components/loading-state'; +import { ErrorState } from '@/components/error-state'; +import { toast } from '@/components/ui/toast'; +import { useLang } from '@/lib/i18n'; +import { useSession } from '@/lib/session'; +import { DangerDialog } from './shared'; + +const ROLES: Role[] = ['manager', 'admin', 'superadmin']; + +export function UsersSection() { + const queryClient = useQueryClient(); + const { userId } = useSession(); + const { t, te, relativeTime } = useLang(); + const [search, setSearch] = useState(''); + const [action, setAction] = useState<{ kind: 'password' | 'logout' | 'delete'; user: AdminUser } | null>(null); + const [password, setPassword] = useState(''); + const [logoutAll, setLogoutAll] = useState(false); + + const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users }); + + const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] }); + const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); + const done = (title: string) => { + toast({ title, variant: 'success' }); + setAction(null); + refresh(); + }; + + const role = useMutation({ + mutationFn: ({ user, role }: { user: AdminUser; role: Role }) => adminApi.updateRole(user.id, role), + onSuccess: (_, { user, role }) => done(t('admin.users.role_changed', { email: user.email, role: t(`users.role.${role}`) })), + onError, + }); + const resetPassword = useMutation({ + mutationFn: (user: AdminUser) => adminApi.resetPassword(user.id, password), + onSuccess: (_, user) => done(t('admin.users.password_set', { email: user.email })), + onError, + }); + const logout = useMutation({ + mutationFn: (user: AdminUser) => adminApi.logoutUser(user.id), + onSuccess: (_, user) => done(t('admin.users.logged_out', { email: user.email })), + onError, + }); + const remove = useMutation({ + mutationFn: (user: AdminUser) => adminApi.deleteUser(user.id), + onSuccess: (_, user) => done(t('users.deleted', { email: user.email })), + onError, + }); + const everyone = useMutation({ + mutationFn: adminApi.logoutEveryone, + onSuccess: ({ users }) => { + toast({ title: t('admin.users.everyone_done', { count: users }), variant: 'success' }); + setLogoutAll(false); + refresh(); + }, + onError, + }); + + if (isLoading) return ; + if (error) return ; + + const query = search.trim().toLowerCase(); + const users = (data?.users ?? []).filter((u) => + !query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query))); + + return ( +
+
+ setSearch(e.target.value)} className="max-w-xs" /> +
+ + +
+
+ +
+ + + + {t('users.name')} + {t('admin.devices.venue')} + {t('users.role')} + {t('admin.users.sessions')} + {t('users.created')} + + + + + {users.map((user) => { + const self = user.id === userId; + return ( + + + {user.name} + {user.email} + + {user.venueName} + + {/* You can't change your own role, so there is always a superadmin left */} + + + {user.activeSessions} + {relativeTime(user.createdAt)} + + {/* Your own password is changed on My Account, which keeps you signed in */} + {!self && ( + <> + + + + + )} + + + ); + })} + +
+
+ + !open && !resetPassword.isPending && setAction(null)}> + + + {t('admin.users.password_title', { email: action?.user.email ?? '' })} + {t('admin.users.password_desc')} + +
+ + setPassword(e.target.value)} placeholder={t('users.password_placeholder')} /> +
+ + + + +
+
+ + !open && !logout.isPending && setAction(null)} + title={t('admin.users.logout_title', { email: action?.user.email ?? '' })} + description={t('admin.users.logout_desc')} + confirmLabel={t('admin.users.logout')} + cancelLabel={t('common.cancel')} + loading={logout.isPending} + onConfirm={() => action && logout.mutate(action.user)} + /> + + !open && !remove.isPending && setAction(null)} + title={t('users.delete_title', { name: action?.user.name ?? '' })} + description={t('users.delete_desc', { email: action?.user.email ?? '' })} + confirmLabel={t('users.delete')} + cancelLabel={t('common.cancel')} + variant="destructive" + loading={remove.isPending} + onConfirm={() => action && remove.mutate(action.user)} + /> + + everyone.mutate()} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/admin/venues-section.tsx b/apps/dashboard/src/app/dashboard/admin/venues-section.tsx new file mode 100644 index 0000000..7af9195 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/admin/venues-section.tsx @@ -0,0 +1,463 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { AlertTriangle, CheckCircle2, Copy, Download, Eye, Info, KeyRound, Pencil, Plus, RefreshCw, RotateCcw, Stethoscope, Trash2 } from 'lucide-react'; +import { adminApi, RESET_SCOPES, type AdminVenue, type Finding, type ResetScope } from '@/lib/api/client'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { LoadingState } from '@/components/loading-state'; +import { ErrorState } from '@/components/error-state'; +import { toast } from '@/components/ui/toast'; +import { useLang } from '@/lib/i18n'; +import { useSession, viewVenue } from '@/lib/session'; +import { Checkbox, CountList, DangerDialog, downloadVenueExport } from './shared'; + +// "Clear all tabs" and "full reset" are the common cases; any other mix can be ticked by hand +const PRESETS: { key: string; scopes: ResetScope[] }[] = [ + { key: 'admin.reset.preset_tabs', scopes: ['tabs'] }, + { key: 'admin.reset.preset_event', scopes: ['tabs', 'plannedTabs', 'stock'] }, + { key: 'admin.reset.preset_full', scopes: [...RESET_SCOPES] }, +]; + +export function VenuesSection() { + const queryClient = useQueryClient(); + const { t, te, relativeTime } = useLang(); + const [selectedId, setSelectedId] = useState(null); + const [creating, setCreating] = useState(false); + + const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues }); + const venues = data?.venues ?? []; + const selected = venues.find((v) => v.id === selectedId) ?? null; + + if (isLoading) return ; + if (error) return ; + + return ( +
+
+

{t('admin.venues.desc')}

+
+ + +
+
+ +
+ + + + {t('admin.venues.name')} + {t('admin.venues.users')} + {t('admin.venues.terminals')} + {t('admin.venues.open_tabs')} + {t('admin.venues.products')} + {t('admin.venues.last_sync')} + + + + {venues.map((venue) => ( + setSelectedId(venue.id === selectedId ? null : venue.id)} + > + {venue.name} + {venue.users} + {venue.devices} + {venue.openTabs} + {venue.products} + {venue.lastSyncAt ? relativeTime(venue.lastSyncAt) : t('admin.never')} + + ))} + +
+
+ + {selected ? ( + setSelectedId(null)} /> + ) : ( +

{t('admin.venues.pick')}

+ )} + + { + const venue = await adminApi.createVenue(name); + toast({ title: t('admin.venues.created', { name: venue.name }), variant: 'success' }); + setSelectedId(venue.id); + queryClient.invalidateQueries({ queryKey: ['admin'] }); + }} + /> +
+ ); +} + +function VenuePanel({ venue, venues, onDeleted }: { venue: AdminVenue; venues: AdminVenue[]; onDeleted: () => void }) { + const queryClient = useQueryClient(); + const { homeVenueId } = useSession(); + const { t, te, dateTime } = useLang(); + const [dialog, setDialog] = useState<'rename' | 'reset' | 'copy' | 'delete' | null>(null); + const [findings, setFindings] = useState(null); + const [pairCode, setPairCode] = useState<{ code: string; expiresAt: string } | null>(null); + const [busy, setBusy] = useState(null); + + const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] }); + + async function run(key: string, action: () => Promise) { + setBusy(key); + try { + await action(); + } catch (err) { + toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); + } finally { + setBusy(null); + } + } + + return ( + + + {venue.name} + {venue.id} + + +
+ + + + + + + + {venue.id !== homeVenueId && ( + + )} +
+ + {pairCode && ( +
+ {t('admin.venues.pair_code')} {pairCode.code} + {t('admin.venues.pair_expires', { time: dateTime(pairCode.expiresAt) })} +
+ )} + + {findings && } +
+ + setDialog(open ? 'rename' : null)} + title={t('admin.venues.rename_title', { name: venue.name })} + initial={venue.name} + onSave={async (name) => { + await adminApi.renameVenue(venue.id, name); + toast({ title: t('admin.venues.renamed'), variant: 'success' }); + refresh(); + }} + /> + setDialog(open ? 'reset' : null)} venue={venue} onDone={refresh} /> + setDialog(open ? 'copy' : null)} venue={venue} venues={venues} onDone={refresh} /> + setDialog(open ? 'delete' : null)} + venue={venue} + onDone={() => { + onDeleted(); + refresh(); + }} + /> +
+ ); +} + +const SEVERITY_ICON = { error: AlertTriangle, warning: AlertTriangle, info: Info }; +const SEVERITY_BADGE = { error: 'destructive', warning: 'warning', info: 'info' } as const; + +function Findings({ findings }: { findings: Finding[] }) { + const { t } = useLang(); + if (findings.length === 0) { + return ( +

+ {t('admin.check.clean')} +

+ ); + } + return ( +
    + {findings.map((finding, index) => { + const Icon = SEVERITY_ICON[finding.severity]; + return ( +
  • + + {t(`admin.check.severity.${finding.severity}`)} + {finding.message} +
  • + ); + })} +
+ ); +} + +function NameDialog({ open, onOpenChange, title, initial, onSave }: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + initial: string; + onSave: (name: string) => Promise; +}) { + const { t, te } = useLang(); + const [name, setName] = useState(initial); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) setName(initial); + }, [open, initial]); + + async function save() { + setSaving(true); + try { + await onSave(name.trim()); + onOpenChange(false); + } catch (err) { + toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); + } finally { + setSaving(false); + } + } + + return ( + + + + {title} + +
+ + setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && name.trim() && save()} /> +
+ + + + +
+
+ ); +} + +function ResetDialog({ open, onOpenChange, venue, onDone }: { open: boolean; onOpenChange: (open: boolean) => void; venue: AdminVenue; onDone: () => void }) { + const { t, te } = useLang(); + const [scopes, setScopes] = useState(['tabs']); + const [stockValue, setStockValue] = useState('0'); + const [exportFirst, setExportFirst] = useState(true); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + setScopes(['tabs']); + setStockValue('0'); + setExportFirst(true); + } + }, [open]); + + const stock = parseInt(stockValue, 10); + const stockValid = !scopes.includes('stock') || (Number.isInteger(stock) && stock >= 0); + + // A fresh dry run whenever the selection changes, so the numbers shown are what will go + const preview = useQuery({ + queryKey: ['admin', 'reset-preview', venue.id, [...scopes].sort().join(',')], + queryFn: () => adminApi.resetVenue(venue.id, { scopes, dryRun: true }), + enabled: open && scopes.length > 0, + }); + + function toggle(scope: ResetScope, on: boolean) { + setScopes((current) => (on ? [...current, scope] : current.filter((s) => s !== scope))); + } + + async function confirm() { + setLoading(true); + try { + if (exportFirst) await downloadVenueExport(venue); + const { counts } = await adminApi.resetVenue(venue.id, { scopes, stockValue: stock, dryRun: false }); + const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0); + toast({ title: t('admin.reset.done', { name: venue.name }), description: t('admin.reset.done_desc', { count: total }), variant: 'success' }); + onOpenChange(false); + onDone(); + } catch (err) { + toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); + } finally { + setLoading(false); + } + } + + return ( + +
+
+ {PRESETS.map((preset) => ( + + ))} +
+
+ {RESET_SCOPES.map((scope) => ( + toggle(scope, on)} + label={t(`admin.reset.scope.${scope}`)} + hint={t(`admin.reset.scope.${scope}_hint`)} + /> + ))} +
+ {scopes.includes('stock') && ( +
+ + setStockValue(e.target.value)} className="w-32" /> +
+ )} + {scopes.length > 0 && ( +
+

{t('admin.preview')}

+ {preview.data ? ( + t(`admin.reset.scope.${scope}`)} /> + ) : ( +

{preview.error ? te(preview.error) : t('common.loading')}

+ )} +
+ )} + +
+
+ ); +} + +function CopyCatalogDialog({ open, onOpenChange, venue, venues, onDone }: { + open: boolean; + onOpenChange: (open: boolean) => void; + venue: AdminVenue; + venues: AdminVenue[]; + onDone: () => void; +}) { + const { t, te } = useLang(); + const [fromId, setFromId] = useState(''); + const others = venues.filter((v) => v.id !== venue.id); + + const mutation = useMutation({ + mutationFn: () => adminApi.copyCatalog(venue.id, fromId), + onSuccess: ({ created }) => { + toast({ title: t('admin.copy.done', { count: created }), variant: 'success' }); + onOpenChange(false); + onDone(); + }, + onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }), + }); + + return ( + !mutation.isPending && onOpenChange(next)}> + + + {t('admin.copy.title', { name: venue.name })} + {t('admin.copy.desc')} + + + + + + + + + ); +} + +function DeleteVenueDialog({ open, onOpenChange, venue, onDone }: { open: boolean; onOpenChange: (open: boolean) => void; venue: AdminVenue; onDone: () => void }) { + const { t, te } = useLang(); + const [exportFirst, setExportFirst] = useState(true); + const [loading, setLoading] = useState(false); + + async function confirm() { + setLoading(true); + try { + if (exportFirst) await downloadVenueExport(venue); + await adminApi.deleteVenue(venue.id, venue.name); + toast({ title: t('admin.delete.done', { name: venue.name }), variant: 'success' }); + onOpenChange(false); + onDone(); + } catch (err) { + toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); + } finally { + setLoading(false); + } + } + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx index d1b1003..bd5dde3 100644 --- a/apps/dashboard/src/app/dashboard/layout.tsx +++ b/apps/dashboard/src/app/dashboard/layout.tsx @@ -15,6 +15,8 @@ import { Bell, Package, UserCog, + ShieldAlert, + Eye, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/cn'; @@ -23,18 +25,22 @@ import { LanguageSelector } from '@/components/language-selector'; import { authApi, venuesApi } from '@/lib/api/client'; import { toast } from '@/components/ui/toast'; import { RealtimeProvider, useRealtime } from '@/lib/realtime'; -import { readSession, Session, SessionProvider } from '@/lib/session'; +import { canAdministerVenue, readSession, Session, SessionProvider, viewVenue } from '@/lib/session'; import { LoadingState } from '@/components/loading-state'; -const navItems = [ - { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false }, - { href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false }, - { href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false }, - { href: '/dashboard/stock', key: 'nav.stock', icon: Package, adminOnly: false }, - { 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 }, +// Who sees each item: everyone, admins (and superadmins), or superadmins only +type Access = 'all' | 'admin' | 'superadmin'; + +const navItems: { href: string; key: string; icon: typeof LayoutDashboard; access: Access }[] = [ + { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' }, + { href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, access: 'all' }, + { href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, access: 'all' }, + { href: '/dashboard/stock', key: 'nav.stock', icon: Package, access: 'all' }, + { href: '/dashboard/users', key: 'nav.users', icon: Monitor, access: 'admin' }, + { href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, access: 'all' }, + { href: '/dashboard/settings', key: 'nav.settings', icon: Settings, access: 'all' }, + { href: '/dashboard/account', key: 'nav.account', icon: UserCog, access: 'all' }, + { href: '/dashboard/admin', key: 'nav.admin', icon: ShieldAlert, access: 'superadmin' }, ]; export default function DashboardLayout({ @@ -108,9 +114,8 @@ export default function DashboardLayout({ const Icon = item.icon; const active = pathname === item.href; - if (item.adminOnly && user.role !== 'admin') { - return null; - } + if (item.access === 'admin' && !canAdministerVenue(user.role)) return null; + if (item.access === 'superadmin' && user.role !== 'superadmin') return null; return ( + {user.viewing && ( +
+ + {t('admin.viewing_banner', { name: user.viewing.name })} + +
+ )}
{children}
diff --git a/apps/dashboard/src/app/dashboard/reminders/page.tsx b/apps/dashboard/src/app/dashboard/reminders/page.tsx index 227b3d5..ac32acb 100644 --- a/apps/dashboard/src/app/dashboard/reminders/page.tsx +++ b/apps/dashboard/src/app/dashboard/reminders/page.tsx @@ -14,7 +14,7 @@ import { EmptyState } from '@/components/empty-state'; import { toast } from '@/components/ui/toast'; import { formatCents } from '@/lib/utils'; import { useLang } from '@/lib/i18n'; -import { useSession } from '@/lib/session'; +import { canManageVenue, useSession } from '@/lib/session'; import { Send, History, Search } from 'lucide-react'; const DAY_MS = 24 * 60 * 60 * 1000; @@ -29,7 +29,7 @@ export default function RemindersPage() { const { t, tn, te, relativeTime, dateTime } = useLang(); const { venueId, role } = useSession(); // Admins and managers both run the venue - const canManage = role === 'admin' || role === 'manager'; + const canManage = canManageVenue(role); const [sendDialogOpen, setSendDialogOpen] = useState(false); const [showHistory, setShowHistory] = useState(false); const [search, setSearch] = useState(''); diff --git a/apps/dashboard/src/app/dashboard/settings/page.tsx b/apps/dashboard/src/app/dashboard/settings/page.tsx index ec33f01..e832238 100644 --- a/apps/dashboard/src/app/dashboard/settings/page.tsx +++ b/apps/dashboard/src/app/dashboard/settings/page.tsx @@ -11,11 +11,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { ConfirmDialog } from '@/components/confirm-dialog'; import { toast } from '@/components/ui/toast'; import { useLang } from '@/lib/i18n'; -import { useSession } from '@/lib/session'; +import { canAdministerVenue, useSession } from '@/lib/session'; import { Monitor, Copy, Plus } from 'lucide-react'; export default function SettingsPage() { - const { venueId, role } = useSession(); + const { venueId, role, viewing } = useSession(); + const isAdmin = canAdministerVenue(role); const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null); const [generating, setGenerating] = useState(false); const [unpairing, setUnpairing] = useState(null); @@ -49,7 +50,7 @@ export default function SettingsPage() { async function handleGenerateCode() { setGenerating(true); try { - const result = await devicesApi.generateCode(); + const result = await devicesApi.generateCode(viewing?.id); setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt }); } catch (err) { toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); @@ -100,7 +101,7 @@ export default function SettingsPage() { {device.name} {t('settings.since', { date: dateTime(device.createdAt) })} - {role === 'admin' && ( + {isAdmin && ( @@ -113,7 +114,7 @@ export default function SettingsPage() { {t('settings.pair_desc')}

)} - {role === 'admin' && ( + {isAdmin && ( - {role === 'admin' && ( + {isAdmin && ( @@ -95,7 +96,7 @@ export default function UsersPage() { {t('users.email')} {t('users.role')} {t('users.created')} - {role === 'admin' && {t('tabs.actions')}} + {isAdmin && {t('tabs.actions')}} @@ -103,9 +104,9 @@ export default function UsersPage() { {u.name} {u.email} - {t(`users.role.${u.role}`)} + {t(`users.role.${u.role}`)} {relativeTime(u.createdAt)} - {role === 'admin' && ( + {isAdmin && ( {/* You can't delete yourself, so the venue always keeps an admin */} {u.id !== userId && ( diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index 049c6d9..b90e4ff 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -5,8 +5,11 @@ export interface ApiError { message: string; } -/** admin: the owner (users, terminals); manager: runs the venue (products, stock, tabs, reminders) */ -export type Role = 'admin' | 'manager'; +/** + * admin: the owner (users, terminals); manager: runs the venue (products, stock, tabs, reminders); superadmin: runs + * KoolTap itself (every venue, and the admin page) + */ +export type Role = 'admin' | 'manager' | 'superadmin'; export type TabLine = { productName: string; quantity: number; unitPriceInCents: number }; @@ -288,7 +291,9 @@ export const devicesApi = { api<{ devices: Device[] }>(`/venues/${venueId}/devices`), unpair: (venueId: string, deviceId: string) => api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }), - generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'), + /** [venueId]: the venue a superadmin is viewing; others always pair for their own venue */ + generateCode: (venueId?: string) => + api<{ code: string; expiresAt: string }>(`/devices/generation-code${venueId ? `?venueId=${encodeURIComponent(venueId)}` : ''}`), }; // Users API @@ -397,6 +402,151 @@ export const stockApi = { }), }; +// Superadmin API: every venue, terminal and user. Destructive calls take dryRun to preview what they would change + +export const RESET_SCOPES = ['tabs', 'settlements', 'tabEvents', 'plannedTabs', 'stock', 'stockHistory', 'commands', 'auditLog'] as const; +export type ResetScope = (typeof RESET_SCOPES)[number]; + +export const PURGE_TARGETS = ['tabEvents', 'auditLog', 'commands'] as const; +export type PurgeTarget = (typeof PURGE_TARGETS)[number]; + +export interface AdminVenue { + id: string; + name: string; + createdAt: string; + users: number; + devices: number; + openTabs: number; + products: number; + plannedTabs: number; + settlements: number; + /** The latest sync of any of its terminals */ + lastSyncAt: string | null; +} + +export interface AdminDevice { + id: string; + name: string; + venueId: string; + venueName: string; + tokenPrefix: string; + tokenExpiresAt: string; + lastSeenAt: string; + revokedAt: string | null; + createdAt: string; + pendingCommands: number; +} + +export interface AdminUser { + id: string; + email: string; + name: string; + role: Role; + venueId: string; + venueName: string; + createdAt: string; + activeSessions: number; +} + +export interface RegistrationCode { + id: string; + code: string; + venueId: string; + venueName: string; + expiresAt: string; + createdAt: string; +} + +export interface AuditEntry { + id: string; + venueId: string; + venueName: string; + actorType: string; + actorId: string; + /** The user's email or the terminal's name; null when it no longer exists */ + actorName: string | null; + action: string; + details: unknown; + createdAt: string; +} + +export type FindingSeverity = 'error' | 'warning' | 'info'; + +export interface Finding { + severity: FindingSeverity; + code: string; + message: string; + subjectId: string; +} + +export interface SystemHealth { + checkedAt: string; + uptimeSeconds: number; + nodeVersion: string; + databaseBytes: number; + tables: { table: string; rows: number }[]; + migrations: { applied: number; latest: string | null; failed: string[] }; + realtimeConnections: number; + /** Null until the hourly job ran since the server started */ + commandExpiryLastRunAt: string | null; + stuckCommands: number; + emailConfigured: boolean; +} + +const post = (endpoint: string, body: unknown = {}) => + api(endpoint, { method: 'POST', body: JSON.stringify(body) }); + +export const adminApi = { + venues: () => api<{ venues: AdminVenue[] }>('/admin/venues'), + createVenue: (name: string) => post<{ id: string; name: string }>('/admin/venues', { name }), + renameVenue: (venueId: string, name: string) => + api(`/admin/venues/${venueId}`, { method: 'PATCH', body: JSON.stringify({ name }) }), + /** [confirmName] must be the venue's name */ + deleteVenue: (venueId: string, confirmName: string) => + api(`/admin/venues/${venueId}?confirmName=${encodeURIComponent(confirmName)}`, { method: 'DELETE' }), + viewVenue: (venueId: string) => post<{ id: string; name: string }>(`/admin/venues/${venueId}/view`), + copyCatalog: (venueId: string, fromVenueId: string) => + post<{ created: number }>(`/admin/venues/${venueId}/copy-catalog`, { fromVenueId }), + resetVenue: (venueId: string, data: { scopes: ResetScope[]; stockValue?: number; dryRun: boolean }) => + post<{ dryRun: boolean; counts: Partial> }>(`/admin/venues/${venueId}/reset`, data), + consistency: (venueId: string) => + api<{ checkedAt: string; findings: Finding[] }>(`/admin/venues/${venueId}/consistency`), + exportVenue: (venueId: string) => api>(`/admin/venues/${venueId}/export`), + createRegistrationCode: (venueId: string) => + post<{ code: string; expiresAt: string }>(`/admin/venues/${venueId}/registration-codes`), + + devices: () => api<{ devices: AdminDevice[] }>('/admin/devices'), + revokeDevice: (deviceId: string) => post(`/admin/devices/${deviceId}/revoke`), + registrationCodes: () => api<{ codes: RegistrationCode[]; stale: number }>('/admin/registration-codes'), + revokeRegistrationCode: (id: string) => post(`/admin/registration-codes/${id}/revoke`), + cleanupRegistrationCodes: () => post<{ deleted: number }>('/admin/registration-codes/cleanup'), + expireStuckCommands: (dryRun: boolean) => post<{ dryRun: boolean; count: number }>('/admin/commands/expire-stuck', { dryRun }), + + users: () => api<{ users: AdminUser[] }>('/admin/users'), + updateRole: (userId: string, role: Role) => + api(`/admin/users/${userId}/role`, { method: 'PATCH', body: JSON.stringify({ role }) }), + resetPassword: (userId: string, password: string) => post(`/admin/users/${userId}/password`, { password }), + logoutUser: (userId: string) => post(`/admin/users/${userId}/logout`), + deleteUser: (userId: string) => api(`/admin/users/${userId}`, { method: 'DELETE' }), + logoutEveryone: () => post<{ users: number }>('/admin/sessions/revoke-all'), + + purge: (data: { targets: PurgeTarget[]; olderThanDays: number; venueId?: string; dryRun: boolean }) => + post<{ dryRun: boolean; counts: Partial> }>('/admin/purge', data), + cleanupImages: (dryRun: boolean) => post<{ dryRun: boolean; count: number; bytes?: number }>('/admin/images/cleanup', { dryRun }), + testEmail: (to: string) => post<{ ok: boolean; email: string }>('/admin/email/test', { to }), + + audit: (params: { venueId?: string; action?: string; limit?: number; offset?: number }) => { + const searchParams = new URLSearchParams(); + if (params.venueId) searchParams.set('venueId', params.venueId); + if (params.action) searchParams.set('action', params.action); + if (params.limit) searchParams.set('limit', String(params.limit)); + if (params.offset) searchParams.set('offset', String(params.offset)); + const qs = searchParams.toString(); + return api<{ entries: AuditEntry[]; total: number }>(`/admin/audit${qs ? `?${qs}` : ''}`); + }, + health: () => api('/admin/health'), +}; + export { setTokens, clearTokens, refreshAccessToken }; export function getAccessToken(): string | null { diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx index 33ed355..1dfe25a 100644 --- a/apps/dashboard/src/lib/i18n.tsx +++ b/apps/dashboard/src/lib/i18n.tsx @@ -392,6 +392,175 @@ const translations: Record> = { 'stock.status.cancelled': 'Cancelled', 'stock.reason.unknown_product': 'product not on the terminal', 'stock.reason.stock_not_tracked': 'stock not tracked on the terminal', + 'nav.admin': 'KoolTap Admin', + 'users.role.superadmin': 'Superadmin', + 'errors.cannot_delete_own_venue': 'You cannot delete the venue your account belongs to', + 'errors.confirm_name_mismatch': 'Type the venue name exactly to confirm', + 'errors.same_venue': 'Pick another venue to copy from', + 'errors.cannot_change_own_role': 'You cannot change your own role', + 'errors.cannot_logout_self': 'Use Logout to sign yourself out', + 'errors.use_account_page': 'Change your own password on My Account', + 'errors.venue_not_found': 'This venue no longer exists', + 'errors.device_not_found': 'This terminal no longer exists', + 'errors.email_failed': 'The email could not be sent', + 'admin.title': 'KoolTap Admin', + 'admin.desc': 'Every venue, terminal and user. Changes here cannot be undone.', + 'admin.section.venues': 'Venues', + 'admin.section.devices': 'Terminals', + 'admin.section.users': 'Users', + 'admin.section.maintenance': 'Maintenance', + 'admin.section.audit': 'Audit log', + 'admin.section.health': 'System', + 'admin.working': 'Working...', + 'admin.save': 'Save', + 'admin.never': 'never', + 'admin.preview': 'This will remove:', + 'admin.all_venues': 'All venues', + 'admin.type_to_confirm': 'Type {text} to confirm', + 'admin.export_first': 'Download a backup (JSON) first', + 'admin.viewing_banner': 'You are viewing {name} as a KoolTap admin. Everything you do here happens in this venue.', + 'admin.viewing_stop': 'Back to KoolTap Admin', + 'admin.venues.desc': 'Pick a venue to see what you can do with it.', + 'admin.venues.pick': 'Pick a venue above.', + 'admin.venues.create': 'New venue', + 'admin.venues.created': '{name} created', + 'admin.venues.name': 'Name', + 'admin.venues.users': 'Users', + 'admin.venues.terminals': 'Terminals', + 'admin.venues.open_tabs': 'Open tabs', + 'admin.venues.products': 'Products', + 'admin.venues.last_sync': 'Last sync', + 'admin.venues.view': 'View as venue', + 'admin.venues.check': 'Check data', + 'admin.venues.export': 'Export', + 'admin.venues.pair': 'Pairing code', + 'admin.venues.pair_code': 'Pairing code:', + 'admin.venues.pair_expires': 'valid until {time}', + 'admin.venues.rename': 'Rename', + 'admin.venues.rename_title': 'Rename {name}', + 'admin.venues.renamed': 'Venue renamed', + 'admin.venues.copy_catalog': 'Copy products', + 'admin.venues.reset': 'Reset…', + 'admin.venues.delete': 'Delete…', + 'admin.check.clean': 'Nothing found: the data adds up.', + 'admin.check.severity.error': 'Error', + 'admin.check.severity.warning': 'Warning', + 'admin.check.severity.info': 'Note', + 'admin.reset.title': 'Reset {name}', + 'admin.reset.desc': 'Users, terminals and products stay. Terminals pick up the change on their next sync.', + 'admin.reset.preset_tabs': 'Clear all tabs', + 'admin.reset.preset_event': 'New event', + 'admin.reset.preset_full': 'Full reset', + 'admin.reset.scope.tabs': 'Open tabs', + 'admin.reset.scope.tabs_hint': 'Unpaid drinks on them are lost', + 'admin.reset.scope.settlements': 'Paid tabs', + 'admin.reset.scope.settlements_hint': 'The sales history', + 'admin.reset.scope.tabEvents': 'Terminal events', + 'admin.reset.scope.tabEvents_hint': 'Only when every terminal has synced: an event sent again would count twice', + 'admin.reset.scope.plannedTabs': 'Planned customers', + 'admin.reset.scope.plannedTabs_hint': 'With their email addresses and reminders', + 'admin.reset.scope.stock': 'Stock', + 'admin.reset.scope.stock_hint': 'Every tracked product gets the same count', + 'admin.reset.scope.stockHistory': 'Stock history', + 'admin.reset.scope.stockHistory_hint': 'Deliveries, breakage and counts', + 'admin.reset.scope.commands': 'Command history', + 'admin.reset.scope.commands_hint': 'Tabs closed from the dashboard', + 'admin.reset.scope.auditLog': 'Audit log', + 'admin.reset.scope.auditLog_hint': 'An entry for this reset is kept', + 'admin.reset.stock_value': 'Stock count', + 'admin.reset.confirm': 'Reset', + 'admin.reset.done': '{name} is reset', + 'admin.reset.done_desc': '{count} rows changed', + 'admin.copy.title': 'Copy products into {name}', + 'admin.copy.desc': 'Products this venue doesn\'t have yet (by name) are added, with their pictures. Stock is not copied.', + 'admin.copy.from': 'Copy from…', + 'admin.copy.option': '{name} ({count} products)', + 'admin.copy.confirm': 'Copy', + 'admin.copy.done': '{count} products added', + 'admin.delete.title': 'Delete {name}?', + 'admin.delete.desc': 'The venue is deleted with everything in it: {users} users, {terminals} terminals and {settlements} paid tabs. Its terminals stop working.', + 'admin.delete.confirm': 'Delete venue', + 'admin.delete.done': '{name} is deleted', + 'admin.devices.show_unpaired': 'Show unpaired terminals ({count})', + 'admin.devices.name': 'Terminal', + 'admin.devices.venue': 'Venue', + 'admin.devices.status': 'Status', + 'admin.devices.last_seen': 'Last seen', + 'admin.devices.token_expires': 'Token valid until', + 'admin.devices.pending': 'Pending commands', + 'admin.devices.unpaired': 'Unpaired', + 'admin.codes.title': 'Pairing codes', + 'admin.codes.desc': 'Codes that can still be used to pair a terminal.', + 'admin.codes.none': 'No codes waiting to be used.', + 'admin.codes.revoke': 'Revoke', + 'admin.codes.cleanup': 'Delete used and expired codes ({count})', + 'admin.codes.cleaned': '{count} codes deleted', + 'admin.users.search': 'Search name, email or venue', + 'admin.users.sessions': 'Sessions', + 'admin.users.role_changed': '{email} is now {role}', + 'admin.users.password': 'Set password', + 'admin.users.password_title': 'New password for {email}', + 'admin.users.password_desc': 'They are signed out everywhere and log in with this password.', + 'admin.users.password_confirm': 'Set password', + 'admin.users.password_set': 'Password of {email} changed', + 'admin.users.logout': 'Sign out everywhere', + 'admin.users.logout_title': 'Sign out {email}?', + 'admin.users.logout_desc': 'Every session of this user ends right away. They can log in again.', + 'admin.users.logged_out': '{email} is signed out', + 'admin.users.everyone': 'Sign out everyone', + 'admin.users.everyone_title': 'Sign out every user?', + 'admin.users.everyone_desc': 'Everyone but you is signed out right away, in every venue. Terminals keep working.', + 'admin.users.everyone_done': '{count} users signed out', + 'admin.stuck.title': 'Stuck commands', + 'admin.stuck.desc': 'Close requests no terminal picked up. The server settles tabs itself now, so these will never run.', + 'admin.stuck.count': 'Pending: {count}', + 'admin.stuck.confirm': 'Expire all', + 'admin.stuck.done': '{count} commands expired', + 'admin.images.title': 'Unused pictures', + 'admin.images.desc': 'Product pictures no product uses anymore, older than a week.', + 'admin.images.count': '{count} pictures, {size}', + 'admin.images.confirm': 'Delete them', + 'admin.images.done': '{count} pictures deleted', + 'admin.purge.title': 'Delete old history', + 'admin.purge.desc': 'Keeps the database small. Open tabs, sales and stock are never touched.', + 'admin.purge.target.tabEvents': 'Terminal events', + 'admin.purge.target.tabEvents_hint': 'What the terminals reported, drink by drink', + 'admin.purge.target.auditLog': 'Audit log', + 'admin.purge.target.auditLog_hint': 'Who did what on the dashboard', + 'admin.purge.target.commands': 'Finished commands', + 'admin.purge.target.commands_hint': 'Pending ones are kept', + 'admin.purge.days': 'Older than (days)', + 'admin.purge.preview': 'Preview…', + 'admin.purge.confirm_desc': 'Everything older than {days} days:', + 'admin.purge.confirm': 'Delete', + 'admin.purge.done': '{count} rows deleted', + 'admin.email.title': 'Test email', + 'admin.email.desc': 'Sends the test email, to check that sending reminders works.', + 'admin.email.send': 'Send', + 'admin.email.sent': 'Test email sent to {email}', + 'admin.audit.action_filter': 'Filter on action', + 'admin.audit.when': 'When', + 'admin.audit.who': 'Who', + 'admin.audit.action': 'Action', + 'admin.audit.details': 'Details', + 'admin.audit.page': 'Page {page} of {pages} ({total} entries)', + 'admin.audit.previous': 'Previous', + 'admin.audit.next': 'Next', + 'admin.health.database': 'Database size', + 'admin.health.uptime': 'API uptime', + 'admin.health.connections': 'Live dashboards', + 'admin.health.migrations': 'Migrations applied', + 'admin.health.migrations_failed': '{count} failed', + 'admin.health.failed_list': 'Failed migrations: {names}', + 'admin.health.latest_migration': 'Latest migration:', + 'admin.health.expiry_job': 'Command expiry job', + 'admin.health.not_yet': 'not yet (hourly)', + 'admin.health.stuck': 'Stuck commands', + 'admin.health.email': 'Email', + 'admin.health.configured': 'Set up', + 'admin.health.not_configured': 'Not set up', + 'admin.health.node': 'Node.js', + 'admin.health.tables': 'Rows per table', }, nl: { 'nav.overview': 'Overzicht', @@ -781,6 +950,175 @@ const translations: Record> = { 'stock.status.cancelled': 'Geannuleerd', 'stock.reason.unknown_product': 'product staat niet op de kassa', 'stock.reason.stock_not_tracked': 'voorraad niet bijgehouden op de kassa', + 'nav.admin': 'KoolTap-beheer', + 'users.role.superadmin': 'Superadmin', + 'errors.cannot_delete_own_venue': 'Je kan de zaak van je eigen account niet verwijderen', + 'errors.confirm_name_mismatch': 'Typ de naam van de zaak exact om te bevestigen', + 'errors.same_venue': 'Kies een andere zaak om van te kopiëren', + 'errors.cannot_change_own_role': 'Je kan je eigen rol niet wijzigen', + 'errors.cannot_logout_self': 'Gebruik Uitloggen om jezelf af te melden', + 'errors.use_account_page': 'Wijzig je eigen wachtwoord via Mijn account', + 'errors.venue_not_found': 'Deze zaak bestaat niet meer', + 'errors.device_not_found': 'Deze kassa bestaat niet meer', + 'errors.email_failed': 'De e-mail kon niet verstuurd worden', + 'admin.title': 'KoolTap-beheer', + 'admin.desc': 'Alle zaken, kassa\'s en gebruikers. Wat je hier doet, kan je niet ongedaan maken.', + 'admin.section.venues': 'Zaken', + 'admin.section.devices': 'Kassa\'s', + 'admin.section.users': 'Gebruikers', + 'admin.section.maintenance': 'Onderhoud', + 'admin.section.audit': 'Logboek', + 'admin.section.health': 'Systeem', + 'admin.working': 'Bezig...', + 'admin.save': 'Opslaan', + 'admin.never': 'nooit', + 'admin.preview': 'Dit wordt verwijderd:', + 'admin.all_venues': 'Alle zaken', + 'admin.type_to_confirm': 'Typ {text} om te bevestigen', + 'admin.export_first': 'Eerst een back-up (JSON) downloaden', + 'admin.viewing_banner': 'Je bekijkt {name} als KoolTap-beheerder. Alles wat je hier doet, gebeurt in deze zaak.', + 'admin.viewing_stop': 'Terug naar KoolTap-beheer', + 'admin.venues.desc': 'Kies een zaak om te zien wat je ermee kan doen.', + 'admin.venues.pick': 'Kies hierboven een zaak.', + 'admin.venues.create': 'Nieuwe zaak', + 'admin.venues.created': '{name} aangemaakt', + 'admin.venues.name': 'Naam', + 'admin.venues.users': 'Gebruikers', + 'admin.venues.terminals': 'Kassa\'s', + 'admin.venues.open_tabs': 'Open poefs', + 'admin.venues.products': 'Producten', + 'admin.venues.last_sync': 'Laatste sync', + 'admin.venues.view': 'Bekijk als zaak', + 'admin.venues.check': 'Gegevens controleren', + 'admin.venues.export': 'Exporteren', + 'admin.venues.pair': 'Koppelcode', + 'admin.venues.pair_code': 'Koppelcode:', + 'admin.venues.pair_expires': 'geldig tot {time}', + 'admin.venues.rename': 'Hernoemen', + 'admin.venues.rename_title': '{name} hernoemen', + 'admin.venues.renamed': 'Zaak hernoemd', + 'admin.venues.copy_catalog': 'Producten kopiëren', + 'admin.venues.reset': 'Resetten…', + 'admin.venues.delete': 'Verwijderen…', + 'admin.check.clean': 'Niets gevonden: de gegevens kloppen.', + 'admin.check.severity.error': 'Fout', + 'admin.check.severity.warning': 'Waarschuwing', + 'admin.check.severity.info': 'Opmerking', + 'admin.reset.title': '{name} resetten', + 'admin.reset.desc': 'Gebruikers, kassa\'s en producten blijven. De kassa\'s nemen de wijziging over bij hun volgende sync.', + 'admin.reset.preset_tabs': 'Alle poefs wissen', + 'admin.reset.preset_event': 'Nieuw evenement', + 'admin.reset.preset_full': 'Volledige reset', + 'admin.reset.scope.tabs': 'Open poefs', + 'admin.reset.scope.tabs_hint': 'Onbetaalde drankjes erop gaan verloren', + 'admin.reset.scope.settlements': 'Betaalde poefs', + 'admin.reset.scope.settlements_hint': 'De verkoopgeschiedenis', + 'admin.reset.scope.tabEvents': 'Kassagebeurtenissen', + 'admin.reset.scope.tabEvents_hint': 'Alleen als elke kassa gesynct is: een opnieuw verstuurde gebeurtenis telt anders dubbel', + 'admin.reset.scope.plannedTabs': 'Geplande klanten', + 'admin.reset.scope.plannedTabs_hint': 'Met hun e-mailadressen en herinneringen', + 'admin.reset.scope.stock': 'Voorraad', + 'admin.reset.scope.stock_hint': 'Elk opgevolgd product krijgt dezelfde telling', + 'admin.reset.scope.stockHistory': 'Voorraadgeschiedenis', + 'admin.reset.scope.stockHistory_hint': 'Leveringen, breuk en tellingen', + 'admin.reset.scope.commands': 'Opdrachtengeschiedenis', + 'admin.reset.scope.commands_hint': 'Poefs afgesloten via het dashboard', + 'admin.reset.scope.auditLog': 'Logboek', + 'admin.reset.scope.auditLog_hint': 'Een regel voor deze reset blijft bewaard', + 'admin.reset.stock_value': 'Voorraadtelling', + 'admin.reset.confirm': 'Resetten', + 'admin.reset.done': '{name} is gereset', + 'admin.reset.done_desc': '{count} rijen gewijzigd', + 'admin.copy.title': 'Producten kopiëren naar {name}', + 'admin.copy.desc': 'Producten die deze zaak nog niet heeft (op naam) worden toegevoegd, met hun foto. De voorraad wordt niet gekopieerd.', + 'admin.copy.from': 'Kopiëren van…', + 'admin.copy.option': '{name} ({count} producten)', + 'admin.copy.confirm': 'Kopiëren', + 'admin.copy.done': '{count} producten toegevoegd', + 'admin.delete.title': '{name} verwijderen?', + 'admin.delete.desc': 'De zaak wordt verwijderd met alles erin: {users} gebruikers, {terminals} kassa\'s en {settlements} betaalde poefs. De kassa\'s werken niet meer.', + 'admin.delete.confirm': 'Zaak verwijderen', + 'admin.delete.done': '{name} is verwijderd', + 'admin.devices.show_unpaired': 'Ontkoppelde kassa\'s tonen ({count})', + 'admin.devices.name': 'Kassa', + 'admin.devices.venue': 'Zaak', + 'admin.devices.status': 'Status', + 'admin.devices.last_seen': 'Laatst gezien', + 'admin.devices.token_expires': 'Token geldig tot', + 'admin.devices.pending': 'Wachtende opdrachten', + 'admin.devices.unpaired': 'Ontkoppeld', + 'admin.codes.title': 'Koppelcodes', + 'admin.codes.desc': 'Codes die nog gebruikt kunnen worden om een kassa te koppelen.', + 'admin.codes.none': 'Geen codes die nog wachten.', + 'admin.codes.revoke': 'Intrekken', + 'admin.codes.cleanup': 'Gebruikte en verlopen codes verwijderen ({count})', + 'admin.codes.cleaned': '{count} codes verwijderd', + 'admin.users.search': 'Zoek op naam, e-mail of zaak', + 'admin.users.sessions': 'Sessies', + 'admin.users.role_changed': '{email} is nu {role}', + 'admin.users.password': 'Wachtwoord instellen', + 'admin.users.password_title': 'Nieuw wachtwoord voor {email}', + 'admin.users.password_desc': 'De gebruiker wordt overal uitgelogd en logt in met dit wachtwoord.', + 'admin.users.password_confirm': 'Wachtwoord instellen', + 'admin.users.password_set': 'Wachtwoord van {email} gewijzigd', + 'admin.users.logout': 'Overal uitloggen', + 'admin.users.logout_title': '{email} uitloggen?', + 'admin.users.logout_desc': 'Elke sessie van deze gebruiker stopt meteen. Opnieuw inloggen kan.', + 'admin.users.logged_out': '{email} is uitgelogd', + 'admin.users.everyone': 'Iedereen uitloggen', + 'admin.users.everyone_title': 'Alle gebruikers uitloggen?', + 'admin.users.everyone_desc': 'Iedereen behalve jij wordt meteen uitgelogd, in elke zaak. De kassa\'s blijven werken.', + 'admin.users.everyone_done': '{count} gebruikers uitgelogd', + 'admin.stuck.title': 'Vastgelopen opdrachten', + 'admin.stuck.desc': 'Afsluitverzoeken die geen kassa oppikte. De server sluit poefs nu zelf af, dus deze worden nooit meer uitgevoerd.', + 'admin.stuck.count': 'Wachtend: {count}', + 'admin.stuck.confirm': 'Alle laten verlopen', + 'admin.stuck.done': '{count} opdrachten verlopen', + 'admin.images.title': 'Ongebruikte foto\'s', + 'admin.images.desc': 'Productfoto\'s die geen product meer gebruikt, ouder dan een week.', + 'admin.images.count': '{count} foto\'s, {size}', + 'admin.images.confirm': 'Verwijderen', + 'admin.images.done': '{count} foto\'s verwijderd', + 'admin.purge.title': 'Oude geschiedenis verwijderen', + 'admin.purge.desc': 'Houdt de database klein. Open poefs, verkopen en voorraad blijven altijd.', + 'admin.purge.target.tabEvents': 'Kassagebeurtenissen', + 'admin.purge.target.tabEvents_hint': 'Wat de kassa\'s doorgaven, drankje per drankje', + 'admin.purge.target.auditLog': 'Logboek', + 'admin.purge.target.auditLog_hint': 'Wie wat deed op het dashboard', + 'admin.purge.target.commands': 'Afgehandelde opdrachten', + 'admin.purge.target.commands_hint': 'Wachtende blijven bewaard', + 'admin.purge.days': 'Ouder dan (dagen)', + 'admin.purge.preview': 'Voorbeeld…', + 'admin.purge.confirm_desc': 'Alles ouder dan {days} dagen:', + 'admin.purge.confirm': 'Verwijderen', + 'admin.purge.done': '{count} rijen verwijderd', + 'admin.email.title': 'Test-e-mail', + 'admin.email.desc': 'Verstuurt de test-e-mail, om te controleren dat herinneringen versturen werkt.', + 'admin.email.send': 'Versturen', + 'admin.email.sent': 'Test-e-mail verstuurd naar {email}', + 'admin.audit.action_filter': 'Filter op actie', + 'admin.audit.when': 'Wanneer', + 'admin.audit.who': 'Wie', + 'admin.audit.action': 'Actie', + 'admin.audit.details': 'Details', + 'admin.audit.page': 'Pagina {page} van {pages} ({total} regels)', + 'admin.audit.previous': 'Vorige', + 'admin.audit.next': 'Volgende', + 'admin.health.database': 'Databasegrootte', + 'admin.health.uptime': 'API draait sinds', + 'admin.health.connections': 'Live dashboards', + 'admin.health.migrations': 'Migraties uitgevoerd', + 'admin.health.migrations_failed': '{count} mislukt', + 'admin.health.failed_list': 'Mislukte migraties: {names}', + 'admin.health.latest_migration': 'Laatste migratie:', + 'admin.health.expiry_job': 'Opdrachten-verloopjob', + 'admin.health.not_yet': 'nog niet (elk uur)', + 'admin.health.stuck': 'Vastgelopen opdrachten', + 'admin.health.email': 'E-mail', + 'admin.health.configured': 'Ingesteld', + 'admin.health.not_configured': 'Niet ingesteld', + 'admin.health.node': 'Node.js', + 'admin.health.tables': 'Rijen per tabel', }, }; diff --git a/apps/dashboard/src/lib/realtime.tsx b/apps/dashboard/src/lib/realtime.tsx index ce06cb3..ebfe947 100644 --- a/apps/dashboard/src/lib/realtime.tsx +++ b/apps/dashboard/src/lib/realtime.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react import { useQueryClient } from '@tanstack/react-query'; import { io } from 'socket.io-client'; import { API_URL, getAccessToken, refreshAccessToken } from '@/lib/api/client'; +import { useSession } from '@/lib/session'; // Server topics mapped to the dashboard queries that show that data const TOPIC_QUERIES: Record = { @@ -20,12 +21,14 @@ const RealtimeContext = createContext({ connected: false }); export function RealtimeProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); + const { venueId } = useSession(); const [connected, setConnected] = useState(false); useEffect(() => { const socket = io(`${new URL(API_URL).origin}/realtime`, { - // Read on every (re)connect so a refreshed token is used - auth: (cb) => cb({ token: getAccessToken() }), + // Read on every (re)connect so a refreshed token is used. The venue is only used for a superadmin viewing + // another venue; everyone else follows their own + auth: (cb) => cb({ token: getAccessToken(), venueId }), transports: ['websocket', 'polling'], }); @@ -64,7 +67,7 @@ export function RealtimeProvider({ children }: { children: ReactNode }) { return () => { socket.disconnect(); }; - }, [queryClient]); + }, [queryClient, venueId]); return {children}; } diff --git a/apps/dashboard/src/lib/session.tsx b/apps/dashboard/src/lib/session.tsx index bf26ced..746e55a 100644 --- a/apps/dashboard/src/lib/session.tsx +++ b/apps/dashboard/src/lib/session.tsx @@ -7,9 +7,18 @@ export interface Session { userId: string; email: string; role: Role; + /** The venue the dashboard shows: the user's own, or the one a superadmin is viewing */ venueId: string; + /** The venue the user belongs to */ + homeVenueId: string; + /** Set while a superadmin views another venue */ + viewing: ViewedVenue | null; } +export type ViewedVenue = { id: string; name: string }; + +const VIEWING_KEY = 'kooltap-viewing-venue'; + /** The signed-in user from the access token cookie, or null when there is none or it can't be read. */ export function readSession(): Session | null { const token = getCookie('access_token'); @@ -20,12 +29,50 @@ export function readSession(): Session | null { if (!payload.venueId || !payload.role) return null; // "viewer" was the old name of a manager; tokens issued before the rename still carry it const role = payload.role === 'viewer' ? 'manager' : payload.role; - return { userId: payload.sub, email: payload.email, role, venueId: payload.venueId }; + const viewing = role === 'superadmin' ? readViewing() : null; + const showing = viewing && viewing.id !== payload.venueId ? viewing : null; + return { + userId: payload.sub, + email: payload.email, + role, + venueId: showing?.id ?? payload.venueId, + homeVenueId: payload.venueId, + viewing: showing, + }; } catch { return null; } } +function readViewing(): ViewedVenue | null { + try { + const stored = JSON.parse(localStorage.getItem(VIEWING_KEY) ?? 'null'); + return stored && typeof stored.id === 'string' && typeof stored.name === 'string' ? stored : null; + } catch { + return null; + } +} + +/** + * Shows the dashboard as [venue], or the superadmin's own venue with null. Reloads, so every page and query starts + * over with the other venue. + */ +export function viewVenue(venue: ViewedVenue | null, path = '/dashboard') { + try { + if (venue) localStorage.setItem(VIEWING_KEY, JSON.stringify(venue)); + else localStorage.removeItem(VIEWING_KEY); + } catch { + // Without storage a superadmin can only see their own venue + } + window.location.href = path; +} + +/** Users and terminals: the owner, and KoolTap's superadmins. */ +export const canAdministerVenue = (role: Role) => role === 'admin' || role === 'superadmin'; + +/** Products, stock, tabs and reminders. */ +export const canManageVenue = (role: Role) => role === 'admin' || role === 'manager' || role === 'superadmin'; + const SessionContext = createContext(null); export function SessionProvider({ session, children }: { session: Session; children: ReactNode }) { diff --git a/docs/security.md b/docs/security.md index 88c90b4..04c771c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -22,10 +22,15 @@ - `admin`: Full access, including users and pairing or unpairing terminals - `manager` (the people running the venue): products and their pictures, stock, tabs, planned customers and reminders. Accounts and tokens with the former role `viewer` are treated as managers +- `superadmin` (KoolTap itself): everything an admin may do, in every venue, plus the KoolTap Admin page (`/admin` + endpoints). Only a superadmin can make someone a superadmin; the first one is made with + `pnpm --filter @kooltap/api run superadmin `. A superadmin viewing a venue is recorded in that venue's audit log, + and every admin action is audited +- Signing a user out from the admin page sets `sessionsRevokedAt`: access tokens issued before it are refused at once - Product pictures are public by id (`GET /images/:imageId`); the id is a SHA-256 hash, so it can't be guessed ### Venue Isolation -- Every user and device is scoped to exactly one venue +- Every user and device is scoped to exactly one venue; only a superadmin can reach other venues - All service methods validate venue context from authentication - Cross-venue access is blocked at the service layer