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

This commit is contained in:
2026-10-26 11:58:10 +01:00
parent 072cda111c
commit 4e43521da7
45 changed files with 3531 additions and 65 deletions
@@ -0,0 +1,150 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import { adminApi, type AdminDevice } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { DeviceStatusBadge } from '@/components/device-status-badge';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { Checkbox } from './shared';
export function DevicesSection() {
const queryClient = useQueryClient();
const { t, te, relativeTime, dateTime } = useLang();
const [showUnpaired, setShowUnpaired] = useState(false);
const [unpairing, setUnpairing] = useState<AdminDevice | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['admin', 'devices'],
queryFn: adminApi.devices,
// A terminal going offline sends no event, so its status is polled
refetchInterval: 15000,
});
const codes = useQuery({ queryKey: ['admin', 'registration-codes'], queryFn: adminApi.registrationCodes });
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
const unpair = useMutation({
mutationFn: (device: AdminDevice) => adminApi.revokeDevice(device.id),
onSuccess: (_, device) => toast({ title: t('settings.unpaired', { name: device.name }), variant: 'success' }),
onError,
onSettled: () => {
setUnpairing(null);
refresh();
},
});
const revokeCode = useMutation({ mutationFn: adminApi.revokeRegistrationCode, onError, onSettled: refresh });
const cleanupCodes = useMutation({
mutationFn: adminApi.cleanupRegistrationCodes,
onSuccess: ({ deleted }) => toast({ title: t('admin.codes.cleaned', { count: deleted }), variant: 'success' }),
onError,
onSettled: refresh,
});
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error)} />;
const all = data?.devices ?? [];
const devices = showUnpaired ? all : all.filter((d) => !d.revokedAt);
const now = Date.now();
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<Checkbox checked={showUnpaired} onChange={setShowUnpaired} label={t('admin.devices.show_unpaired', { count: all.length - all.filter((d) => !d.revokedAt).length })} />
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.devices.name')}</TableHead>
<TableHead>{t('admin.devices.venue')}</TableHead>
<TableHead>{t('admin.devices.status')}</TableHead>
<TableHead>{t('admin.devices.last_seen')}</TableHead>
<TableHead>{t('admin.devices.token_expires')}</TableHead>
<TableHead className="text-right">{t('admin.devices.pending')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device) => (
<TableRow key={device.id}>
<TableCell className="font-medium">
{device.name}
<span className="block text-xs text-muted-foreground font-mono">{device.tokenPrefix}…</span>
</TableCell>
<TableCell>{device.venueName}</TableCell>
<TableCell>
{device.revokedAt ? <Badge variant="secondary">{t('admin.devices.unpaired')}</Badge> : <DeviceStatusBadge lastSeenAt={device.lastSeenAt} />}
</TableCell>
<TableCell className="text-muted-foreground">{relativeTime(device.lastSeenAt)}</TableCell>
<TableCell className={!device.revokedAt && new Date(device.tokenExpiresAt).getTime() < now ? 'text-destructive' : 'text-muted-foreground'}>
{dateTime(device.tokenExpiresAt)}
</TableCell>
<TableCell className="text-right tabular-nums">{device.pendingCommands || ''}</TableCell>
<TableCell className="text-right">
{!device.revokedAt && (
<Button variant="ghost" size="sm" onClick={() => setUnpairing(device)}>{t('settings.unpair')}</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Card>
<CardHeader>
<CardTitle>{t('admin.codes.title')}</CardTitle>
<CardDescription>{t('admin.codes.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(codes.data?.codes ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.codes.none')}</p>
) : (
<ul className="divide-y rounded-md border text-sm">
{codes.data!.codes.map((code) => (
<li key={code.id} className="flex flex-wrap items-center gap-3 px-3 py-2">
<span className="font-mono font-bold tracking-widest">{code.code}</span>
<span>{code.venueName}</span>
<span className="text-muted-foreground">{t('admin.venues.pair_expires', { time: dateTime(code.expiresAt) })}</span>
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => revokeCode.mutate(code.id)} disabled={revokeCode.isPending}>
{t('admin.codes.revoke')}
</Button>
</li>
))}
</ul>
)}
<Button variant="outline" size="sm" onClick={() => cleanupCodes.mutate()} disabled={!codes.data?.stale || cleanupCodes.isPending}>
{t('admin.codes.cleanup', { count: codes.data?.stale ?? 0 })}
</Button>
</CardContent>
</Card>
<ConfirmDialog
open={unpairing !== null}
onOpenChange={(open) => !open && !unpair.isPending && setUnpairing(null)}
title={t('settings.unpair_title', { name: unpairing?.name ?? '' })}
description={t('settings.unpair_desc')}
confirmLabel={t('settings.unpair')}
cancelLabel={t('common.cancel')}
variant="destructive"
loading={unpair.isPending}
onConfirm={() => unpairing && unpair.mutate(unpairing)}
/>
</div>
);
}
@@ -0,0 +1,184 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import { adminApi } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { useLang } from '@/lib/i18n';
import { formatBytes } from './shared';
const PAGE_SIZE = 50;
const ALL_VENUES = 'all';
export function AuditSection() {
const { t, te, dateTime } = useLang();
const [venueId, setVenueId] = useState(ALL_VENUES);
const [action, setAction] = useState('');
const [page, setPage] = useState(0);
const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['admin', 'audit', venueId, action.trim(), page],
queryFn: () => adminApi.audit({
venueId: venueId === ALL_VENUES ? undefined : venueId,
action: action.trim() || undefined,
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
}),
placeholderData: (previous) => previous,
});
const total = data?.total ?? 0;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-48">
<Select value={venueId} onValueChange={(v) => { setVenueId(v); setPage(0); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VENUES}>{t('admin.all_venues')}</SelectItem>
{(venues.data?.venues ?? []).map((v) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input placeholder={t('admin.audit.action_filter')} value={action} onChange={(e) => { setAction(e.target.value); setPage(0); }} className="max-w-xs" />
<Button variant="outline" size="sm" className="ml-auto" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
</div>
{isLoading ? <LoadingState /> : error ? <ErrorState message={te(error)} /> : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.audit.when')}</TableHead>
<TableHead>{t('admin.devices.venue')}</TableHead>
<TableHead>{t('admin.audit.who')}</TableHead>
<TableHead>{t('admin.audit.action')}</TableHead>
<TableHead>{t('admin.audit.details')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(data?.entries ?? []).map((entry) => {
const details = entry.details === null ? '' : typeof entry.details === 'string' ? entry.details : JSON.stringify(entry.details);
return (
<TableRow key={entry.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">{dateTime(entry.createdAt)}</TableCell>
<TableCell>{entry.venueName}</TableCell>
<TableCell>
{entry.actorName ?? <span className="font-mono text-xs">{entry.actorId.slice(0, 8)}</span>}
<span className="block text-xs text-muted-foreground">{entry.actorType}</span>
</TableCell>
<TableCell>
<Badge variant={entry.action.startsWith('admin_') ? 'warning' : 'secondary'}>{entry.action}</Badge>
</TableCell>
<TableCell className="max-w-md">
<span className="block truncate font-mono text-xs text-muted-foreground" title={details}>{details}</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
<div className="flex items-center justify-end gap-2 text-sm">
<span className="text-muted-foreground">{t('admin.audit.page', { page: page + 1, pages, total })}</span>
<Button variant="outline" size="sm" onClick={() => setPage((p) => p - 1)} disabled={page === 0}>{t('admin.audit.previous')}</Button>
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page + 1 >= pages}>{t('admin.audit.next')}</Button>
</div>
</div>
);
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
export function HealthSection() {
const { t, te, relativeTime } = useLang();
const { data, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['admin', 'health'],
queryFn: adminApi.health,
refetchInterval: 30000,
});
if (isLoading) return <LoadingState />;
if (error || !data) return <ErrorState message={te(error)} />;
const stats: { label: string; value: string; bad?: boolean }[] = [
{ label: t('admin.health.database'), value: formatBytes(data.databaseBytes) },
{ label: t('admin.health.uptime'), value: formatUptime(data.uptimeSeconds) },
{ label: t('admin.health.connections'), value: String(data.realtimeConnections) },
{
label: t('admin.health.migrations'),
value: data.migrations.failed.length > 0 ? t('admin.health.migrations_failed', { count: data.migrations.failed.length }) : String(data.migrations.applied),
bad: data.migrations.failed.length > 0,
},
{
label: t('admin.health.expiry_job'),
value: data.commandExpiryLastRunAt ? relativeTime(data.commandExpiryLastRunAt) : t('admin.health.not_yet'),
},
{ label: t('admin.health.stuck'), value: String(data.stuckCommands), bad: data.stuckCommands > 0 },
{ label: t('admin.health.email'), value: data.emailConfigured ? t('admin.health.configured') : t('admin.health.not_configured'), bad: !data.emailConfigured },
{ label: t('admin.health.node'), value: data.nodeVersion },
];
return (
<div className="space-y-6">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
</div>
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.label}>
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">{stat.label}</p>
<p className={`text-xl font-semibold mt-1 tabular-nums ${stat.bad ? 'text-destructive' : ''}`}>{stat.value}</p>
</CardContent>
</Card>
))}
</div>
{data.migrations.failed.length > 0 && (
<p className="text-sm text-destructive">{t('admin.health.failed_list', { names: data.migrations.failed.join(', ') })}</p>
)}
{data.migrations.latest && (
<p className="text-sm text-muted-foreground">{t('admin.health.latest_migration')} <span className="font-mono">{data.migrations.latest}</span></p>
)}
<Card>
<CardHeader>
<CardTitle>{t('admin.health.tables')}</CardTitle>
</CardHeader>
<CardContent>
<ul className="grid gap-x-8 gap-y-1 text-sm sm:grid-cols-2 lg:grid-cols-3">
{data.tables.map(({ table, rows }) => (
<li key={table} className="flex justify-between border-b py-1">
<span className="font-mono text-xs">{table}</span>
<span className="tabular-nums">{rows.toLocaleString()}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,220 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminApi, PURGE_TARGETS, type AdminVenue, type PurgeTarget } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { Checkbox, CountList, DangerDialog, formatBytes } from './shared';
const ALL_VENUES = 'all';
export function MaintenanceSection() {
return (
<div className="grid gap-6 lg:grid-cols-2">
<StuckCommandsCard />
<ImagesCard />
<PurgeCard />
<TestEmailCard />
</div>
);
}
function useFeedback() {
const queryClient = useQueryClient();
const { t, te } = useLang();
return {
onError: (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
refresh: () => queryClient.invalidateQueries({ queryKey: ['admin'] }),
};
}
function StuckCommandsCard() {
const { t } = useLang();
const { onError, refresh } = useFeedback();
const preview = useQuery({ queryKey: ['admin', 'stuck-commands'], queryFn: () => adminApi.expireStuckCommands(true) });
const expire = useMutation({
mutationFn: () => adminApi.expireStuckCommands(false),
onSuccess: ({ count }) => toast({ title: t('admin.stuck.done', { count }), variant: 'success' }),
onError,
onSettled: refresh,
});
const count = preview.data?.count ?? 0;
return (
<Card>
<CardHeader>
<CardTitle>{t('admin.stuck.title')}</CardTitle>
<CardDescription>{t('admin.stuck.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">{preview.isLoading ? t('common.loading') : t('admin.stuck.count', { count })}</p>
<Button variant="outline" size="sm" onClick={() => expire.mutate()} disabled={count === 0 || expire.isPending}>
{expire.isPending ? t('admin.working') : t('admin.stuck.confirm')}
</Button>
</CardContent>
</Card>
);
}
function ImagesCard() {
const { t } = useLang();
const { onError, refresh } = useFeedback();
const [confirming, setConfirming] = useState(false);
const preview = useQuery({ queryKey: ['admin', 'orphan-images'], queryFn: () => adminApi.cleanupImages(true) });
const cleanup = useMutation({
mutationFn: () => adminApi.cleanupImages(false),
onSuccess: ({ count }) => {
toast({ title: t('admin.images.done', { count }), variant: 'success' });
setConfirming(false);
},
onError,
onSettled: refresh,
});
const count = preview.data?.count ?? 0;
return (
<Card>
<CardHeader>
<CardTitle>{t('admin.images.title')}</CardTitle>
<CardDescription>{t('admin.images.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">
{preview.isLoading ? t('common.loading') : t('admin.images.count', { count, size: formatBytes(preview.data?.bytes ?? 0) })}
</p>
<Button variant="outline" size="sm" onClick={() => setConfirming(true)} disabled={count === 0}>
{t('admin.images.confirm')}
</Button>
</CardContent>
<DangerDialog
open={confirming}
onOpenChange={setConfirming}
title={t('admin.images.title')}
description={t('admin.images.count', { count, size: formatBytes(preview.data?.bytes ?? 0) })}
confirmLabel={t('admin.images.confirm')}
loading={cleanup.isPending}
onConfirm={() => cleanup.mutate()}
/>
</Card>
);
}
function PurgeCard() {
const { t, te } = useLang();
const { onError, refresh } = useFeedback();
const [targets, setTargets] = useState<PurgeTarget[]>(['tabEvents', 'commands']);
const [days, setDays] = useState('90');
const [venueId, setVenueId] = useState(ALL_VENUES);
const [confirming, setConfirming] = useState(false);
const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
const olderThanDays = parseInt(days, 10);
const valid = targets.length > 0 && Number.isInteger(olderThanDays) && olderThanDays >= 1;
const request = { targets, olderThanDays, venueId: venueId === ALL_VENUES ? undefined : venueId };
const preview = useQuery({
queryKey: ['admin', 'purge-preview', [...targets].sort().join(','), olderThanDays, venueId],
queryFn: () => adminApi.purge({ ...request, dryRun: true }),
enabled: confirming && valid,
});
const purge = useMutation({
mutationFn: () => adminApi.purge({ ...request, dryRun: false }),
onSuccess: ({ counts }) => {
const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
toast({ title: t('admin.purge.done', { count: total }), variant: 'success' });
setConfirming(false);
},
onError,
onSettled: refresh,
});
function toggle(target: PurgeTarget, on: boolean) {
setTargets((current) => (on ? [...current, target] : current.filter((x) => x !== target)));
}
return (
<Card>
<CardHeader>
<CardTitle>{t('admin.purge.title')}</CardTitle>
<CardDescription>{t('admin.purge.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
{PURGE_TARGETS.map((target) => (
<Checkbox key={target} checked={targets.includes(target)} onChange={(on) => toggle(target, on)}
label={t(`admin.purge.target.${target}`)} hint={t(`admin.purge.target.${target}_hint`)} />
))}
</div>
<div className="flex flex-wrap gap-4">
<div className="space-y-1">
<Label htmlFor="purge-days">{t('admin.purge.days')}</Label>
<Input id="purge-days" type="number" min={1} value={days} onChange={(e) => setDays(e.target.value)} className="w-28" />
</div>
<div className="space-y-1 min-w-48">
<Label>{t('admin.devices.venue')}</Label>
<Select value={venueId} onValueChange={setVenueId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VENUES}>{t('admin.all_venues')}</SelectItem>
{(venues.data?.venues ?? []).map((v: AdminVenue) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => setConfirming(true)} disabled={!valid}>
{t('admin.purge.preview')}
</Button>
</CardContent>
<DangerDialog
open={confirming}
onOpenChange={setConfirming}
title={t('admin.purge.title')}
description={t('admin.purge.confirm_desc', { days: olderThanDays })}
confirmLabel={t('admin.purge.confirm')}
loading={purge.isPending}
disabled={!preview.data}
onConfirm={() => purge.mutate()}
>
{preview.data ? (
<CountList counts={preview.data.counts} label={(target) => t(`admin.purge.target.${target}`)} />
) : (
<p className="text-sm text-muted-foreground">{preview.error ? te(preview.error) : t('common.loading')}</p>
)}
</DangerDialog>
</Card>
);
}
function TestEmailCard() {
const { t } = useLang();
const { onError } = useFeedback();
const [to, setTo] = useState('');
const send = useMutation({
mutationFn: () => adminApi.testEmail(to.trim()),
onSuccess: ({ email }) => toast({ title: t('admin.email.sent', { email }), variant: 'success' }),
onError,
});
return (
<Card>
<CardHeader>
<CardTitle>{t('admin.email.title')}</CardTitle>
<CardDescription>{t('admin.email.desc')}</CardDescription>
</CardHeader>
<CardContent>
<form className="flex gap-2" onSubmit={(e) => { e.preventDefault(); send.mutate(); }}>
<Input type="email" placeholder="you@example.com" value={to} onChange={(e) => setTo(e.target.value)} />
<Button type="submit" size="sm" className="h-9" disabled={!to.includes('@') || send.isPending}>
{send.isPending ? t('admin.working') : t('admin.email.send')}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { Activity, Building2, Monitor, ScrollText, Users, Wrench } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { ErrorState } from '@/components/error-state';
import { cn } from '@/lib/cn';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { VenuesSection } from './venues-section';
import { DevicesSection } from './devices-section';
import { UsersSection } from './users-section';
import { MaintenanceSection } from './maintenance-section';
import { AuditSection, HealthSection } from './insight-sections';
const SECTIONS = [
{ key: 'venues', icon: Building2, Component: VenuesSection },
{ key: 'devices', icon: Monitor, Component: DevicesSection },
{ key: 'users', icon: Users, Component: UsersSection },
{ key: 'maintenance', icon: Wrench, Component: MaintenanceSection },
{ key: 'audit', icon: ScrollText, Component: AuditSection },
{ key: 'health', icon: Activity, Component: HealthSection },
] as const;
type SectionKey = (typeof SECTIONS)[number]['key'];
const SECTION_KEY = 'kooltap-admin-section';
function storedSection(): SectionKey {
try {
const stored = localStorage.getItem(SECTION_KEY);
return SECTIONS.some((s) => s.key === stored) ? (stored as SectionKey) : 'venues';
} catch {
return 'venues';
}
}
/** KoolTap's own admin page: every venue, terminal and user. Only for superadmins. */
export default function AdminPage() {
const { role } = useSession();
const { t } = useLang();
const [section, setSection] = useState<SectionKey>(storedSection);
if (role !== 'superadmin') return <ErrorState message={t('errors.forbidden')} />;
function select(key: SectionKey) {
setSection(key);
try {
localStorage.setItem(SECTION_KEY, key);
} catch {
// Not remembered, but the section still opens
}
}
const { Component } = SECTIONS.find((s) => s.key === section)!;
return (
<div>
<PageHeader title={t('admin.title')} description={t('admin.desc')} />
<div className="flex gap-1 mb-6 overflow-x-auto overflow-y-hidden border-b" role="tablist">
{SECTIONS.map(({ key, icon: Icon }) => (
<button
key={key}
role="tab"
aria-selected={section === key}
onClick={() => select(key)}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors',
section === key ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<Icon className="h-4 w-4" />
{t(`admin.section.${key}`)}
</button>
))}
</div>
<Component />
</div>
);
}
@@ -0,0 +1,124 @@
'use client';
import { ReactNode, useEffect, useState } from 'react';
import { adminApi } from '@/lib/api/client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLang } from '@/lib/i18n';
/**
* A confirmation for something that can't be undone. With [confirmText] the button only works once that text is
* typed, so a misclick or the wrong row can't wipe a venue.
*/
export function DangerDialog({
open,
onOpenChange,
title,
description,
children,
confirmText,
confirmLabel,
loading,
disabled,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
children?: ReactNode;
confirmText?: string;
confirmLabel: string;
loading: boolean;
disabled?: boolean;
onConfirm: () => void;
}) {
const { t } = useLang();
const [typed, setTyped] = useState('');
useEffect(() => {
if (open) setTyped('');
}, [open]);
const matches = !confirmText || typed.trim() === confirmText;
return (
<Dialog open={open} onOpenChange={(next) => !loading && onOpenChange(next)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{children}
{confirmText && (
<div className="space-y-2">
<Label htmlFor="danger-confirm">{t('admin.type_to_confirm', { text: confirmText })}</Label>
<Input id="danger-confirm" value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" />
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={loading || disabled || !matches}>
{loading ? t('admin.working') : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function Checkbox({ checked, onChange, label, hint }: { checked: boolean; onChange: (checked: boolean) => void; label: string; hint?: string }) {
return (
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-primary" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span>
{label}
{hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
</span>
</label>
);
}
/** Downloads everything of the venue as a JSON file: the backup taken before a reset or delete. */
export async function downloadVenueExport(venue: { id: string; name: string }) {
const data = await adminApi.exportVenue(venue.id);
const slug = venue.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'venue';
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = `kooltap-${slug}-${stamp}.json`;
link.click();
URL.revokeObjectURL(url);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
}
/** Counts from a dry run, one line each. */
export function CountList({ counts, label }: { counts: Record<string, number | undefined>; label: (key: string) => string }) {
const entries = Object.entries(counts).filter((entry): entry is [string, number] => entry[1] !== undefined);
return (
<ul className="text-sm rounded-md border divide-y">
{entries.map(([key, count]) => (
<li key={key} className="flex justify-between px-3 py-1.5">
<span>{label(key)}</span>
<span className="font-mono tabular-nums">{count.toLocaleString()}</span>
</li>
))}
</ul>
);
}
@@ -0,0 +1,207 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { KeyRound, LogOut, RefreshCw, Trash2 } from 'lucide-react';
import { adminApi, type AdminUser, type Role } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { DangerDialog } from './shared';
const ROLES: Role[] = ['manager', 'admin', 'superadmin'];
export function UsersSection() {
const queryClient = useQueryClient();
const { userId } = useSession();
const { t, te, relativeTime } = useLang();
const [search, setSearch] = useState('');
const [action, setAction] = useState<{ kind: 'password' | 'logout' | 'delete'; user: AdminUser } | null>(null);
const [password, setPassword] = useState('');
const [logoutAll, setLogoutAll] = useState(false);
const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users });
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
const done = (title: string) => {
toast({ title, variant: 'success' });
setAction(null);
refresh();
};
const role = useMutation({
mutationFn: ({ user, role }: { user: AdminUser; role: Role }) => adminApi.updateRole(user.id, role),
onSuccess: (_, { user, role }) => done(t('admin.users.role_changed', { email: user.email, role: t(`users.role.${role}`) })),
onError,
});
const resetPassword = useMutation({
mutationFn: (user: AdminUser) => adminApi.resetPassword(user.id, password),
onSuccess: (_, user) => done(t('admin.users.password_set', { email: user.email })),
onError,
});
const logout = useMutation({
mutationFn: (user: AdminUser) => adminApi.logoutUser(user.id),
onSuccess: (_, user) => done(t('admin.users.logged_out', { email: user.email })),
onError,
});
const remove = useMutation({
mutationFn: (user: AdminUser) => adminApi.deleteUser(user.id),
onSuccess: (_, user) => done(t('users.deleted', { email: user.email })),
onError,
});
const everyone = useMutation({
mutationFn: adminApi.logoutEveryone,
onSuccess: ({ users }) => {
toast({ title: t('admin.users.everyone_done', { count: users }), variant: 'success' });
setLogoutAll(false);
refresh();
},
onError,
});
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error)} />;
const query = search.trim().toLowerCase();
const users = (data?.users ?? []).filter((u) =>
!query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query)));
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<Input placeholder={t('admin.users.search')} value={search} onChange={(e) => setSearch(e.target.value)} className="max-w-xs" />
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
<Button variant="destructive" size="sm" onClick={() => setLogoutAll(true)}>
<LogOut className="h-4 w-4 mr-2" /> {t('admin.users.everyone')}
</Button>
</div>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('users.name')}</TableHead>
<TableHead>{t('admin.devices.venue')}</TableHead>
<TableHead>{t('users.role')}</TableHead>
<TableHead className="text-right">{t('admin.users.sessions')}</TableHead>
<TableHead>{t('users.created')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => {
const self = user.id === userId;
return (
<TableRow key={user.id}>
<TableCell>
<span className="font-medium">{user.name}</span>
<span className="block text-xs text-muted-foreground">{user.email}</span>
</TableCell>
<TableCell>{user.venueName}</TableCell>
<TableCell>
{/* You can't change your own role, so there is always a superadmin left */}
<Select value={user.role} onValueChange={(next) => role.mutate({ user, role: next as Role })} disabled={self || role.isPending}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
{ROLES.map((r) => <SelectItem key={r} value={r}>{t(`users.role.${r}`)}</SelectItem>)}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right tabular-nums">{user.activeSessions}</TableCell>
<TableCell className="text-muted-foreground">{relativeTime(user.createdAt)}</TableCell>
<TableCell className="text-right whitespace-nowrap">
{/* Your own password is changed on My Account, which keeps you signed in */}
{!self && (
<>
<Button variant="ghost" size="sm" title={t('admin.users.password')} aria-label={t('admin.users.password')}
onClick={() => { setPassword(''); setAction({ kind: 'password', user }); }}>
<KeyRound className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" title={t('admin.users.logout')} aria-label={t('admin.users.logout')}
onClick={() => setAction({ kind: 'logout', user })}>
<LogOut className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive"
title={t('users.delete_label', { name: user.name })} aria-label={t('users.delete_label', { name: user.name })}
onClick={() => setAction({ kind: 'delete', user })}>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<Dialog open={action?.kind === 'password'} onOpenChange={(open) => !open && !resetPassword.isPending && setAction(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.users.password_title', { email: action?.user.email ?? '' })}</DialogTitle>
<DialogDescription>{t('admin.users.password_desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="new-password">{t('users.password')}</Label>
<Input id="new-password" type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t('users.password_placeholder')} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAction(null)} disabled={resetPassword.isPending}>{t('common.cancel')}</Button>
<Button onClick={() => action && resetPassword.mutate(action.user)} disabled={password.length < 6 || resetPassword.isPending}>
{resetPassword.isPending ? t('admin.working') : t('admin.users.password_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={action?.kind === 'logout'}
onOpenChange={(open) => !open && !logout.isPending && setAction(null)}
title={t('admin.users.logout_title', { email: action?.user.email ?? '' })}
description={t('admin.users.logout_desc')}
confirmLabel={t('admin.users.logout')}
cancelLabel={t('common.cancel')}
loading={logout.isPending}
onConfirm={() => action && logout.mutate(action.user)}
/>
<ConfirmDialog
open={action?.kind === 'delete'}
onOpenChange={(open) => !open && !remove.isPending && setAction(null)}
title={t('users.delete_title', { name: action?.user.name ?? '' })}
description={t('users.delete_desc', { email: action?.user.email ?? '' })}
confirmLabel={t('users.delete')}
cancelLabel={t('common.cancel')}
variant="destructive"
loading={remove.isPending}
onConfirm={() => action && remove.mutate(action.user)}
/>
<DangerDialog
open={logoutAll}
onOpenChange={setLogoutAll}
title={t('admin.users.everyone_title')}
description={t('admin.users.everyone_desc')}
confirmText="LOGOUT"
confirmLabel={t('admin.users.everyone')}
loading={everyone.isPending}
onConfirm={() => everyone.mutate()}
/>
</div>
);
}
@@ -0,0 +1,463 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle2, Copy, Download, Eye, Info, KeyRound, Pencil, Plus, RefreshCw, RotateCcw, Stethoscope, Trash2 } from 'lucide-react';
import { adminApi, RESET_SCOPES, type AdminVenue, type Finding, type ResetScope } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { useSession, viewVenue } from '@/lib/session';
import { Checkbox, CountList, DangerDialog, downloadVenueExport } from './shared';
// "Clear all tabs" and "full reset" are the common cases; any other mix can be ticked by hand
const PRESETS: { key: string; scopes: ResetScope[] }[] = [
{ key: 'admin.reset.preset_tabs', scopes: ['tabs'] },
{ key: 'admin.reset.preset_event', scopes: ['tabs', 'plannedTabs', 'stock'] },
{ key: 'admin.reset.preset_full', scopes: [...RESET_SCOPES] },
];
export function VenuesSection() {
const queryClient = useQueryClient();
const { t, te, relativeTime } = useLang();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
const venues = data?.venues ?? [];
const selected = venues.find((v) => v.id === selectedId) ?? null;
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error)} />;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">{t('admin.venues.desc')}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['admin'] })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
<Button size="sm" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4 mr-2" /> {t('admin.venues.create')}
</Button>
</div>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.venues.name')}</TableHead>
<TableHead className="text-right">{t('admin.venues.users')}</TableHead>
<TableHead className="text-right">{t('admin.venues.terminals')}</TableHead>
<TableHead className="text-right">{t('admin.venues.open_tabs')}</TableHead>
<TableHead className="text-right">{t('admin.venues.products')}</TableHead>
<TableHead>{t('admin.venues.last_sync')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{venues.map((venue) => (
<TableRow
key={venue.id}
className={venue.id === selectedId ? 'bg-muted' : 'cursor-pointer'}
onClick={() => setSelectedId(venue.id === selectedId ? null : venue.id)}
>
<TableCell className="font-medium">{venue.name}</TableCell>
<TableCell className="text-right tabular-nums">{venue.users}</TableCell>
<TableCell className="text-right tabular-nums">{venue.devices}</TableCell>
<TableCell className="text-right tabular-nums">{venue.openTabs}</TableCell>
<TableCell className="text-right tabular-nums">{venue.products}</TableCell>
<TableCell className="text-muted-foreground">{venue.lastSyncAt ? relativeTime(venue.lastSyncAt) : t('admin.never')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{selected ? (
<VenuePanel key={selected.id} venue={selected} venues={venues} onDeleted={() => setSelectedId(null)} />
) : (
<p className="text-sm text-muted-foreground">{t('admin.venues.pick')}</p>
)}
<NameDialog
open={creating}
onOpenChange={setCreating}
title={t('admin.venues.create')}
initial=""
onSave={async (name) => {
const venue = await adminApi.createVenue(name);
toast({ title: t('admin.venues.created', { name: venue.name }), variant: 'success' });
setSelectedId(venue.id);
queryClient.invalidateQueries({ queryKey: ['admin'] });
}}
/>
</div>
);
}
function VenuePanel({ venue, venues, onDeleted }: { venue: AdminVenue; venues: AdminVenue[]; onDeleted: () => void }) {
const queryClient = useQueryClient();
const { homeVenueId } = useSession();
const { t, te, dateTime } = useLang();
const [dialog, setDialog] = useState<'rename' | 'reset' | 'copy' | 'delete' | null>(null);
const [findings, setFindings] = useState<Finding[] | null>(null);
const [pairCode, setPairCode] = useState<{ code: string; expiresAt: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
async function run(key: string, action: () => Promise<void>) {
setBusy(key);
try {
await action();
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} finally {
setBusy(null);
}
}
return (
<Card>
<CardHeader>
<CardTitle>{venue.name}</CardTitle>
<CardDescription className="font-mono text-xs">{venue.id}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => run('view', async () => {
await adminApi.viewVenue(venue.id);
viewVenue(venue.id === homeVenueId ? null : { id: venue.id, name: venue.name });
})} disabled={busy !== null}>
<Eye className="h-4 w-4 mr-2" /> {t('admin.venues.view')}
</Button>
<Button size="sm" variant="outline" onClick={() => run('check', async () => {
setFindings((await adminApi.consistency(venue.id)).findings);
})} disabled={busy !== null}>
<Stethoscope className="h-4 w-4 mr-2" /> {busy === 'check' ? t('admin.working') : t('admin.venues.check')}
</Button>
<Button size="sm" variant="outline" onClick={() => run('export', () => downloadVenueExport(venue))} disabled={busy !== null}>
<Download className="h-4 w-4 mr-2" /> {busy === 'export' ? t('admin.working') : t('admin.venues.export')}
</Button>
<Button size="sm" variant="outline" onClick={() => run('pair', async () => {
setPairCode(await adminApi.createRegistrationCode(venue.id));
})} disabled={busy !== null}>
<KeyRound className="h-4 w-4 mr-2" /> {t('admin.venues.pair')}
</Button>
<Button size="sm" variant="outline" onClick={() => setDialog('rename')}>
<Pencil className="h-4 w-4 mr-2" /> {t('admin.venues.rename')}
</Button>
<Button size="sm" variant="outline" onClick={() => setDialog('copy')} disabled={venues.length < 2}>
<Copy className="h-4 w-4 mr-2" /> {t('admin.venues.copy_catalog')}
</Button>
<Button size="sm" variant="outline" className="text-destructive" onClick={() => setDialog('reset')}>
<RotateCcw className="h-4 w-4 mr-2" /> {t('admin.venues.reset')}
</Button>
{venue.id !== homeVenueId && (
<Button size="sm" variant="outline" className="text-destructive" onClick={() => setDialog('delete')}>
<Trash2 className="h-4 w-4 mr-2" /> {t('admin.venues.delete')}
</Button>
)}
</div>
{pairCode && (
<div className="rounded-md border p-3 text-sm">
{t('admin.venues.pair_code')} <span className="font-mono text-lg font-bold tracking-widest ml-2">{pairCode.code}</span>
<span className="text-muted-foreground ml-2">{t('admin.venues.pair_expires', { time: dateTime(pairCode.expiresAt) })}</span>
</div>
)}
{findings && <Findings findings={findings} />}
</CardContent>
<NameDialog
open={dialog === 'rename'}
onOpenChange={(open) => setDialog(open ? 'rename' : null)}
title={t('admin.venues.rename_title', { name: venue.name })}
initial={venue.name}
onSave={async (name) => {
await adminApi.renameVenue(venue.id, name);
toast({ title: t('admin.venues.renamed'), variant: 'success' });
refresh();
}}
/>
<ResetDialog open={dialog === 'reset'} onOpenChange={(open) => setDialog(open ? 'reset' : null)} venue={venue} onDone={refresh} />
<CopyCatalogDialog open={dialog === 'copy'} onOpenChange={(open) => setDialog(open ? 'copy' : null)} venue={venue} venues={venues} onDone={refresh} />
<DeleteVenueDialog
open={dialog === 'delete'}
onOpenChange={(open) => setDialog(open ? 'delete' : null)}
venue={venue}
onDone={() => {
onDeleted();
refresh();
}}
/>
</Card>
);
}
const SEVERITY_ICON = { error: AlertTriangle, warning: AlertTriangle, info: Info };
const SEVERITY_BADGE = { error: 'destructive', warning: 'warning', info: 'info' } as const;
function Findings({ findings }: { findings: Finding[] }) {
const { t } = useLang();
if (findings.length === 0) {
return (
<p className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" /> {t('admin.check.clean')}
</p>
);
}
return (
<ul className="space-y-2">
{findings.map((finding, index) => {
const Icon = SEVERITY_ICON[finding.severity];
return (
<li key={index} className="flex items-start gap-2 text-sm">
<Icon className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<Badge variant={SEVERITY_BADGE[finding.severity]}>{t(`admin.check.severity.${finding.severity}`)}</Badge>
<span>{finding.message}</span>
</li>
);
})}
</ul>
);
}
function NameDialog({ open, onOpenChange, title, initial, onSave }: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
initial: string;
onSave: (name: string) => Promise<void>;
}) {
const { t, te } = useLang();
const [name, setName] = useState(initial);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) setName(initial);
}, [open, initial]);
async function save() {
setSaving(true);
try {
await onSave(name.trim());
onOpenChange(false);
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="venue-name">{t('admin.venues.name')}</Label>
<Input id="venue-name" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && name.trim() && save()} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>{t('common.cancel')}</Button>
<Button onClick={save} disabled={saving || !name.trim() || name.trim() === initial}>{t('admin.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ResetDialog({ open, onOpenChange, venue, onDone }: { open: boolean; onOpenChange: (open: boolean) => void; venue: AdminVenue; onDone: () => void }) {
const { t, te } = useLang();
const [scopes, setScopes] = useState<ResetScope[]>(['tabs']);
const [stockValue, setStockValue] = useState('0');
const [exportFirst, setExportFirst] = useState(true);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setScopes(['tabs']);
setStockValue('0');
setExportFirst(true);
}
}, [open]);
const stock = parseInt(stockValue, 10);
const stockValid = !scopes.includes('stock') || (Number.isInteger(stock) && stock >= 0);
// A fresh dry run whenever the selection changes, so the numbers shown are what will go
const preview = useQuery({
queryKey: ['admin', 'reset-preview', venue.id, [...scopes].sort().join(',')],
queryFn: () => adminApi.resetVenue(venue.id, { scopes, dryRun: true }),
enabled: open && scopes.length > 0,
});
function toggle(scope: ResetScope, on: boolean) {
setScopes((current) => (on ? [...current, scope] : current.filter((s) => s !== scope)));
}
async function confirm() {
setLoading(true);
try {
if (exportFirst) await downloadVenueExport(venue);
const { counts } = await adminApi.resetVenue(venue.id, { scopes, stockValue: stock, dryRun: false });
const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
toast({ title: t('admin.reset.done', { name: venue.name }), description: t('admin.reset.done_desc', { count: total }), variant: 'success' });
onOpenChange(false);
onDone();
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} finally {
setLoading(false);
}
}
return (
<DangerDialog
open={open}
onOpenChange={onOpenChange}
title={t('admin.reset.title', { name: venue.name })}
description={t('admin.reset.desc')}
confirmText={venue.name}
confirmLabel={t('admin.reset.confirm')}
loading={loading}
disabled={scopes.length === 0 || !stockValid}
onConfirm={confirm}
>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => (
<Button key={preset.key} size="sm" variant="secondary" onClick={() => setScopes(preset.scopes)}>
{t(preset.key)}
</Button>
))}
</div>
<div className="grid gap-2">
{RESET_SCOPES.map((scope) => (
<Checkbox
key={scope}
checked={scopes.includes(scope)}
onChange={(on) => toggle(scope, on)}
label={t(`admin.reset.scope.${scope}`)}
hint={t(`admin.reset.scope.${scope}_hint`)}
/>
))}
</div>
{scopes.includes('stock') && (
<div className="space-y-1">
<Label htmlFor="stock-value">{t('admin.reset.stock_value')}</Label>
<Input id="stock-value" type="number" min={0} value={stockValue} onChange={(e) => setStockValue(e.target.value)} className="w-32" />
</div>
)}
{scopes.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium">{t('admin.preview')}</p>
{preview.data ? (
<CountList counts={preview.data.counts} label={(scope) => t(`admin.reset.scope.${scope}`)} />
) : (
<p className="text-sm text-muted-foreground">{preview.error ? te(preview.error) : t('common.loading')}</p>
)}
</div>
)}
<Checkbox checked={exportFirst} onChange={setExportFirst} label={t('admin.export_first')} />
</div>
</DangerDialog>
);
}
function CopyCatalogDialog({ open, onOpenChange, venue, venues, onDone }: {
open: boolean;
onOpenChange: (open: boolean) => void;
venue: AdminVenue;
venues: AdminVenue[];
onDone: () => void;
}) {
const { t, te } = useLang();
const [fromId, setFromId] = useState('');
const others = venues.filter((v) => v.id !== venue.id);
const mutation = useMutation({
mutationFn: () => adminApi.copyCatalog(venue.id, fromId),
onSuccess: ({ created }) => {
toast({ title: t('admin.copy.done', { count: created }), variant: 'success' });
onOpenChange(false);
onDone();
},
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
});
return (
<Dialog open={open} onOpenChange={(next) => !mutation.isPending && onOpenChange(next)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.copy.title', { name: venue.name })}</DialogTitle>
<DialogDescription>{t('admin.copy.desc')}</DialogDescription>
</DialogHeader>
<Select value={fromId} onValueChange={setFromId}>
<SelectTrigger><SelectValue placeholder={t('admin.copy.from')} /></SelectTrigger>
<SelectContent>
{others.map((v) => (
<SelectItem key={v.id} value={v.id}>{t('admin.copy.option', { name: v.name, count: v.products })}</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={() => mutation.mutate()} disabled={!fromId || mutation.isPending}>
{mutation.isPending ? t('admin.working') : t('admin.copy.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function DeleteVenueDialog({ open, onOpenChange, venue, onDone }: { open: boolean; onOpenChange: (open: boolean) => void; venue: AdminVenue; onDone: () => void }) {
const { t, te } = useLang();
const [exportFirst, setExportFirst] = useState(true);
const [loading, setLoading] = useState(false);
async function confirm() {
setLoading(true);
try {
if (exportFirst) await downloadVenueExport(venue);
await adminApi.deleteVenue(venue.id, venue.name);
toast({ title: t('admin.delete.done', { name: venue.name }), variant: 'success' });
onOpenChange(false);
onDone();
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} finally {
setLoading(false);
}
}
return (
<DangerDialog
open={open}
onOpenChange={onOpenChange}
title={t('admin.delete.title', { name: venue.name })}
description={t('admin.delete.desc', { users: venue.users, terminals: venue.devices, settlements: venue.settlements })}
confirmText={venue.name}
confirmLabel={t('admin.delete.confirm')}
loading={loading}
onConfirm={confirm}
>
<Checkbox checked={exportFirst} onChange={setExportFirst} label={t('admin.export_first')} />
</DangerDialog>
);
}
+27 -13
View File
@@ -15,6 +15,8 @@ import {
Bell,
Package,
UserCog,
ShieldAlert,
Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
@@ -23,18 +25,22 @@ import { LanguageSelector } from '@/components/language-selector';
import { authApi, venuesApi } from '@/lib/api/client';
import { toast } from '@/components/ui/toast';
import { RealtimeProvider, useRealtime } from '@/lib/realtime';
import { readSession, Session, SessionProvider } from '@/lib/session';
import { canAdministerVenue, readSession, Session, SessionProvider, viewVenue } from '@/lib/session';
import { LoadingState } from '@/components/loading-state';
const navItems = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false },
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false },
{ href: '/dashboard/stock', key: 'nav.stock', icon: Package, adminOnly: false },
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
{ href: '/dashboard/account', key: 'nav.account', icon: UserCog, adminOnly: false },
// Who sees each item: everyone, admins (and superadmins), or superadmins only
type Access = 'all' | 'admin' | 'superadmin';
const navItems: { href: string; key: string; icon: typeof LayoutDashboard; access: Access }[] = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' },
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, access: 'all' },
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, access: 'all' },
{ href: '/dashboard/stock', key: 'nav.stock', icon: Package, access: 'all' },
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, access: 'admin' },
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, access: 'all' },
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, access: 'all' },
{ href: '/dashboard/account', key: 'nav.account', icon: UserCog, access: 'all' },
{ href: '/dashboard/admin', key: 'nav.admin', icon: ShieldAlert, access: 'superadmin' },
];
export default function DashboardLayout({
@@ -108,9 +114,8 @@ export default function DashboardLayout({
const Icon = item.icon;
const active = pathname === item.href;
if (item.adminOnly && user.role !== 'admin') {
return null;
}
if (item.access === 'admin' && !canAdministerVenue(user.role)) return null;
if (item.access === 'superadmin' && user.role !== 'superadmin') return null;
return (
<Link
@@ -151,6 +156,15 @@ export default function DashboardLayout({
{/* Main content */}
<main className="lg:pl-64 pt-14 lg:pt-0">
{user.viewing && (
<div className="flex flex-wrap items-center gap-3 px-6 py-2 text-sm bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100 border-b">
<Eye className="h-4 w-4" />
<span>{t('admin.viewing_banner', { name: user.viewing.name })}</span>
<Button size="sm" variant="outline" className="ml-auto h-7" onClick={() => viewVenue(null, '/dashboard/admin')}>
{t('admin.viewing_stop')}
</Button>
</div>
)}
<div className="p-6 max-w-7xl mx-auto">{children}</div>
</main>
</div>
@@ -14,7 +14,7 @@ import { EmptyState } from '@/components/empty-state';
import { toast } from '@/components/ui/toast';
import { formatCents } from '@/lib/utils';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { canManageVenue, useSession } from '@/lib/session';
import { Send, History, Search } from 'lucide-react';
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -29,7 +29,7 @@ export default function RemindersPage() {
const { t, tn, te, relativeTime, dateTime } = useLang();
const { venueId, role } = useSession();
// Admins and managers both run the venue
const canManage = role === 'admin' || role === 'manager';
const canManage = canManageVenue(role);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [search, setSearch] = useState('');
@@ -11,11 +11,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { ConfirmDialog } from '@/components/confirm-dialog';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { canAdministerVenue, useSession } from '@/lib/session';
import { Monitor, Copy, Plus } from 'lucide-react';
export default function SettingsPage() {
const { venueId, role } = useSession();
const { venueId, role, viewing } = useSession();
const isAdmin = canAdministerVenue(role);
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = useState(false);
const [unpairing, setUnpairing] = useState<Device | null>(null);
@@ -49,7 +50,7 @@ export default function SettingsPage() {
async function handleGenerateCode() {
setGenerating(true);
try {
const result = await devicesApi.generateCode();
const result = await devicesApi.generateCode(viewing?.id);
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
@@ -100,7 +101,7 @@ export default function SettingsPage() {
<span className="font-medium">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
{role === 'admin' && (
{isAdmin && (
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => setUnpairing(device)} aria-label={t('settings.unpair_label', { name: device.name })}>
{t('settings.unpair')}
</Button>
@@ -113,7 +114,7 @@ export default function SettingsPage() {
{t('settings.pair_desc')}
</p>
)}
{role === 'admin' && (
{isAdmin && (
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
<Plus className="h-4 w-4 mr-2" />
{generating ? t('settings.generating') : devices.length > 0 ? t('settings.repair') : t('settings.pair')}
@@ -20,7 +20,7 @@ import { centsToInput, formatCents, parseEuros } from '@/lib/utils';
import { RefreshCw, Search, Truck, History, Plus, Pencil, ImageIcon, Trash2, Upload } from 'lucide-react';
import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
import { useSession } from '@/lib/session';
import { canManageVenue, useSession } from '@/lib/session';
type Filter = 'all' | 'low' | 'untracked' | 'archived';
type Mode = 'add' | 'remove' | 'count';
@@ -59,7 +59,7 @@ export default function StockPage() {
const { connected: live } = useRealtime();
const { venueId, role } = useSession();
// Admins and managers both run the venue
const canManage = role === 'admin' || role === 'manager';
const canManage = canManageVenue(role);
const queryClient = useQueryClient();
const { t, tn, te, relativeTime, dateTime } = useLang();
const [search, setSearch] = useState('');
@@ -23,7 +23,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
import { useSession } from '@/lib/session';
import { canManageVenue, useSession } from '@/lib/session';
type SortKey = 'name' | 'amount';
@@ -37,7 +37,7 @@ export default function TabsPage() {
const { connected: live } = useRealtime();
const { venueId, role } = useSession();
// Admins and managers both run the venue
const canManage = role === 'admin' || role === 'manager';
const canManage = canManageVenue(role);
const queryClient = useQueryClient();
const [closeTab, setCloseTab] = useState<Tab | null>(null);
const [deleteTab, setDeleteTab] = useState<Tab | null>(null);
@@ -16,13 +16,14 @@ import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { canAdministerVenue, useSession } from '@/lib/session';
import { RefreshCw, Plus, Trash2 } from 'lucide-react';
import { ConfirmDialog } from '@/components/confirm-dialog';
export default function UsersPage() {
const queryClient = useQueryClient();
const { venueId, role, userId } = useSession();
const isAdmin = canAdministerVenue(role);
const [addDialog, setAddDialog] = useState(false);
const [deleting, setDeleting] = useState<User | null>(null);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
@@ -75,7 +76,7 @@ export default function UsersPage() {
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button>
{role === 'admin' && (
{isAdmin && (
<Button size="sm" onClick={() => setAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> {t('users.add')}
</Button>
@@ -95,7 +96,7 @@ export default function UsersPage() {
<TableHead>{t('users.email')}</TableHead>
<TableHead>{t('users.role')}</TableHead>
<TableHead>{t('users.created')}</TableHead>
{role === 'admin' && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
{isAdmin && <TableHead className="text-right">{t('tabs.actions')}</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@@ -103,9 +104,9 @@ export default function UsersPage() {
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
<TableCell><Badge variant={u.role === 'manager' ? 'secondary' : 'default'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
<TableCell>{relativeTime(u.createdAt)}</TableCell>
{role === 'admin' && (
{isAdmin && (
<TableCell className="text-right">
{/* You can't delete yourself, so the venue always keeps an admin */}
{u.id !== userId && (
+153 -3
View File
@@ -5,8 +5,11 @@ export interface ApiError {
message: string;
}
/** admin: the owner (users, terminals); manager: runs the venue (products, stock, tabs, reminders) */
export type Role = 'admin' | 'manager';
/**
* admin: the owner (users, terminals); manager: runs the venue (products, stock, tabs, reminders); superadmin: runs
* KoolTap itself (every venue, and the admin page)
*/
export type Role = 'admin' | 'manager' | 'superadmin';
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
@@ -288,7 +291,9 @@ export const devicesApi = {
api<{ devices: Device[] }>(`/venues/${venueId}/devices`),
unpair: (venueId: string, deviceId: string) =>
api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }),
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
/** [venueId]: the venue a superadmin is viewing; others always pair for their own venue */
generateCode: (venueId?: string) =>
api<{ code: string; expiresAt: string }>(`/devices/generation-code${venueId ? `?venueId=${encodeURIComponent(venueId)}` : ''}`),
};
// Users API
@@ -397,6 +402,151 @@ export const stockApi = {
}),
};
// Superadmin API: every venue, terminal and user. Destructive calls take dryRun to preview what they would change
export const RESET_SCOPES = ['tabs', 'settlements', 'tabEvents', 'plannedTabs', 'stock', 'stockHistory', 'commands', 'auditLog'] as const;
export type ResetScope = (typeof RESET_SCOPES)[number];
export const PURGE_TARGETS = ['tabEvents', 'auditLog', 'commands'] as const;
export type PurgeTarget = (typeof PURGE_TARGETS)[number];
export interface AdminVenue {
id: string;
name: string;
createdAt: string;
users: number;
devices: number;
openTabs: number;
products: number;
plannedTabs: number;
settlements: number;
/** The latest sync of any of its terminals */
lastSyncAt: string | null;
}
export interface AdminDevice {
id: string;
name: string;
venueId: string;
venueName: string;
tokenPrefix: string;
tokenExpiresAt: string;
lastSeenAt: string;
revokedAt: string | null;
createdAt: string;
pendingCommands: number;
}
export interface AdminUser {
id: string;
email: string;
name: string;
role: Role;
venueId: string;
venueName: string;
createdAt: string;
activeSessions: number;
}
export interface RegistrationCode {
id: string;
code: string;
venueId: string;
venueName: string;
expiresAt: string;
createdAt: string;
}
export interface AuditEntry {
id: string;
venueId: string;
venueName: string;
actorType: string;
actorId: string;
/** The user's email or the terminal's name; null when it no longer exists */
actorName: string | null;
action: string;
details: unknown;
createdAt: string;
}
export type FindingSeverity = 'error' | 'warning' | 'info';
export interface Finding {
severity: FindingSeverity;
code: string;
message: string;
subjectId: string;
}
export interface SystemHealth {
checkedAt: string;
uptimeSeconds: number;
nodeVersion: string;
databaseBytes: number;
tables: { table: string; rows: number }[];
migrations: { applied: number; latest: string | null; failed: string[] };
realtimeConnections: number;
/** Null until the hourly job ran since the server started */
commandExpiryLastRunAt: string | null;
stuckCommands: number;
emailConfigured: boolean;
}
const post = <T>(endpoint: string, body: unknown = {}) =>
api<T>(endpoint, { method: 'POST', body: JSON.stringify(body) });
export const adminApi = {
venues: () => api<{ venues: AdminVenue[] }>('/admin/venues'),
createVenue: (name: string) => post<{ id: string; name: string }>('/admin/venues', { name }),
renameVenue: (venueId: string, name: string) =>
api(`/admin/venues/${venueId}`, { method: 'PATCH', body: JSON.stringify({ name }) }),
/** [confirmName] must be the venue's name */
deleteVenue: (venueId: string, confirmName: string) =>
api(`/admin/venues/${venueId}?confirmName=${encodeURIComponent(confirmName)}`, { method: 'DELETE' }),
viewVenue: (venueId: string) => post<{ id: string; name: string }>(`/admin/venues/${venueId}/view`),
copyCatalog: (venueId: string, fromVenueId: string) =>
post<{ created: number }>(`/admin/venues/${venueId}/copy-catalog`, { fromVenueId }),
resetVenue: (venueId: string, data: { scopes: ResetScope[]; stockValue?: number; dryRun: boolean }) =>
post<{ dryRun: boolean; counts: Partial<Record<ResetScope, number>> }>(`/admin/venues/${venueId}/reset`, data),
consistency: (venueId: string) =>
api<{ checkedAt: string; findings: Finding[] }>(`/admin/venues/${venueId}/consistency`),
exportVenue: (venueId: string) => api<Record<string, unknown>>(`/admin/venues/${venueId}/export`),
createRegistrationCode: (venueId: string) =>
post<{ code: string; expiresAt: string }>(`/admin/venues/${venueId}/registration-codes`),
devices: () => api<{ devices: AdminDevice[] }>('/admin/devices'),
revokeDevice: (deviceId: string) => post(`/admin/devices/${deviceId}/revoke`),
registrationCodes: () => api<{ codes: RegistrationCode[]; stale: number }>('/admin/registration-codes'),
revokeRegistrationCode: (id: string) => post(`/admin/registration-codes/${id}/revoke`),
cleanupRegistrationCodes: () => post<{ deleted: number }>('/admin/registration-codes/cleanup'),
expireStuckCommands: (dryRun: boolean) => post<{ dryRun: boolean; count: number }>('/admin/commands/expire-stuck', { dryRun }),
users: () => api<{ users: AdminUser[] }>('/admin/users'),
updateRole: (userId: string, role: Role) =>
api(`/admin/users/${userId}/role`, { method: 'PATCH', body: JSON.stringify({ role }) }),
resetPassword: (userId: string, password: string) => post(`/admin/users/${userId}/password`, { password }),
logoutUser: (userId: string) => post(`/admin/users/${userId}/logout`),
deleteUser: (userId: string) => api(`/admin/users/${userId}`, { method: 'DELETE' }),
logoutEveryone: () => post<{ users: number }>('/admin/sessions/revoke-all'),
purge: (data: { targets: PurgeTarget[]; olderThanDays: number; venueId?: string; dryRun: boolean }) =>
post<{ dryRun: boolean; counts: Partial<Record<PurgeTarget, number>> }>('/admin/purge', data),
cleanupImages: (dryRun: boolean) => post<{ dryRun: boolean; count: number; bytes?: number }>('/admin/images/cleanup', { dryRun }),
testEmail: (to: string) => post<{ ok: boolean; email: string }>('/admin/email/test', { to }),
audit: (params: { venueId?: string; action?: string; limit?: number; offset?: number }) => {
const searchParams = new URLSearchParams();
if (params.venueId) searchParams.set('venueId', params.venueId);
if (params.action) searchParams.set('action', params.action);
if (params.limit) searchParams.set('limit', String(params.limit));
if (params.offset) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString();
return api<{ entries: AuditEntry[]; total: number }>(`/admin/audit${qs ? `?${qs}` : ''}`);
},
health: () => api<SystemHealth>('/admin/health'),
};
export { setTokens, clearTokens, refreshAccessToken };
export function getAccessToken(): string | null {
+338
View File
@@ -392,6 +392,175 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.status.cancelled': 'Cancelled',
'stock.reason.unknown_product': 'product not on the terminal',
'stock.reason.stock_not_tracked': 'stock not tracked on the terminal',
'nav.admin': 'KoolTap Admin',
'users.role.superadmin': 'Superadmin',
'errors.cannot_delete_own_venue': 'You cannot delete the venue your account belongs to',
'errors.confirm_name_mismatch': 'Type the venue name exactly to confirm',
'errors.same_venue': 'Pick another venue to copy from',
'errors.cannot_change_own_role': 'You cannot change your own role',
'errors.cannot_logout_self': 'Use Logout to sign yourself out',
'errors.use_account_page': 'Change your own password on My Account',
'errors.venue_not_found': 'This venue no longer exists',
'errors.device_not_found': 'This terminal no longer exists',
'errors.email_failed': 'The email could not be sent',
'admin.title': 'KoolTap Admin',
'admin.desc': 'Every venue, terminal and user. Changes here cannot be undone.',
'admin.section.venues': 'Venues',
'admin.section.devices': 'Terminals',
'admin.section.users': 'Users',
'admin.section.maintenance': 'Maintenance',
'admin.section.audit': 'Audit log',
'admin.section.health': 'System',
'admin.working': 'Working...',
'admin.save': 'Save',
'admin.never': 'never',
'admin.preview': 'This will remove:',
'admin.all_venues': 'All venues',
'admin.type_to_confirm': 'Type {text} to confirm',
'admin.export_first': 'Download a backup (JSON) first',
'admin.viewing_banner': 'You are viewing {name} as a KoolTap admin. Everything you do here happens in this venue.',
'admin.viewing_stop': 'Back to KoolTap Admin',
'admin.venues.desc': 'Pick a venue to see what you can do with it.',
'admin.venues.pick': 'Pick a venue above.',
'admin.venues.create': 'New venue',
'admin.venues.created': '{name} created',
'admin.venues.name': 'Name',
'admin.venues.users': 'Users',
'admin.venues.terminals': 'Terminals',
'admin.venues.open_tabs': 'Open tabs',
'admin.venues.products': 'Products',
'admin.venues.last_sync': 'Last sync',
'admin.venues.view': 'View as venue',
'admin.venues.check': 'Check data',
'admin.venues.export': 'Export',
'admin.venues.pair': 'Pairing code',
'admin.venues.pair_code': 'Pairing code:',
'admin.venues.pair_expires': 'valid until {time}',
'admin.venues.rename': 'Rename',
'admin.venues.rename_title': 'Rename {name}',
'admin.venues.renamed': 'Venue renamed',
'admin.venues.copy_catalog': 'Copy products',
'admin.venues.reset': 'Reset…',
'admin.venues.delete': 'Delete…',
'admin.check.clean': 'Nothing found: the data adds up.',
'admin.check.severity.error': 'Error',
'admin.check.severity.warning': 'Warning',
'admin.check.severity.info': 'Note',
'admin.reset.title': 'Reset {name}',
'admin.reset.desc': 'Users, terminals and products stay. Terminals pick up the change on their next sync.',
'admin.reset.preset_tabs': 'Clear all tabs',
'admin.reset.preset_event': 'New event',
'admin.reset.preset_full': 'Full reset',
'admin.reset.scope.tabs': 'Open tabs',
'admin.reset.scope.tabs_hint': 'Unpaid drinks on them are lost',
'admin.reset.scope.settlements': 'Paid tabs',
'admin.reset.scope.settlements_hint': 'The sales history',
'admin.reset.scope.tabEvents': 'Terminal events',
'admin.reset.scope.tabEvents_hint': 'Only when every terminal has synced: an event sent again would count twice',
'admin.reset.scope.plannedTabs': 'Planned customers',
'admin.reset.scope.plannedTabs_hint': 'With their email addresses and reminders',
'admin.reset.scope.stock': 'Stock',
'admin.reset.scope.stock_hint': 'Every tracked product gets the same count',
'admin.reset.scope.stockHistory': 'Stock history',
'admin.reset.scope.stockHistory_hint': 'Deliveries, breakage and counts',
'admin.reset.scope.commands': 'Command history',
'admin.reset.scope.commands_hint': 'Tabs closed from the dashboard',
'admin.reset.scope.auditLog': 'Audit log',
'admin.reset.scope.auditLog_hint': 'An entry for this reset is kept',
'admin.reset.stock_value': 'Stock count',
'admin.reset.confirm': 'Reset',
'admin.reset.done': '{name} is reset',
'admin.reset.done_desc': '{count} rows changed',
'admin.copy.title': 'Copy products into {name}',
'admin.copy.desc': 'Products this venue doesn\'t have yet (by name) are added, with their pictures. Stock is not copied.',
'admin.copy.from': 'Copy from…',
'admin.copy.option': '{name} ({count} products)',
'admin.copy.confirm': 'Copy',
'admin.copy.done': '{count} products added',
'admin.delete.title': 'Delete {name}?',
'admin.delete.desc': 'The venue is deleted with everything in it: {users} users, {terminals} terminals and {settlements} paid tabs. Its terminals stop working.',
'admin.delete.confirm': 'Delete venue',
'admin.delete.done': '{name} is deleted',
'admin.devices.show_unpaired': 'Show unpaired terminals ({count})',
'admin.devices.name': 'Terminal',
'admin.devices.venue': 'Venue',
'admin.devices.status': 'Status',
'admin.devices.last_seen': 'Last seen',
'admin.devices.token_expires': 'Token valid until',
'admin.devices.pending': 'Pending commands',
'admin.devices.unpaired': 'Unpaired',
'admin.codes.title': 'Pairing codes',
'admin.codes.desc': 'Codes that can still be used to pair a terminal.',
'admin.codes.none': 'No codes waiting to be used.',
'admin.codes.revoke': 'Revoke',
'admin.codes.cleanup': 'Delete used and expired codes ({count})',
'admin.codes.cleaned': '{count} codes deleted',
'admin.users.search': 'Search name, email or venue',
'admin.users.sessions': 'Sessions',
'admin.users.role_changed': '{email} is now {role}',
'admin.users.password': 'Set password',
'admin.users.password_title': 'New password for {email}',
'admin.users.password_desc': 'They are signed out everywhere and log in with this password.',
'admin.users.password_confirm': 'Set password',
'admin.users.password_set': 'Password of {email} changed',
'admin.users.logout': 'Sign out everywhere',
'admin.users.logout_title': 'Sign out {email}?',
'admin.users.logout_desc': 'Every session of this user ends right away. They can log in again.',
'admin.users.logged_out': '{email} is signed out',
'admin.users.everyone': 'Sign out everyone',
'admin.users.everyone_title': 'Sign out every user?',
'admin.users.everyone_desc': 'Everyone but you is signed out right away, in every venue. Terminals keep working.',
'admin.users.everyone_done': '{count} users signed out',
'admin.stuck.title': 'Stuck commands',
'admin.stuck.desc': 'Close requests no terminal picked up. The server settles tabs itself now, so these will never run.',
'admin.stuck.count': 'Pending: {count}',
'admin.stuck.confirm': 'Expire all',
'admin.stuck.done': '{count} commands expired',
'admin.images.title': 'Unused pictures',
'admin.images.desc': 'Product pictures no product uses anymore, older than a week.',
'admin.images.count': '{count} pictures, {size}',
'admin.images.confirm': 'Delete them',
'admin.images.done': '{count} pictures deleted',
'admin.purge.title': 'Delete old history',
'admin.purge.desc': 'Keeps the database small. Open tabs, sales and stock are never touched.',
'admin.purge.target.tabEvents': 'Terminal events',
'admin.purge.target.tabEvents_hint': 'What the terminals reported, drink by drink',
'admin.purge.target.auditLog': 'Audit log',
'admin.purge.target.auditLog_hint': 'Who did what on the dashboard',
'admin.purge.target.commands': 'Finished commands',
'admin.purge.target.commands_hint': 'Pending ones are kept',
'admin.purge.days': 'Older than (days)',
'admin.purge.preview': 'Preview…',
'admin.purge.confirm_desc': 'Everything older than {days} days:',
'admin.purge.confirm': 'Delete',
'admin.purge.done': '{count} rows deleted',
'admin.email.title': 'Test email',
'admin.email.desc': 'Sends the test email, to check that sending reminders works.',
'admin.email.send': 'Send',
'admin.email.sent': 'Test email sent to {email}',
'admin.audit.action_filter': 'Filter on action',
'admin.audit.when': 'When',
'admin.audit.who': 'Who',
'admin.audit.action': 'Action',
'admin.audit.details': 'Details',
'admin.audit.page': 'Page {page} of {pages} ({total} entries)',
'admin.audit.previous': 'Previous',
'admin.audit.next': 'Next',
'admin.health.database': 'Database size',
'admin.health.uptime': 'API uptime',
'admin.health.connections': 'Live dashboards',
'admin.health.migrations': 'Migrations applied',
'admin.health.migrations_failed': '{count} failed',
'admin.health.failed_list': 'Failed migrations: {names}',
'admin.health.latest_migration': 'Latest migration:',
'admin.health.expiry_job': 'Command expiry job',
'admin.health.not_yet': 'not yet (hourly)',
'admin.health.stuck': 'Stuck commands',
'admin.health.email': 'Email',
'admin.health.configured': 'Set up',
'admin.health.not_configured': 'Not set up',
'admin.health.node': 'Node.js',
'admin.health.tables': 'Rows per table',
},
nl: {
'nav.overview': 'Overzicht',
@@ -781,6 +950,175 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.status.cancelled': 'Geannuleerd',
'stock.reason.unknown_product': 'product staat niet op de kassa',
'stock.reason.stock_not_tracked': 'voorraad niet bijgehouden op de kassa',
'nav.admin': 'KoolTap-beheer',
'users.role.superadmin': 'Superadmin',
'errors.cannot_delete_own_venue': 'Je kan de zaak van je eigen account niet verwijderen',
'errors.confirm_name_mismatch': 'Typ de naam van de zaak exact om te bevestigen',
'errors.same_venue': 'Kies een andere zaak om van te kopiëren',
'errors.cannot_change_own_role': 'Je kan je eigen rol niet wijzigen',
'errors.cannot_logout_self': 'Gebruik Uitloggen om jezelf af te melden',
'errors.use_account_page': 'Wijzig je eigen wachtwoord via Mijn account',
'errors.venue_not_found': 'Deze zaak bestaat niet meer',
'errors.device_not_found': 'Deze kassa bestaat niet meer',
'errors.email_failed': 'De e-mail kon niet verstuurd worden',
'admin.title': 'KoolTap-beheer',
'admin.desc': 'Alle zaken, kassa\'s en gebruikers. Wat je hier doet, kan je niet ongedaan maken.',
'admin.section.venues': 'Zaken',
'admin.section.devices': 'Kassa\'s',
'admin.section.users': 'Gebruikers',
'admin.section.maintenance': 'Onderhoud',
'admin.section.audit': 'Logboek',
'admin.section.health': 'Systeem',
'admin.working': 'Bezig...',
'admin.save': 'Opslaan',
'admin.never': 'nooit',
'admin.preview': 'Dit wordt verwijderd:',
'admin.all_venues': 'Alle zaken',
'admin.type_to_confirm': 'Typ {text} om te bevestigen',
'admin.export_first': 'Eerst een back-up (JSON) downloaden',
'admin.viewing_banner': 'Je bekijkt {name} als KoolTap-beheerder. Alles wat je hier doet, gebeurt in deze zaak.',
'admin.viewing_stop': 'Terug naar KoolTap-beheer',
'admin.venues.desc': 'Kies een zaak om te zien wat je ermee kan doen.',
'admin.venues.pick': 'Kies hierboven een zaak.',
'admin.venues.create': 'Nieuwe zaak',
'admin.venues.created': '{name} aangemaakt',
'admin.venues.name': 'Naam',
'admin.venues.users': 'Gebruikers',
'admin.venues.terminals': 'Kassa\'s',
'admin.venues.open_tabs': 'Open poefs',
'admin.venues.products': 'Producten',
'admin.venues.last_sync': 'Laatste sync',
'admin.venues.view': 'Bekijk als zaak',
'admin.venues.check': 'Gegevens controleren',
'admin.venues.export': 'Exporteren',
'admin.venues.pair': 'Koppelcode',
'admin.venues.pair_code': 'Koppelcode:',
'admin.venues.pair_expires': 'geldig tot {time}',
'admin.venues.rename': 'Hernoemen',
'admin.venues.rename_title': '{name} hernoemen',
'admin.venues.renamed': 'Zaak hernoemd',
'admin.venues.copy_catalog': 'Producten kopiëren',
'admin.venues.reset': 'Resetten…',
'admin.venues.delete': 'Verwijderen…',
'admin.check.clean': 'Niets gevonden: de gegevens kloppen.',
'admin.check.severity.error': 'Fout',
'admin.check.severity.warning': 'Waarschuwing',
'admin.check.severity.info': 'Opmerking',
'admin.reset.title': '{name} resetten',
'admin.reset.desc': 'Gebruikers, kassa\'s en producten blijven. De kassa\'s nemen de wijziging over bij hun volgende sync.',
'admin.reset.preset_tabs': 'Alle poefs wissen',
'admin.reset.preset_event': 'Nieuw evenement',
'admin.reset.preset_full': 'Volledige reset',
'admin.reset.scope.tabs': 'Open poefs',
'admin.reset.scope.tabs_hint': 'Onbetaalde drankjes erop gaan verloren',
'admin.reset.scope.settlements': 'Betaalde poefs',
'admin.reset.scope.settlements_hint': 'De verkoopgeschiedenis',
'admin.reset.scope.tabEvents': 'Kassagebeurtenissen',
'admin.reset.scope.tabEvents_hint': 'Alleen als elke kassa gesynct is: een opnieuw verstuurde gebeurtenis telt anders dubbel',
'admin.reset.scope.plannedTabs': 'Geplande klanten',
'admin.reset.scope.plannedTabs_hint': 'Met hun e-mailadressen en herinneringen',
'admin.reset.scope.stock': 'Voorraad',
'admin.reset.scope.stock_hint': 'Elk opgevolgd product krijgt dezelfde telling',
'admin.reset.scope.stockHistory': 'Voorraadgeschiedenis',
'admin.reset.scope.stockHistory_hint': 'Leveringen, breuk en tellingen',
'admin.reset.scope.commands': 'Opdrachtengeschiedenis',
'admin.reset.scope.commands_hint': 'Poefs afgesloten via het dashboard',
'admin.reset.scope.auditLog': 'Logboek',
'admin.reset.scope.auditLog_hint': 'Een regel voor deze reset blijft bewaard',
'admin.reset.stock_value': 'Voorraadtelling',
'admin.reset.confirm': 'Resetten',
'admin.reset.done': '{name} is gereset',
'admin.reset.done_desc': '{count} rijen gewijzigd',
'admin.copy.title': 'Producten kopiëren naar {name}',
'admin.copy.desc': 'Producten die deze zaak nog niet heeft (op naam) worden toegevoegd, met hun foto. De voorraad wordt niet gekopieerd.',
'admin.copy.from': 'Kopiëren van…',
'admin.copy.option': '{name} ({count} producten)',
'admin.copy.confirm': 'Kopiëren',
'admin.copy.done': '{count} producten toegevoegd',
'admin.delete.title': '{name} verwijderen?',
'admin.delete.desc': 'De zaak wordt verwijderd met alles erin: {users} gebruikers, {terminals} kassa\'s en {settlements} betaalde poefs. De kassa\'s werken niet meer.',
'admin.delete.confirm': 'Zaak verwijderen',
'admin.delete.done': '{name} is verwijderd',
'admin.devices.show_unpaired': 'Ontkoppelde kassa\'s tonen ({count})',
'admin.devices.name': 'Kassa',
'admin.devices.venue': 'Zaak',
'admin.devices.status': 'Status',
'admin.devices.last_seen': 'Laatst gezien',
'admin.devices.token_expires': 'Token geldig tot',
'admin.devices.pending': 'Wachtende opdrachten',
'admin.devices.unpaired': 'Ontkoppeld',
'admin.codes.title': 'Koppelcodes',
'admin.codes.desc': 'Codes die nog gebruikt kunnen worden om een kassa te koppelen.',
'admin.codes.none': 'Geen codes die nog wachten.',
'admin.codes.revoke': 'Intrekken',
'admin.codes.cleanup': 'Gebruikte en verlopen codes verwijderen ({count})',
'admin.codes.cleaned': '{count} codes verwijderd',
'admin.users.search': 'Zoek op naam, e-mail of zaak',
'admin.users.sessions': 'Sessies',
'admin.users.role_changed': '{email} is nu {role}',
'admin.users.password': 'Wachtwoord instellen',
'admin.users.password_title': 'Nieuw wachtwoord voor {email}',
'admin.users.password_desc': 'De gebruiker wordt overal uitgelogd en logt in met dit wachtwoord.',
'admin.users.password_confirm': 'Wachtwoord instellen',
'admin.users.password_set': 'Wachtwoord van {email} gewijzigd',
'admin.users.logout': 'Overal uitloggen',
'admin.users.logout_title': '{email} uitloggen?',
'admin.users.logout_desc': 'Elke sessie van deze gebruiker stopt meteen. Opnieuw inloggen kan.',
'admin.users.logged_out': '{email} is uitgelogd',
'admin.users.everyone': 'Iedereen uitloggen',
'admin.users.everyone_title': 'Alle gebruikers uitloggen?',
'admin.users.everyone_desc': 'Iedereen behalve jij wordt meteen uitgelogd, in elke zaak. De kassa\'s blijven werken.',
'admin.users.everyone_done': '{count} gebruikers uitgelogd',
'admin.stuck.title': 'Vastgelopen opdrachten',
'admin.stuck.desc': 'Afsluitverzoeken die geen kassa oppikte. De server sluit poefs nu zelf af, dus deze worden nooit meer uitgevoerd.',
'admin.stuck.count': 'Wachtend: {count}',
'admin.stuck.confirm': 'Alle laten verlopen',
'admin.stuck.done': '{count} opdrachten verlopen',
'admin.images.title': 'Ongebruikte foto\'s',
'admin.images.desc': 'Productfoto\'s die geen product meer gebruikt, ouder dan een week.',
'admin.images.count': '{count} foto\'s, {size}',
'admin.images.confirm': 'Verwijderen',
'admin.images.done': '{count} foto\'s verwijderd',
'admin.purge.title': 'Oude geschiedenis verwijderen',
'admin.purge.desc': 'Houdt de database klein. Open poefs, verkopen en voorraad blijven altijd.',
'admin.purge.target.tabEvents': 'Kassagebeurtenissen',
'admin.purge.target.tabEvents_hint': 'Wat de kassa\'s doorgaven, drankje per drankje',
'admin.purge.target.auditLog': 'Logboek',
'admin.purge.target.auditLog_hint': 'Wie wat deed op het dashboard',
'admin.purge.target.commands': 'Afgehandelde opdrachten',
'admin.purge.target.commands_hint': 'Wachtende blijven bewaard',
'admin.purge.days': 'Ouder dan (dagen)',
'admin.purge.preview': 'Voorbeeld…',
'admin.purge.confirm_desc': 'Alles ouder dan {days} dagen:',
'admin.purge.confirm': 'Verwijderen',
'admin.purge.done': '{count} rijen verwijderd',
'admin.email.title': 'Test-e-mail',
'admin.email.desc': 'Verstuurt de test-e-mail, om te controleren dat herinneringen versturen werkt.',
'admin.email.send': 'Versturen',
'admin.email.sent': 'Test-e-mail verstuurd naar {email}',
'admin.audit.action_filter': 'Filter op actie',
'admin.audit.when': 'Wanneer',
'admin.audit.who': 'Wie',
'admin.audit.action': 'Actie',
'admin.audit.details': 'Details',
'admin.audit.page': 'Pagina {page} van {pages} ({total} regels)',
'admin.audit.previous': 'Vorige',
'admin.audit.next': 'Volgende',
'admin.health.database': 'Databasegrootte',
'admin.health.uptime': 'API draait sinds',
'admin.health.connections': 'Live dashboards',
'admin.health.migrations': 'Migraties uitgevoerd',
'admin.health.migrations_failed': '{count} mislukt',
'admin.health.failed_list': 'Mislukte migraties: {names}',
'admin.health.latest_migration': 'Laatste migratie:',
'admin.health.expiry_job': 'Opdrachten-verloopjob',
'admin.health.not_yet': 'nog niet (elk uur)',
'admin.health.stuck': 'Vastgelopen opdrachten',
'admin.health.email': 'E-mail',
'admin.health.configured': 'Ingesteld',
'admin.health.not_configured': 'Niet ingesteld',
'admin.health.node': 'Node.js',
'admin.health.tables': 'Rijen per tabel',
},
};
+6 -3
View File
@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react
import { useQueryClient } from '@tanstack/react-query';
import { io } from 'socket.io-client';
import { API_URL, getAccessToken, refreshAccessToken } from '@/lib/api/client';
import { useSession } from '@/lib/session';
// Server topics mapped to the dashboard queries that show that data
const TOPIC_QUERIES: Record<string, string[][]> = {
@@ -20,12 +21,14 @@ const RealtimeContext = createContext({ connected: false });
export function RealtimeProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { venueId } = useSession();
const [connected, setConnected] = useState(false);
useEffect(() => {
const socket = io(`${new URL(API_URL).origin}/realtime`, {
// Read on every (re)connect so a refreshed token is used
auth: (cb) => cb({ token: getAccessToken() }),
// Read on every (re)connect so a refreshed token is used. The venue is only used for a superadmin viewing
// another venue; everyone else follows their own
auth: (cb) => cb({ token: getAccessToken(), venueId }),
transports: ['websocket', 'polling'],
});
@@ -64,7 +67,7 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
return () => {
socket.disconnect();
};
}, [queryClient]);
}, [queryClient, venueId]);
return <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
}
+48 -1
View File
@@ -7,9 +7,18 @@ export interface Session {
userId: string;
email: string;
role: Role;
/** The venue the dashboard shows: the user's own, or the one a superadmin is viewing */
venueId: string;
/** The venue the user belongs to */
homeVenueId: string;
/** Set while a superadmin views another venue */
viewing: ViewedVenue | null;
}
export type ViewedVenue = { id: string; name: string };
const VIEWING_KEY = 'kooltap-viewing-venue';
/** The signed-in user from the access token cookie, or null when there is none or it can't be read. */
export function readSession(): Session | null {
const token = getCookie('access_token');
@@ -20,12 +29,50 @@ export function readSession(): Session | null {
if (!payload.venueId || !payload.role) return null;
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
const role = payload.role === 'viewer' ? 'manager' : payload.role;
return { userId: payload.sub, email: payload.email, role, venueId: payload.venueId };
const viewing = role === 'superadmin' ? readViewing() : null;
const showing = viewing && viewing.id !== payload.venueId ? viewing : null;
return {
userId: payload.sub,
email: payload.email,
role,
venueId: showing?.id ?? payload.venueId,
homeVenueId: payload.venueId,
viewing: showing,
};
} catch {
return null;
}
}
function readViewing(): ViewedVenue | null {
try {
const stored = JSON.parse(localStorage.getItem(VIEWING_KEY) ?? 'null');
return stored && typeof stored.id === 'string' && typeof stored.name === 'string' ? stored : null;
} catch {
return null;
}
}
/**
* Shows the dashboard as [venue], or the superadmin's own venue with null. Reloads, so every page and query starts
* over with the other venue.
*/
export function viewVenue(venue: ViewedVenue | null, path = '/dashboard') {
try {
if (venue) localStorage.setItem(VIEWING_KEY, JSON.stringify(venue));
else localStorage.removeItem(VIEWING_KEY);
} catch {
// Without storage a superadmin can only see their own venue
}
window.location.href = path;
}
/** Users and terminals: the owner, and KoolTap's superadmins. */
export const canAdministerVenue = (role: Role) => role === 'admin' || role === 'superadmin';
/** Products, stock, tabs and reminders. */
export const canManageVenue = (role: Role) => role === 'admin' || role === 'manager' || role === 'superadmin';
const SessionContext = createContext<Session | null>(null);
export function SessionProvider({ session, children }: { session: Session; children: ReactNode }) {