@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -3,5 +3,6 @@ import { CommandExpiryJob } from './command-expiry.job';
|
||||
|
||||
@Module({
|
||||
providers: [CommandExpiryJob],
|
||||
exports: [CommandExpiryJob],
|
||||
})
|
||||
export class JobsModule {}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user