This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user