feat: add delete user and change password functioality
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-22 23:19:56 +02:00
parent 48ad82acd1
commit 383ba32935
14 changed files with 477 additions and 82 deletions
@@ -0,0 +1,97 @@
'use client';
import { FormEvent, useState } from 'react';
import { authApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
const MIN_PASSWORD_LENGTH = 6;
export default function AccountPage() {
const { email, role } = useSession();
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
const [submitting, setSubmitting] = useState(false);
const { t, te } = useLang();
const tooShort = form.next.length > 0 && form.next.length < MIN_PASSWORD_LENGTH;
const mismatch = form.confirm.length > 0 && form.next !== form.confirm;
const canSubmit = form.current.length > 0 && form.next.length >= MIN_PASSWORD_LENGTH && form.next === form.confirm;
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
try {
await authApi.changePassword(form.current, form.next);
toast({ title: t('account.password_changed'), description: t('account.password_changed_desc'), variant: 'success' });
setForm({ current: '', next: '', confirm: '' });
} catch (err) {
toast({ title: t('account.password_failed'), description: te(err), variant: 'destructive' });
} finally {
setSubmitting(false);
}
}
return (
<div>
<PageHeader title={t('account.title')} description={`${email} · ${t(`users.role.${role}`)}`} />
<Card className="max-w-md">
<CardHeader>
<CardTitle>{t('account.change_password')}</CardTitle>
<CardDescription>{t('account.change_password_desc')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Lets password managers tie the new password to this account */}
<input type="email" name="username" autoComplete="username" value={email} readOnly hidden />
<div className="space-y-2">
<Label htmlFor="current-password">{t('account.current_password')}</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={form.current}
onChange={(e) => setForm({ ...form, current: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t('account.new_password')}</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={form.next}
onChange={(e) => setForm({ ...form, next: e.target.value })}
placeholder={t('users.password_placeholder')}
aria-invalid={tooShort}
/>
{tooShort && <p className="text-xs text-destructive">{t('account.too_short')}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">{t('account.confirm_password')}</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={form.confirm}
onChange={(e) => setForm({ ...form, confirm: e.target.value })}
aria-invalid={mismatch}
/>
{mismatch && <p className="text-xs text-destructive">{t('account.mismatch')}</p>}
</div>
<Button type="submit" disabled={submitting || !canSubmit}>
{submitting ? t('account.saving') : t('account.save')}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -14,6 +14,7 @@ import {
X,
Bell,
Package,
UserCog,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
@@ -33,6 +34,7 @@ const navItems = [
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
{ href: '/dashboard/account', key: 'nav.account', icon: UserCog, adminOnly: false },
];
export default function DashboardLayout({
+47 -7
View File
@@ -18,7 +18,8 @@ import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { formatCents } from '@/lib/utils';
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown, Search } from 'lucide-react';
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown, Search, Trash2 } from 'lucide-react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
@@ -39,6 +40,7 @@ export default function TabsPage() {
const canManage = role === 'admin' || role === 'manager';
const queryClient = useQueryClient();
const [closeTab, setCloseTab] = useState<Tab | null>(null);
const [deleteTab, setDeleteTab] = useState<Tab | null>(null);
const [paymentMethod, setPaymentMethod] = useState('');
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
@@ -92,6 +94,16 @@ export default function TabsPage() {
},
});
const deleteTabMutation = useMutation({
mutationFn: (tab: Tab) => tabsApi.remove(venueId, tab.tabId, tab.version),
onSuccess: (_, tab) => toast({ title: t('tabs.deleted', { name: tab.customerName }), variant: 'success' }),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
setDeleteTab(null);
queryClient.invalidateQueries({ queryKey: ['tabs'] });
},
});
function openCloseDialog(tab: Tab) {
setPaymentMethod('');
setCloseTab(tab);
@@ -304,12 +316,26 @@ export default function TabsPage() {
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{relativeTime(tab.lastChangedAt)}</TableCell>
<TableCell className="text-right">
{closingTabIds.has(tab.tabId) ? (
<Badge variant="warning">{t('tabs.closing')}</Badge>
) : (
// Closing settles the tab on the terminal; a tab without a balance has nothing to settle
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => openCloseDialog(tab)} aria-label={t('tabs.close_label', { name: tab.customerName })}>{t('tabs.close')}</Button>
)}
<div className="flex items-center justify-end gap-1">
{closingTabIds.has(tab.tabId) ? (
<Badge variant="warning">{t('tabs.closing')}</Badge>
) : (
// Closing settles the tab on the terminal; a tab without a balance has nothing to settle
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => openCloseDialog(tab)} aria-label={t('tabs.close_label', { name: tab.customerName })}>{t('tabs.close')}</Button>
)}
{canManage && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTab(tab)}
title={t('tabs.delete_label', { name: tab.customerName })}
aria-label={t('tabs.delete_label', { name: tab.customerName })}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
{expanded && (
@@ -338,6 +364,20 @@ export default function TabsPage() {
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
/>
<ConfirmDialog
open={deleteTab !== null}
onOpenChange={(open) => !open && !deleteTabMutation.isPending && setDeleteTab(null)}
title={t('tabs.delete_title', { name: deleteTab?.customerName ?? '' })}
description={deleteTab && deleteTab.totalInCents > 0
? t('tabs.delete_desc_unpaid', { amount: formatCents(deleteTab.totalInCents) })
: t('tabs.delete_desc')}
confirmLabel={t('tabs.delete')}
cancelLabel={t('common.cancel')}
variant="destructive"
loading={deleteTabMutation.isPending}
onConfirm={() => deleteTab && deleteTabMutation.mutate(deleteTab)}
/>
{closeTab && (
<Dialog open onOpenChange={(open) => !open && !createCommandMutation.isPending && setCloseTab(null)}>
<DialogContent>
@@ -2,7 +2,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { usersApi } from '@/lib/api/client';
import { usersApi, type User } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
@@ -17,12 +17,14 @@ import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { RefreshCw, Plus } from 'lucide-react';
import { RefreshCw, Plus, Trash2 } from 'lucide-react';
import { ConfirmDialog } from '@/components/confirm-dialog';
export default function UsersPage() {
const queryClient = useQueryClient();
const { venueId, role } = useSession();
const { venueId, role, userId } = useSession();
const [addDialog, setAddDialog] = useState(false);
const [deleting, setDeleting] = useState<User | null>(null);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'manager' });
const [submitting, setSubmitting] = useState(false);
const { t, tn, te, relativeTime } = useLang();
@@ -50,6 +52,16 @@ export default function UsersPage() {
}
}
const deleteMutation = useMutation({
mutationFn: (user: User) => usersApi.remove(venueId, user.id),
onSuccess: (_, user) => toast({ title: t('users.deleted', { email: user.email }), variant: 'success' }),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
setDeleting(null);
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={te(error, 'users.load_failed')} />;
@@ -83,6 +95,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>}
</TableRow>
</TableHeader>
<TableBody>
@@ -92,6 +105,23 @@ export default function UsersPage() {
<TableCell>{u.email}</TableCell>
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
<TableCell>{relativeTime(u.createdAt)}</TableCell>
{role === 'admin' && (
<TableCell className="text-right">
{/* You can't delete yourself, so the venue always keeps an admin */}
{u.id !== userId && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => setDeleting(u)}
title={t('users.delete_label', { name: u.name })}
aria-label={t('users.delete_label', { name: u.name })}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
@@ -99,6 +129,18 @@ export default function UsersPage() {
</div>
)}
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => !open && !deleteMutation.isPending && setDeleting(null)}
title={t('users.delete_title', { name: deleting?.name ?? '' })}
description={t('users.delete_desc', { email: deleting?.email ?? '' })}
confirmLabel={t('users.delete')}
cancelLabel={t('common.cancel')}
variant="destructive"
loading={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting)}
/>
<Dialog open={addDialog} onOpenChange={setAddDialog}>
<DialogContent>
<DialogHeader>
+14
View File
@@ -262,12 +262,24 @@ export const authApi = {
}).catch(() => {});
}
},
/** Signs out every other session; this one carries on with the new tokens */
changePassword: async (currentPassword: string, newPassword: string) => {
const result = await api<{ accessToken: string; refreshToken: string }>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
setTokens(result.accessToken, result.refreshToken);
},
};
// Tabs API
export const tabsApi = {
list: (venueId: string) =>
api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`),
/** Deletes the tab without settling it; refused when it changed since [expectedVersion] */
remove: (venueId: string, tabId: string, expectedVersion: number) =>
api(`/venues/${venueId}/tabs/${encodeURIComponent(tabId)}?expectedVersion=${expectedVersion}`, { method: 'DELETE' }),
};
// Devices API
@@ -285,6 +297,8 @@ export const usersApi = {
api<{ users: User[] }>(`/venues/${venueId}/users`),
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
remove: (venueId: string, userId: string) =>
api(`/venues/${venueId}/users/${userId}`, { method: 'DELETE' }),
};
// Commands API
+56
View File
@@ -11,6 +11,7 @@ const translations: Record<Lang, Record<string, string>> = {
'nav.users': 'Users',
'nav.commands': 'Command History',
'nav.settings': 'Settings',
'nav.account': 'My Account',
'nav.logout': 'Logout',
'realtime.live': 'Live updates on',
'realtime.offline': 'Live updates unavailable — refreshing periodically',
@@ -38,6 +39,9 @@ const translations: Record<Lang, Record<string, string>> = {
'errors.reminders_in_progress': 'Reminders are already being sent',
'errors.no_email': 'This customer has no email address',
'errors.forbidden': "You don't have permission to do this",
'errors.wrong_password': 'The current password is not correct',
'errors.cannot_delete_self': 'You cannot delete your own account',
'errors.user_not_found': 'This user no longer exists',
'errors.venue_forbidden': "You don't have permission to do this",
'errors.internal_error': 'Something went wrong on the server',
'errors.network_error': "Can't reach the server. Check the connection.",
@@ -98,6 +102,12 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.send': 'Settle tab',
'tabs.sending': 'Settling…',
'tabs.close_label': "Close {name}'s tab",
'tabs.delete': 'Delete',
'tabs.delete_label': "Delete {name}'s tab",
'tabs.delete_title': "Delete {name}'s tab?",
'tabs.delete_desc': 'The tab is removed from every terminal on its next sync.',
'tabs.delete_desc_unpaid': 'There is still {amount} on this tab. It is removed without being paid, from every terminal on its next sync. To record a payment, close the tab instead.',
'tabs.deleted': "{name}'s tab is deleted",
'tabs.settled': 'Tab settled',
'tabs.settled_desc': '{name}: {amount}',
'tabs.send_test': 'Send test email',
@@ -160,6 +170,11 @@ const translations: Record<Lang, Record<string, string>> = {
'users.created_toast': 'User created',
'users.created_toast_desc': '{email} added as {role}',
'users.create_failed': 'Failed to create user',
'users.delete': 'Delete',
'users.delete_label': 'Delete {name}',
'users.delete_title': 'Delete {name}?',
'users.delete_desc': '{email} can no longer sign in to the dashboard and is signed out within a few minutes.',
'users.deleted': '{email} is deleted',
'commands.title': 'Command History',
'commands.count': '{count} command',
'commands.count_plural': '{count} commands',
@@ -207,6 +222,19 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.pair_step5': 'Complete registration',
'settings.copy': 'Copy Code',
'settings.copied_desc': 'Registration code copied to clipboard',
'account.title': 'My Account',
'account.change_password': 'Change Password',
'account.change_password_desc': 'Your other sessions are signed out; this one stays signed in.',
'account.current_password': 'Current password',
'account.new_password': 'New password',
'account.confirm_password': 'Repeat new password',
'account.too_short': 'Use at least 6 characters',
'account.mismatch': "The passwords don't match",
'account.save': 'Change Password',
'account.saving': 'Saving...',
'account.password_changed': 'Password changed',
'account.password_changed_desc': 'Use your new password the next time you sign in.',
'account.password_failed': 'Failed to change password',
'overview.title': 'Overview',
'overview.open_tabs': 'Open Tabs',
'overview.device': 'Terminal',
@@ -372,6 +400,7 @@ const translations: Record<Lang, Record<string, string>> = {
'nav.users': 'Gebruikers',
'nav.commands': 'Opdrachten',
'nav.settings': 'Instellingen',
'nav.account': 'Mijn account',
'nav.logout': 'Uitloggen',
'realtime.live': 'Live updates aan',
'realtime.offline': 'Live updates niet beschikbaar — wordt regelmatig vernieuwd',
@@ -399,6 +428,9 @@ const translations: Record<Lang, Record<string, string>> = {
'errors.reminders_in_progress': 'Herinneringen worden al verstuurd',
'errors.no_email': 'Deze klant heeft geen e-mailadres',
'errors.forbidden': 'Je hebt geen toestemming om dit te doen',
'errors.wrong_password': 'Het huidige wachtwoord klopt niet',
'errors.cannot_delete_self': 'Je kan je eigen account niet verwijderen',
'errors.user_not_found': 'Deze gebruiker bestaat niet meer',
'errors.venue_forbidden': 'Je hebt geen toestemming om dit te doen',
'errors.internal_error': 'Er ging iets mis op de server',
'errors.network_error': 'Kan de server niet bereiken. Controleer de verbinding.',
@@ -459,6 +491,12 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.send': 'Poef afrekenen',
'tabs.sending': 'Bezig met afrekenen…',
'tabs.close_label': 'Poef van {name} sluiten',
'tabs.delete': 'Verwijderen',
'tabs.delete_label': 'Poef van {name} verwijderen',
'tabs.delete_title': 'Poef van {name} verwijderen?',
'tabs.delete_desc': 'De poef verdwijnt van elke terminal bij de volgende synchronisatie.',
'tabs.delete_desc_unpaid': 'Er staat nog {amount} op deze poef. Ze wordt verwijderd zonder betaling, van elke terminal bij de volgende synchronisatie. Wil je een betaling registreren, sluit de poef dan.',
'tabs.deleted': 'Poef van {name} is verwijderd',
'tabs.settled': 'Poef afgerekend',
'tabs.settled_desc': '{name}: {amount}',
'tabs.send_test': 'Testmail versturen',
@@ -521,6 +559,11 @@ const translations: Record<Lang, Record<string, string>> = {
'users.created_toast': 'Gebruiker aangemaakt',
'users.created_toast_desc': '{email} toegevoegd als {role}',
'users.create_failed': 'Gebruiker aanmaken mislukt',
'users.delete': 'Verwijderen',
'users.delete_label': '{name} verwijderen',
'users.delete_title': '{name} verwijderen?',
'users.delete_desc': '{email} kan niet meer inloggen op het dashboard en wordt binnen enkele minuten uitgelogd.',
'users.deleted': '{email} is verwijderd',
'commands.title': 'Opdrachtgeschiedenis',
'commands.count': '{count} opdracht',
'commands.count_plural': '{count} opdrachten',
@@ -568,6 +611,19 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.pair_step5': 'Rond de registratie af',
'settings.copy': 'Code kopiëren',
'settings.copied_desc': 'Registratiecode gekopieerd naar het klembord',
'account.title': 'Mijn account',
'account.change_password': 'Wachtwoord wijzigen',
'account.change_password_desc': 'Je andere sessies worden uitgelogd; deze blijft ingelogd.',
'account.current_password': 'Huidig wachtwoord',
'account.new_password': 'Nieuw wachtwoord',
'account.confirm_password': 'Herhaal nieuw wachtwoord',
'account.too_short': 'Gebruik minstens 6 tekens',
'account.mismatch': 'De wachtwoorden komen niet overeen',
'account.save': 'Wachtwoord wijzigen',
'account.saving': 'Opslaan...',
'account.password_changed': 'Wachtwoord gewijzigd',
'account.password_changed_desc': 'Gebruik je nieuwe wachtwoord de volgende keer dat je inlogt.',
'account.password_failed': 'Wachtwoord wijzigen mislukt',
'overview.title': 'Overzicht',
'overview.open_tabs': 'Open poefs',
'overview.device': 'Terminal',