feat: add superadmin
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-26 11:58:10 +01:00
parent 072cda111c
commit 4e43521da7
45 changed files with 3531 additions and 65 deletions
+4
View File
@@ -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 <email>` (`--revoke` makes them an admin again).
## Device Simulator
+1
View File
@@ -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": {
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ClientUser" ADD COLUMN "sessionsRevokedAt" TIMESTAMP(3);
+4 -1
View File
@@ -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])
+30
View File
@@ -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}`);
}
+31
View File
@@ -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 <email> [--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();
+226
View File
@@ -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();
}
}
+14
View File
@@ -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 {}
+613
View File
@@ -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<ResetScope, RealtimeTopic[]> = {
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<Record<PurgeTarget, number>> = {};
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<string, string>([
...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;
}
}
+97
View File
@@ -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> = {}): 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']);
});
});
+146
View File
@@ -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<string, string[]>();
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<string, string[]>();
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<Severity, number> = { error: 0, warning: 1, info: 2 };
return findings.sort((a, b) => order[a.severity] - order[b.severity]);
}
+95
View File
@@ -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;
}
+131
View File
@@ -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<Record<ResetScope, number>>;
/** 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<ResetCounts> {
const where = { venueId };
const counters: Record<ResetScope, () => Promise<number>> = {
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<ResetCounts> {
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 } });
}
+2
View File
@@ -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: [
@@ -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);
});
});
+11 -6
View File
@@ -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;
}
}
}
/** 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);
}
@@ -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);
+2 -1
View File
@@ -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);
}
}
@@ -1,7 +1,7 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { VenueAccessGuard } from './venue-access.guard';
function contextFor(params: Record<string, string>, user?: { venueId: string }): ExecutionContext {
function contextFor(params: Record<string, string>, 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);
});
@@ -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' });
}
+4 -2
View File
@@ -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);
}
}
+3
View File
@@ -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: {
+1
View File
@@ -3,5 +3,6 @@ import { CommandExpiryJob } from './command-expiry.job';
@Module({
providers: [CommandExpiryJob],
exports: [CommandExpiryJob],
})
export class JobsModule {}
+10
View File
@@ -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'));
@@ -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;
}
}
@@ -5,5 +5,6 @@ import { RemindersService } from './reminders.service';
@Module({
controllers: [RemindersController],
providers: [RemindersService],
exports: [RemindersService],
})
export class RemindersModule {}
+30 -13
View File
@@ -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<RenderedEmail> {
@@ -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<AdminDevice | null>(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 <LoadingState />;
if (error) return <ErrorState message={te(error)} />;
const all = data?.devices ?? [];
const devices = showUnpaired ? all : all.filter((d) => !d.revokedAt);
const now = Date.now();
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<Checkbox checked={showUnpaired} onChange={setShowUnpaired} label={t('admin.devices.show_unpaired', { count: all.length - all.filter((d) => !d.revokedAt).length })} />
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.devices.name')}</TableHead>
<TableHead>{t('admin.devices.venue')}</TableHead>
<TableHead>{t('admin.devices.status')}</TableHead>
<TableHead>{t('admin.devices.last_seen')}</TableHead>
<TableHead>{t('admin.devices.token_expires')}</TableHead>
<TableHead className="text-right">{t('admin.devices.pending')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device) => (
<TableRow key={device.id}>
<TableCell className="font-medium">
{device.name}
<span className="block text-xs text-muted-foreground font-mono">{device.tokenPrefix}…</span>
</TableCell>
<TableCell>{device.venueName}</TableCell>
<TableCell>
{device.revokedAt ? <Badge variant="secondary">{t('admin.devices.unpaired')}</Badge> : <DeviceStatusBadge lastSeenAt={device.lastSeenAt} />}
</TableCell>
<TableCell className="text-muted-foreground">{relativeTime(device.lastSeenAt)}</TableCell>
<TableCell className={!device.revokedAt && new Date(device.tokenExpiresAt).getTime() < now ? 'text-destructive' : 'text-muted-foreground'}>
{dateTime(device.tokenExpiresAt)}
</TableCell>
<TableCell className="text-right tabular-nums">{device.pendingCommands || ''}</TableCell>
<TableCell className="text-right">
{!device.revokedAt && (
<Button variant="ghost" size="sm" onClick={() => setUnpairing(device)}>{t('settings.unpair')}</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Card>
<CardHeader>
<CardTitle>{t('admin.codes.title')}</CardTitle>
<CardDescription>{t('admin.codes.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(codes.data?.codes ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.codes.none')}</p>
) : (
<ul className="divide-y rounded-md border text-sm">
{codes.data!.codes.map((code) => (
<li key={code.id} className="flex flex-wrap items-center gap-3 px-3 py-2">
<span className="font-mono font-bold tracking-widest">{code.code}</span>
<span>{code.venueName}</span>
<span className="text-muted-foreground">{t('admin.venues.pair_expires', { time: dateTime(code.expiresAt) })}</span>
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => revokeCode.mutate(code.id)} disabled={revokeCode.isPending}>
{t('admin.codes.revoke')}
</Button>
</li>
))}
</ul>
)}
<Button variant="outline" size="sm" onClick={() => cleanupCodes.mutate()} disabled={!codes.data?.stale || cleanupCodes.isPending}>
{t('admin.codes.cleanup', { count: codes.data?.stale ?? 0 })}
</Button>
</CardContent>
</Card>
<ConfirmDialog
open={unpairing !== null}
onOpenChange={(open) => !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)}
/>
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-48">
<Select value={venueId} onValueChange={(v) => { setVenueId(v); setPage(0); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VENUES}>{t('admin.all_venues')}</SelectItem>
{(venues.data?.venues ?? []).map((v) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input placeholder={t('admin.audit.action_filter')} value={action} onChange={(e) => { setAction(e.target.value); setPage(0); }} className="max-w-xs" />
<Button variant="outline" size="sm" className="ml-auto" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
</div>
{isLoading ? <LoadingState /> : error ? <ErrorState message={te(error)} /> : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.audit.when')}</TableHead>
<TableHead>{t('admin.devices.venue')}</TableHead>
<TableHead>{t('admin.audit.who')}</TableHead>
<TableHead>{t('admin.audit.action')}</TableHead>
<TableHead>{t('admin.audit.details')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(data?.entries ?? []).map((entry) => {
const details = entry.details === null ? '' : typeof entry.details === 'string' ? entry.details : JSON.stringify(entry.details);
return (
<TableRow key={entry.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">{dateTime(entry.createdAt)}</TableCell>
<TableCell>{entry.venueName}</TableCell>
<TableCell>
{entry.actorName ?? <span className="font-mono text-xs">{entry.actorId.slice(0, 8)}</span>}
<span className="block text-xs text-muted-foreground">{entry.actorType}</span>
</TableCell>
<TableCell>
<Badge variant={entry.action.startsWith('admin_') ? 'warning' : 'secondary'}>{entry.action}</Badge>
</TableCell>
<TableCell className="max-w-md">
<span className="block truncate font-mono text-xs text-muted-foreground" title={details}>{details}</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
<div className="flex items-center justify-end gap-2 text-sm">
<span className="text-muted-foreground">{t('admin.audit.page', { page: page + 1, pages, total })}</span>
<Button variant="outline" size="sm" onClick={() => setPage((p) => p - 1)} disabled={page === 0}>{t('admin.audit.previous')}</Button>
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page + 1 >= pages}>{t('admin.audit.next')}</Button>
</div>
</div>
);
}
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 <LoadingState />;
if (error || !data) return <ErrorState message={te(error)} />;
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 (
<div className="space-y-6">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
</div>
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.label}>
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">{stat.label}</p>
<p className={`text-xl font-semibold mt-1 tabular-nums ${stat.bad ? 'text-destructive' : ''}`}>{stat.value}</p>
</CardContent>
</Card>
))}
</div>
{data.migrations.failed.length > 0 && (
<p className="text-sm text-destructive">{t('admin.health.failed_list', { names: data.migrations.failed.join(', ') })}</p>
)}
{data.migrations.latest && (
<p className="text-sm text-muted-foreground">{t('admin.health.latest_migration')} <span className="font-mono">{data.migrations.latest}</span></p>
)}
<Card>
<CardHeader>
<CardTitle>{t('admin.health.tables')}</CardTitle>
</CardHeader>
<CardContent>
<ul className="grid gap-x-8 gap-y-1 text-sm sm:grid-cols-2 lg:grid-cols-3">
{data.tables.map(({ table, rows }) => (
<li key={table} className="flex justify-between border-b py-1">
<span className="font-mono text-xs">{table}</span>
<span className="tabular-nums">{rows.toLocaleString()}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
}
@@ -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 (
<div className="grid gap-6 lg:grid-cols-2">
<StuckCommandsCard />
<ImagesCard />
<PurgeCard />
<TestEmailCard />
</div>
);
}
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 (
<Card>
<CardHeader>
<CardTitle>{t('admin.stuck.title')}</CardTitle>
<CardDescription>{t('admin.stuck.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">{preview.isLoading ? t('common.loading') : t('admin.stuck.count', { count })}</p>
<Button variant="outline" size="sm" onClick={() => expire.mutate()} disabled={count === 0 || expire.isPending}>
{expire.isPending ? t('admin.working') : t('admin.stuck.confirm')}
</Button>
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader>
<CardTitle>{t('admin.images.title')}</CardTitle>
<CardDescription>{t('admin.images.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">
{preview.isLoading ? t('common.loading') : t('admin.images.count', { count, size: formatBytes(preview.data?.bytes ?? 0) })}
</p>
<Button variant="outline" size="sm" onClick={() => setConfirming(true)} disabled={count === 0}>
{t('admin.images.confirm')}
</Button>
</CardContent>
<DangerDialog
open={confirming}
onOpenChange={setConfirming}
title={t('admin.images.title')}
description={t('admin.images.count', { count, size: formatBytes(preview.data?.bytes ?? 0) })}
confirmLabel={t('admin.images.confirm')}
loading={cleanup.isPending}
onConfirm={() => cleanup.mutate()}
/>
</Card>
);
}
function PurgeCard() {
const { t, te } = useLang();
const { onError, refresh } = useFeedback();
const [targets, setTargets] = useState<PurgeTarget[]>(['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 (
<Card>
<CardHeader>
<CardTitle>{t('admin.purge.title')}</CardTitle>
<CardDescription>{t('admin.purge.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
{PURGE_TARGETS.map((target) => (
<Checkbox key={target} checked={targets.includes(target)} onChange={(on) => toggle(target, on)}
label={t(`admin.purge.target.${target}`)} hint={t(`admin.purge.target.${target}_hint`)} />
))}
</div>
<div className="flex flex-wrap gap-4">
<div className="space-y-1">
<Label htmlFor="purge-days">{t('admin.purge.days')}</Label>
<Input id="purge-days" type="number" min={1} value={days} onChange={(e) => setDays(e.target.value)} className="w-28" />
</div>
<div className="space-y-1 min-w-48">
<Label>{t('admin.devices.venue')}</Label>
<Select value={venueId} onValueChange={setVenueId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VENUES}>{t('admin.all_venues')}</SelectItem>
{(venues.data?.venues ?? []).map((v: AdminVenue) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => setConfirming(true)} disabled={!valid}>
{t('admin.purge.preview')}
</Button>
</CardContent>
<DangerDialog
open={confirming}
onOpenChange={setConfirming}
title={t('admin.purge.title')}
description={t('admin.purge.confirm_desc', { days: olderThanDays })}
confirmLabel={t('admin.purge.confirm')}
loading={purge.isPending}
disabled={!preview.data}
onConfirm={() => purge.mutate()}
>
{preview.data ? (
<CountList counts={preview.data.counts} label={(target) => t(`admin.purge.target.${target}`)} />
) : (
<p className="text-sm text-muted-foreground">{preview.error ? te(preview.error) : t('common.loading')}</p>
)}
</DangerDialog>
</Card>
);
}
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 (
<Card>
<CardHeader>
<CardTitle>{t('admin.email.title')}</CardTitle>
<CardDescription>{t('admin.email.desc')}</CardDescription>
</CardHeader>
<CardContent>
<form className="flex gap-2" onSubmit={(e) => { e.preventDefault(); send.mutate(); }}>
<Input type="email" placeholder="you@example.com" value={to} onChange={(e) => setTo(e.target.value)} />
<Button type="submit" size="sm" className="h-9" disabled={!to.includes('@') || send.isPending}>
{send.isPending ? t('admin.working') : t('admin.email.send')}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -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<SectionKey>(storedSection);
if (role !== 'superadmin') return <ErrorState message={t('errors.forbidden')} />;
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 (
<div>
<PageHeader title={t('admin.title')} description={t('admin.desc')} />
<div className="flex gap-1 mb-6 overflow-x-auto overflow-y-hidden border-b" role="tablist">
{SECTIONS.map(({ key, icon: Icon }) => (
<button
key={key}
role="tab"
aria-selected={section === key}
onClick={() => select(key)}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors',
section === key ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<Icon className="h-4 w-4" />
{t(`admin.section.${key}`)}
</button>
))}
</div>
<Component />
</div>
);
}
@@ -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 (
<Dialog open={open} onOpenChange={(next) => !loading && onOpenChange(next)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{children}
{confirmText && (
<div className="space-y-2">
<Label htmlFor="danger-confirm">{t('admin.type_to_confirm', { text: confirmText })}</Label>
<Input id="danger-confirm" value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" />
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={loading || disabled || !matches}>
{loading ? t('admin.working') : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function Checkbox({ checked, onChange, label, hint }: { checked: boolean; onChange: (checked: boolean) => void; label: string; hint?: string }) {
return (
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-primary" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span>
{label}
{hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
</span>
</label>
);
}
/** 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<string, number | undefined>; label: (key: string) => string }) {
const entries = Object.entries(counts).filter((entry): entry is [string, number] => entry[1] !== undefined);
return (
<ul className="text-sm rounded-md border divide-y">
{entries.map(([key, count]) => (
<li key={key} className="flex justify-between px-3 py-1.5">
<span>{label(key)}</span>
<span className="font-mono tabular-nums">{count.toLocaleString()}</span>
</li>
))}
</ul>
);
}
@@ -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 <LoadingState />;
if (error) return <ErrorState message={te(error)} />;
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 (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<Input placeholder={t('admin.users.search')} value={search} onChange={(e) => setSearch(e.target.value)} className="max-w-xs" />
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
<Button variant="destructive" size="sm" onClick={() => setLogoutAll(true)}>
<LogOut className="h-4 w-4 mr-2" /> {t('admin.users.everyone')}
</Button>
</div>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('users.name')}</TableHead>
<TableHead>{t('admin.devices.venue')}</TableHead>
<TableHead>{t('users.role')}</TableHead>
<TableHead className="text-right">{t('admin.users.sessions')}</TableHead>
<TableHead>{t('users.created')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => {
const self = user.id === userId;
return (
<TableRow key={user.id}>
<TableCell>
<span className="font-medium">{user.name}</span>
<span className="block text-xs text-muted-foreground">{user.email}</span>
</TableCell>
<TableCell>{user.venueName}</TableCell>
<TableCell>
{/* You can't change your own role, so there is always a superadmin left */}
<Select value={user.role} onValueChange={(next) => role.mutate({ user, role: next as Role })} disabled={self || role.isPending}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
{ROLES.map((r) => <SelectItem key={r} value={r}>{t(`users.role.${r}`)}</SelectItem>)}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right tabular-nums">{user.activeSessions}</TableCell>
<TableCell className="text-muted-foreground">{relativeTime(user.createdAt)}</TableCell>
<TableCell className="text-right whitespace-nowrap">
{/* Your own password is changed on My Account, which keeps you signed in */}
{!self && (
<>
<Button variant="ghost" size="sm" title={t('admin.users.password')} aria-label={t('admin.users.password')}
onClick={() => { setPassword(''); setAction({ kind: 'password', user }); }}>
<KeyRound className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" title={t('admin.users.logout')} aria-label={t('admin.users.logout')}
onClick={() => setAction({ kind: 'logout', user })}>
<LogOut className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive"
title={t('users.delete_label', { name: user.name })} aria-label={t('users.delete_label', { name: user.name })}
onClick={() => setAction({ kind: 'delete', user })}>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<Dialog open={action?.kind === 'password'} onOpenChange={(open) => !open && !resetPassword.isPending && setAction(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.users.password_title', { email: action?.user.email ?? '' })}</DialogTitle>
<DialogDescription>{t('admin.users.password_desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="new-password">{t('users.password')}</Label>
<Input id="new-password" type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t('users.password_placeholder')} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAction(null)} disabled={resetPassword.isPending}>{t('common.cancel')}</Button>
<Button onClick={() => action && resetPassword.mutate(action.user)} disabled={password.length < 6 || resetPassword.isPending}>
{resetPassword.isPending ? t('admin.working') : t('admin.users.password_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={action?.kind === 'logout'}
onOpenChange={(open) => !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)}
/>
<ConfirmDialog
open={action?.kind === 'delete'}
onOpenChange={(open) => !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)}
/>
<DangerDialog
open={logoutAll}
onOpenChange={setLogoutAll}
title={t('admin.users.everyone_title')}
description={t('admin.users.everyone_desc')}
confirmText="LOGOUT"
confirmLabel={t('admin.users.everyone')}
loading={everyone.isPending}
onConfirm={() => everyone.mutate()}
/>
</div>
);
}
@@ -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<string | null>(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 <LoadingState />;
if (error) return <ErrorState message={te(error)} />;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">{t('admin.venues.desc')}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['admin'] })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
<Button size="sm" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4 mr-2" /> {t('admin.venues.create')}
</Button>
</div>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.venues.name')}</TableHead>
<TableHead className="text-right">{t('admin.venues.users')}</TableHead>
<TableHead className="text-right">{t('admin.venues.terminals')}</TableHead>
<TableHead className="text-right">{t('admin.venues.open_tabs')}</TableHead>
<TableHead className="text-right">{t('admin.venues.products')}</TableHead>
<TableHead>{t('admin.venues.last_sync')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{venues.map((venue) => (
<TableRow
key={venue.id}
className={venue.id === selectedId ? 'bg-muted' : 'cursor-pointer'}
onClick={() => setSelectedId(venue.id === selectedId ? null : venue.id)}
>
<TableCell className="font-medium">{venue.name}</TableCell>
<TableCell className="text-right tabular-nums">{venue.users}</TableCell>
<TableCell className="text-right tabular-nums">{venue.devices}</TableCell>
<TableCell className="text-right tabular-nums">{venue.openTabs}</TableCell>
<TableCell className="text-right tabular-nums">{venue.products}</TableCell>
<TableCell className="text-muted-foreground">{venue.lastSyncAt ? relativeTime(venue.lastSyncAt) : t('admin.never')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{selected ? (
<VenuePanel key={selected.id} venue={selected} venues={venues} onDeleted={() => setSelectedId(null)} />
) : (
<p className="text-sm text-muted-foreground">{t('admin.venues.pick')}</p>
)}
<NameDialog
open={creating}
onOpenChange={setCreating}
title={t('admin.venues.create')}
initial=""
onSave={async (name) => {
const venue = await adminApi.createVenue(name);
toast({ title: t('admin.venues.created', { name: venue.name }), variant: 'success' });
setSelectedId(venue.id);
queryClient.invalidateQueries({ queryKey: ['admin'] });
}}
/>
</div>
);
}
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<Finding[] | null>(null);
const [pairCode, setPairCode] = useState<{ code: string; expiresAt: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
async function run(key: string, action: () => Promise<void>) {
setBusy(key);
try {
await action();
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} finally {
setBusy(null);
}
}
return (
<Card>
<CardHeader>
<CardTitle>{venue.name}</CardTitle>
<CardDescription className="font-mono text-xs">{venue.id}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => run('view', async () => {
await adminApi.viewVenue(venue.id);
viewVenue(venue.id === homeVenueId ? null : { id: venue.id, name: venue.name });
})} disabled={busy !== null}>
<Eye className="h-4 w-4 mr-2" /> {t('admin.venues.view')}
</Button>
<Button size="sm" variant="outline" onClick={() => run('check', async () => {
setFindings((await adminApi.consistency(venue.id)).findings);
})} disabled={busy !== null}>
<Stethoscope className="h-4 w-4 mr-2" /> {busy === 'check' ? t('admin.working') : t('admin.venues.check')}
</Button>
<Button size="sm" variant="outline" onClick={() => run('export', () => downloadVenueExport(venue))} disabled={busy !== null}>
<Download className="h-4 w-4 mr-2" /> {busy === 'export' ? t('admin.working') : t('admin.venues.export')}
</Button>
<Button size="sm" variant="outline" onClick={() => run('pair', async () => {
setPairCode(await adminApi.createRegistrationCode(venue.id));
})} disabled={busy !== null}>
<KeyRound className="h-4 w-4 mr-2" /> {t('admin.venues.pair')}
</Button>
<Button size="sm" variant="outline" onClick={() => setDialog('rename')}>
<Pencil className="h-4 w-4 mr-2" /> {t('admin.venues.rename')}
</Button>
<Button size="sm" variant="outline" onClick={() => setDialog('copy')} disabled={venues.length < 2}>
<Copy className="h-4 w-4 mr-2" /> {t('admin.venues.copy_catalog')}
</Button>
<Button size="sm" variant="outline" className="text-destructive" onClick={() => setDialog('reset')}>
<RotateCcw className="h-4 w-4 mr-2" /> {t('admin.venues.reset')}
</Button>
{venue.id !== homeVenueId && (
<Button size="sm" variant="outline" className="text-destructive" onClick={() => setDialog('delete')}>
<Trash2 className="h-4 w-4 mr-2" /> {t('admin.venues.delete')}
</Button>
)}
</div>
{pairCode && (
<div className="rounded-md border p-3 text-sm">
{t('admin.venues.pair_code')} <span className="font-mono text-lg font-bold tracking-widest ml-2">{pairCode.code}</span>
<span className="text-muted-foreground ml-2">{t('admin.venues.pair_expires', { time: dateTime(pairCode.expiresAt) })}</span>
</div>
)}
{findings && <Findings findings={findings} />}
</CardContent>
<NameDialog
open={dialog === 'rename'}
onOpenChange={(open) => 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();
}}
/>
<ResetDialog open={dialog === 'reset'} onOpenChange={(open) => setDialog(open ? 'reset' : null)} venue={venue} onDone={refresh} />
<CopyCatalogDialog open={dialog === 'copy'} onOpenChange={(open) => setDialog(open ? 'copy' : null)} venue={venue} venues={venues} onDone={refresh} />
<DeleteVenueDialog
open={dialog === 'delete'}
onOpenChange={(open) => setDialog(open ? 'delete' : null)}
venue={venue}
onDone={() => {
onDeleted();
refresh();
}}
/>
</Card>
);
}
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 (
<p className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" /> {t('admin.check.clean')}
</p>
);
}
return (
<ul className="space-y-2">
{findings.map((finding, index) => {
const Icon = SEVERITY_ICON[finding.severity];
return (
<li key={index} className="flex items-start gap-2 text-sm">
<Icon className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<Badge variant={SEVERITY_BADGE[finding.severity]}>{t(`admin.check.severity.${finding.severity}`)}</Badge>
<span>{finding.message}</span>
</li>
);
})}
</ul>
);
}
function NameDialog({ open, onOpenChange, title, initial, onSave }: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
initial: string;
onSave: (name: string) => Promise<void>;
}) {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="venue-name">{t('admin.venues.name')}</Label>
<Input id="venue-name" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && name.trim() && save()} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>{t('common.cancel')}</Button>
<Button onClick={save} disabled={saving || !name.trim() || name.trim() === initial}>{t('admin.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ResetDialog({ open, onOpenChange, venue, onDone }: { open: boolean; onOpenChange: (open: boolean) => void; venue: AdminVenue; onDone: () => void }) {
const { t, te } = useLang();
const [scopes, setScopes] = useState<ResetScope[]>(['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 (
<DangerDialog
open={open}
onOpenChange={onOpenChange}
title={t('admin.reset.title', { name: venue.name })}
description={t('admin.reset.desc')}
confirmText={venue.name}
confirmLabel={t('admin.reset.confirm')}
loading={loading}
disabled={scopes.length === 0 || !stockValid}
onConfirm={confirm}
>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => (
<Button key={preset.key} size="sm" variant="secondary" onClick={() => setScopes(preset.scopes)}>
{t(preset.key)}
</Button>
))}
</div>
<div className="grid gap-2">
{RESET_SCOPES.map((scope) => (
<Checkbox
key={scope}
checked={scopes.includes(scope)}
onChange={(on) => toggle(scope, on)}
label={t(`admin.reset.scope.${scope}`)}
hint={t(`admin.reset.scope.${scope}_hint`)}
/>
))}
</div>
{scopes.includes('stock') && (
<div className="space-y-1">
<Label htmlFor="stock-value">{t('admin.reset.stock_value')}</Label>
<Input id="stock-value" type="number" min={0} value={stockValue} onChange={(e) => setStockValue(e.target.value)} className="w-32" />
</div>
)}
{scopes.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium">{t('admin.preview')}</p>
{preview.data ? (
<CountList counts={preview.data.counts} label={(scope) => t(`admin.reset.scope.${scope}`)} />
) : (
<p className="text-sm text-muted-foreground">{preview.error ? te(preview.error) : t('common.loading')}</p>
)}
</div>
)}
<Checkbox checked={exportFirst} onChange={setExportFirst} label={t('admin.export_first')} />
</div>
</DangerDialog>
);
}
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 (
<Dialog open={open} onOpenChange={(next) => !mutation.isPending && onOpenChange(next)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.copy.title', { name: venue.name })}</DialogTitle>
<DialogDescription>{t('admin.copy.desc')}</DialogDescription>
</DialogHeader>
<Select value={fromId} onValueChange={setFromId}>
<SelectTrigger><SelectValue placeholder={t('admin.copy.from')} /></SelectTrigger>
<SelectContent>
{others.map((v) => (
<SelectItem key={v.id} value={v.id}>{t('admin.copy.option', { name: v.name, count: v.products })}</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={() => mutation.mutate()} disabled={!fromId || mutation.isPending}>
{mutation.isPending ? t('admin.working') : t('admin.copy.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<DangerDialog
open={open}
onOpenChange={onOpenChange}
title={t('admin.delete.title', { name: venue.name })}
description={t('admin.delete.desc', { users: venue.users, terminals: venue.devices, settlements: venue.settlements })}
confirmText={venue.name}
confirmLabel={t('admin.delete.confirm')}
loading={loading}
onConfirm={confirm}
>
<Checkbox checked={exportFirst} onChange={setExportFirst} label={t('admin.export_first')} />
</DangerDialog>
);
}
+27 -13
View File
@@ -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 (
<Link
@@ -151,6 +156,15 @@ export default function DashboardLayout({
{/* Main content */}
<main className="lg:pl-64 pt-14 lg:pt-0">
{user.viewing && (
<div className="flex flex-wrap items-center gap-3 px-6 py-2 text-sm bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100 border-b">
<Eye className="h-4 w-4" />
<span>{t('admin.viewing_banner', { name: user.viewing.name })}</span>
<Button size="sm" variant="outline" className="ml-auto h-7" onClick={() => viewVenue(null, '/dashboard/admin')}>
{t('admin.viewing_stop')}
</Button>
</div>
)}
<div className="p-6 max-w-7xl mx-auto">{children}</div>
</main>
</div>
@@ -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('');
@@ -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<Device | null>(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() {
<span className="font-medium">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
{role === 'admin' && (
{isAdmin && (
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => setUnpairing(device)} aria-label={t('settings.unpair_label', { name: device.name })}>
{t('settings.unpair')}
</Button>
@@ -113,7 +114,7 @@ export default function SettingsPage() {
{t('settings.pair_desc')}
</p>
)}
{role === 'admin' && (
{isAdmin && (
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
<Plus className="h-4 w-4 mr-2" />
{generating ? t('settings.generating') : devices.length > 0 ? t('settings.repair') : t('settings.pair')}
@@ -20,7 +20,7 @@ import { centsToInput, formatCents, parseEuros } from '@/lib/utils';
import { RefreshCw, Search, Truck, History, Plus, Pencil, ImageIcon, Trash2, Upload } from 'lucide-react';
import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
import { useSession } from '@/lib/session';
import { canManageVenue, useSession } from '@/lib/session';
type Filter = 'all' | 'low' | 'untracked' | 'archived';
type Mode = 'add' | 'remove' | 'count';
@@ -59,7 +59,7 @@ export default function StockPage() {
const { connected: live } = useRealtime();
const { venueId, role } = useSession();
// Admins and managers both run the venue
const canManage = role === 'admin' || role === 'manager';
const canManage = canManageVenue(role);
const queryClient = useQueryClient();
const { t, tn, te, relativeTime, dateTime } = useLang();
const [search, setSearch] = useState('');
@@ -23,7 +23,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
import { useSession } from '@/lib/session';
import { canManageVenue, useSession } from '@/lib/session';
type SortKey = 'name' | 'amount';
@@ -37,7 +37,7 @@ export default function TabsPage() {
const { connected: live } = useRealtime();
const { venueId, role } = useSession();
// Admins and managers both run the venue
const canManage = role === 'admin' || role === 'manager';
const canManage = canManageVenue(role);
const queryClient = useQueryClient();
const [closeTab, setCloseTab] = useState<Tab | null>(null);
const [deleteTab, setDeleteTab] = useState<Tab | null>(null);
@@ -16,13 +16,14 @@ 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 { canAdministerVenue, useSession } from '@/lib/session';
import { RefreshCw, Plus, Trash2 } from 'lucide-react';
import { ConfirmDialog } from '@/components/confirm-dialog';
export default function UsersPage() {
const queryClient = useQueryClient();
const { venueId, role, userId } = useSession();
const isAdmin = canAdministerVenue(role);
const [addDialog, setAddDialog] = useState(false);
const [deleting, setDeleting] = useState<User | null>(null);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
@@ -75,7 +76,7 @@ export default function UsersPage() {
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
{role === 'admin' && (
{isAdmin && (
<Button size="sm" onClick={() => setAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> {t('users.add')}
</Button>
@@ -95,7 +96,7 @@ export default function UsersPage() {
<TableHead>{t('users.email')}</TableHead>
<TableHead>{t('users.role')}</TableHead>
<TableHead>{t('users.created')}</TableHead>
{role === 'admin' && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
{isAdmin && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@@ -103,9 +104,9 @@ export default function UsersPage() {
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
<TableCell><Badge variant={u.role === 'manager' ? 'secondary' : 'default'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
<TableCell>{relativeTime(u.createdAt)}</TableCell>
{role === 'admin' && (
{isAdmin && (
<TableCell className="text-right">
{/* You can't delete yourself, so the venue always keeps an admin */}
{u.id !== userId && (
+153 -3
View File
@@ -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 = <T>(endpoint: string, body: unknown = {}) =>
api<T>(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<Record<ResetScope, number>> }>(`/admin/venues/${venueId}/reset`, data),
consistency: (venueId: string) =>
api<{ checkedAt: string; findings: Finding[] }>(`/admin/venues/${venueId}/consistency`),
exportVenue: (venueId: string) => api<Record<string, unknown>>(`/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<Record<PurgeTarget, number>> }>('/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<SystemHealth>('/admin/health'),
};
export { setTokens, clearTokens, refreshAccessToken };
export function getAccessToken(): string | null {
+338
View File
@@ -392,6 +392,175 @@ const translations: Record<Lang, Record<string, string>> = {
'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<Lang, Record<string, string>> = {
'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',
},
};
+6 -3
View File
@@ -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<string, string[][]> = {
@@ -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 <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
}
+48 -1
View File
@@ -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<Session | null>(null);
export function SessionProvider({ session, children }: { session: Session; children: ReactNode }) {
+6 -1
View File
@@ -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 <email>`. 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