diff --git a/frontend/src/__tests__/settings/SettingsCurrencies.validation.test.tsx b/frontend/src/__tests__/settings/SettingsCurrencies.validation.test.tsx new file mode 100644 index 0000000..99c5ee0 --- /dev/null +++ b/frontend/src/__tests__/settings/SettingsCurrencies.validation.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('@/api/client', () => ({ + apiGet: vi.fn().mockResolvedValue({ items: [], total: 0 }), + apiPost: vi.fn().mockResolvedValue({ id: '1', code: 'EUR', name: 'Euro', symbol: '€', is_default: true }), + apiPatch: vi.fn().mockResolvedValue({}), + apiDelete: vi.fn().mockResolvedValue({}), +})); + +import { SettingsCurrenciesPage } from '@/pages/SettingsCurrencies'; +import { apiPost } from '@/api/client'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('SettingsCurrencies validation (RHF + Zod)', () => { + it('shows validation error when code is empty', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('settings-currencies')).toBeInTheDocument(); + }); + // Click add button to show form (German locale default in test env) + const addBtn = screen.getByText(/währung hinzufügen|add currency/i); + fireEvent.click(addBtn); + // Submit empty form + const saveBtn = screen.getByRole('button', { name: /speichern|^save$/i }); + fireEvent.click(saveBtn); + await waitFor(() => { + const errors = screen.getAllByText(/erforderlich|required/i); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + it('submits successfully with valid data', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('settings-currencies')).toBeInTheDocument(); + }); + const addBtn = screen.getByText(/währung hinzufügen|add currency/i); + fireEvent.click(addBtn); + // Fill form — code, symbol, name + const inputs = screen.getAllByRole('textbox'); + fireEvent.change(inputs[0], { target: { value: 'EUR' } }); // code + fireEvent.change(inputs[1], { target: { value: '€' } }); // symbol + fireEvent.change(inputs[2], { target: { value: 'Euro' } }); // name + const saveBtn = screen.getByRole('button', { name: /speichern|^save$/i }); + fireEvent.click(saveBtn); + await waitFor(() => { + expect(apiPost).toHaveBeenCalledWith('/currencies', expect.objectContaining({ code: 'EUR', name: 'Euro', symbol: '€' })); + }); + }); +}); diff --git a/frontend/src/pages/SettingsCurrencies.tsx b/frontend/src/pages/SettingsCurrencies.tsx index 7694b5f..2394235 100644 --- a/frontend/src/pages/SettingsCurrencies.tsx +++ b/frontend/src/pages/SettingsCurrencies.tsx @@ -1,5 +1,8 @@ import React, { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client'; interface Currency { @@ -10,6 +13,16 @@ interface Currency { is_default: boolean; } +// ── Zod Schema ── +const currencySchema = z.object({ + code: z.string().min(1, 'required').max(3, 'maxLength').toUpperCase(), + name: z.string().min(1, 'required'), + symbol: z.string().min(1, 'required').max(5, 'maxLength'), + is_default: z.boolean().default(false), +}); + +type CurrencyFormData = z.infer; + export function SettingsCurrenciesPage() { const { t } = useTranslation(); const [currencies, setCurrencies] = useState([]); @@ -17,7 +30,16 @@ export function SettingsCurrenciesPage() { const [error, setError] = useState(null); const [showForm, setShowForm] = useState(false); const [editing, setEditing] = useState(null); - const [formData, setFormData] = useState({ code: '', name: '', symbol: '', is_default: false }); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(currencySchema), + defaultValues: { code: '', name: '', symbol: '', is_default: false }, + }); const fetchCurrencies = useCallback(async () => { setLoading(true); @@ -34,17 +56,16 @@ export function SettingsCurrenciesPage() { React.useEffect(() => { fetchCurrencies(); }, [fetchCurrencies]); - const handleSave = async (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = async (data: CurrencyFormData) => { try { if (editing) { - await apiPatch(`/currencies/${editing.id}`, formData); + await apiPatch(`/currencies/${editing.id}`, data); } else { - await apiPost('/currencies', formData); + await apiPost('/currencies', data); } setShowForm(false); setEditing(null); - setFormData({ code: '', name: '', symbol: '', is_default: false }); + reset({ code: '', name: '', symbol: '', is_default: false }); await fetchCurrencies(); } catch (err: any) { setError(err.message || t('common.error')); @@ -63,10 +84,23 @@ export function SettingsCurrenciesPage() { const handleEdit = (c: Currency) => { setEditing(c); - setFormData({ code: c.code, name: c.name, symbol: c.symbol, is_default: c.is_default }); + reset({ code: c.code, name: c.name, symbol: c.symbol, is_default: c.is_default }); setShowForm(true); }; + const handleNew = () => { + setEditing(null); + reset({ code: '', name: '', symbol: '', is_default: false }); + setShowForm(true); + }; + + const errorMsg = (key: string | undefined) => { + if (!key) return undefined; + if (key === 'required') return t('validation.required'); + if (key === 'maxLength') return t('validation.maxLength', { count: key === 'maxLength' ? 5 : 3 }); + return key; + }; + if (loading) return {t('common.loading')}; return ( @@ -74,7 +108,7 @@ export function SettingsCurrenciesPage() { {t('currencies.title')} { setEditing(null); setFormData({ code: '', name: '', symbol: '', is_default: false }); setShowForm(true); }} + onClick={handleNew} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700" > {t('currencies.add')} @@ -84,28 +118,31 @@ export function SettingsCurrenciesPage() { {error && {error}} {showForm && ( - + {t('currencies.code')} - setFormData({ ...formData, code: e.target.value.toUpperCase() })} maxLength={3} required disabled={!!editing} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm disabled:bg-secondary-100" /> + + {errors.code && {errorMsg(errors.code.message)}} {t('currencies.symbol')} - setFormData({ ...formData, symbol: e.target.value })} maxLength={5} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> + + {errors.symbol && {errorMsg(errors.symbol.message)}} {t('currencies.name')} - setFormData({ ...formData, name: e.target.value })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> + + {errors.name && {errorMsg(errors.name.message)}} - setFormData({ ...formData, is_default: e.target.checked })} /> + {t('currencies.isDefault')} { setShowForm(false); setEditing(null); }} className="px-3 py-1.5 text-sm font-medium text-secondary-700 bg-secondary-100 rounded-lg hover:bg-secondary-200">{t('common.cancel')} - {t('common.save')} + {t('common.save')} )} diff --git a/frontend/src/pages/SettingsGroups.tsx b/frontend/src/pages/SettingsGroups.tsx index 94f8bd2..e9a51d4 100644 --- a/frontend/src/pages/SettingsGroups.tsx +++ b/frontend/src/pages/SettingsGroups.tsx @@ -1,5 +1,8 @@ import React, { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { useGroups, useCreateGroup, @@ -81,9 +84,19 @@ export function SettingsGroupsPage() { const updateGroupMutation = useUpdateGroup(); const deleteGroupMutation = useDeleteGroup(); + // ── Zod Schema for create form ── + const groupCreateSchema = z.object({ + name: z.string().min(1, 'required'), + description: z.string().optional().default(''), + }); + type GroupCreateFormData = z.infer; + + const { register: registerGroup, handleSubmit: handleSubmitGroup, reset: resetGroup, formState: { errors: groupErrors } } = useForm({ + resolver: zodResolver(groupCreateSchema), + defaultValues: { name: '', description: '' }, + }); + const [createOpen, setCreateOpen] = useState(false); - const [newGroupName, setNewGroupName] = useState(''); - const [newGroupDescription, setNewGroupDescription] = useState(''); const [newGroupPermissions, setNewGroupPermissions] = useState>({}); const [newGroupDenied, setNewGroupDenied] = useState([]); const [editingGroup, setEditingGroup] = useState(null); @@ -117,22 +130,17 @@ export function SettingsGroupsPage() { return groups; }, [allPermissions]); - const handleCreateGroup = async () => { - if (!newGroupName.trim()) { - toast.error(t('validation.required', 'Pflichtfeld')); - return; - } + const handleCreateGroup = async (data: GroupCreateFormData) => { try { await createGroupMutation.mutateAsync({ - name: newGroupName.trim(), - description: newGroupDescription.trim() || null, + name: data.name.trim(), + description: data.description.trim() || null, permissions: newGroupPermissions, denied_permissions: newGroupDenied, field_permissions: {}, }); toast.success(t('settings.groupCreated', 'Gruppe erstellt')); - setNewGroupName(''); - setNewGroupDescription(''); + resetGroup({ name: '', description: '' }); setNewGroupPermissions({}); setNewGroupDenied([]); setCreateOpen(false); @@ -243,7 +251,7 @@ export function SettingsGroupsPage() { {t('settings.groups', 'Gruppen')} - setCreateOpen(true)} data-testid="create-group-btn"> + { resetGroup({ name: '', description: '' }); setCreateOpen(true); }} data-testid="create-group-btn"> {t('settings.createGroup', 'Gruppe erstellen')} @@ -317,18 +325,17 @@ export function SettingsGroupsPage() { title={t('settings.createGroup', 'Gruppe erstellen')} size="lg" > - + setNewGroupName(e.target.value)} + {...registerGroup('name')} + error={groupErrors.name?.message === 'required' ? t('validation.required') : undefined} placeholder={t('settings.groupName', 'Gruppenname')} data-testid="new-group-name" /> setNewGroupDescription(e.target.value)} + {...registerGroup('description')} placeholder={t('settings.groupDescription', 'Beschreibung')} data-testid="new-group-description" /> @@ -384,14 +391,14 @@ export function SettingsGroupsPage() { setCreateOpen(false)}>{t('common.cancel')} {t('common.save')} - + {/* Edit Group Modal */} diff --git a/frontend/src/pages/SettingsRoles.tsx b/frontend/src/pages/SettingsRoles.tsx index 3a8e21e..026ab5f 100644 --- a/frontend/src/pages/SettingsRoles.tsx +++ b/frontend/src/pages/SettingsRoles.tsx @@ -1,5 +1,8 @@ import React, { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { useRoles, useCreateRole, useUpdateRole, useDeleteRole, usePermissions } from '@/api/hooks'; import type { PermissionItem, FieldDefinition } from '@/api/hooks'; import { Input } from '@/components/ui/Input'; @@ -69,8 +72,18 @@ export function SettingsRolesPage() { const updateRoleMutation = useUpdateRole(); const deleteRoleMutation = useDeleteRole(); + // ── Zod Schema for create form ── + const roleCreateSchema = z.object({ + name: z.string().min(1, 'required'), + }); + type RoleCreateFormData = z.infer; + + const { register: registerRole, handleSubmit: handleSubmitRole, reset: resetRole, formState: { errors: roleErrors } } = useForm({ + resolver: zodResolver(roleCreateSchema), + defaultValues: { name: '' }, + }); + const [createOpen, setCreateOpen] = useState(false); - const [newRoleName, setNewRoleName] = useState(''); const [newRolePermissions, setNewRolePermissions] = useState>({}); const [newRoleDenied, setNewRoleDenied] = useState([]); const [editingRole, setEditingRole] = useState(null); @@ -117,20 +130,16 @@ export function SettingsRolesPage() { return groups; }, [fieldDefinitions]); - const handleCreateRole = async () => { - if (!newRoleName.trim()) { - toast.error(t('validation.required')); - return; - } + const handleCreateRole = async (data: RoleCreateFormData) => { try { await createRoleMutation.mutateAsync({ - name: newRoleName.trim(), + name: data.name.trim(), permissions: newRolePermissions, denied_permissions: newRoleDenied, field_permissions: {}, } as any); toast.success(t('settings.roleCreated')); - setNewRoleName(''); + resetRole({ name: '' }); setNewRolePermissions({}); setNewRoleDenied([]); setCreateOpen(false); @@ -238,7 +247,7 @@ export function SettingsRolesPage() { {t('settings.roles')} - setCreateOpen(true)} data-testid="create-role-btn"> + { resetRole({ name: '' }); setCreateOpen(true); }} data-testid="create-role-btn"> {t('settings.createRole')} @@ -292,11 +301,11 @@ export function SettingsRolesPage() { )} setCreateOpen(false)} title={t('settings.createRole')}> - + setNewRoleName(e.target.value)} + {...registerRole('name')} + error={roleErrors.name?.message === 'required' ? t('validation.required') : undefined} placeholder={t('settings.roleName')} data-testid="new-role-name" /> @@ -350,14 +359,14 @@ export function SettingsRolesPage() { setCreateOpen(false)}>{t('common.cancel')} {t('common.save')} - + setEditingRole(null)} title={t('settings.roles') + ' — ' + (editingRole?.name || '')} size="xl"> diff --git a/frontend/src/pages/SettingsSequences.tsx b/frontend/src/pages/SettingsSequences.tsx index 6dcb7c2..4661eda 100644 --- a/frontend/src/pages/SettingsSequences.tsx +++ b/frontend/src/pages/SettingsSequences.tsx @@ -1,5 +1,8 @@ import React, { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client'; interface Sequence { @@ -10,6 +13,15 @@ interface Sequence { padding: number; } +// ── Zod Schema ── +const sequenceSchema = z.object({ + name: z.string().min(1, 'required'), + prefix: z.string().optional().default(''), + padding: z.coerce.number().int().min(1, 'invalidNumber').max(10, 'invalidNumber'), +}); + +type SequenceFormData = z.infer; + export function SettingsSequencesPage() { const { t } = useTranslation(); const [sequences, setSequences] = useState([]); @@ -17,7 +29,16 @@ export function SettingsSequencesPage() { const [error, setError] = useState(null); const [showForm, setShowForm] = useState(false); const [editing, setEditing] = useState(null); - const [formData, setFormData] = useState({ name: '', prefix: '', padding: 4 }); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(sequenceSchema), + defaultValues: { name: '', prefix: '', padding: 4 }, + }); const fetchSequences = useCallback(async () => { setLoading(true); @@ -34,17 +55,16 @@ export function SettingsSequencesPage() { React.useEffect(() => { fetchSequences(); }, [fetchSequences]); - const handleSave = async (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = async (data: SequenceFormData) => { try { if (editing) { - await apiPatch(`/sequences/${editing.id}`, formData); + await apiPatch(`/sequences/${editing.id}`, data); } else { - await apiPost('/sequences', formData); + await apiPost('/sequences', data); } setShowForm(false); setEditing(null); - setFormData({ name: '', prefix: '', padding: 4 }); + reset({ name: '', prefix: '', padding: 4 }); await fetchSequences(); } catch (err: any) { setError(err.message || t('common.error')); @@ -63,10 +83,23 @@ export function SettingsSequencesPage() { const handleEdit = (seq: Sequence) => { setEditing(seq); - setFormData({ name: seq.name, prefix: seq.prefix, padding: seq.padding }); + reset({ name: seq.name, prefix: seq.prefix, padding: seq.padding }); setShowForm(true); }; + const handleNew = () => { + setEditing(null); + reset({ name: '', prefix: '', padding: 4 }); + setShowForm(true); + }; + + const errorMsg = (key: string | undefined) => { + if (!key) return undefined; + if (key === 'required') return t('validation.required'); + if (key === 'invalidNumber') return t('validation.invalidNumber', 'Invalid number'); + return key; + }; + if (loading) return {t('common.loading')}; return ( @@ -74,7 +107,7 @@ export function SettingsSequencesPage() { {t('sequences.title')} { setEditing(null); setFormData({ name: '', prefix: '', padding: 4 }); setShowForm(true); }} + onClick={handleNew} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700" > {t('sequences.add')} @@ -84,24 +117,27 @@ export function SettingsSequencesPage() { {error && {error}} {showForm && ( - + {t('sequences.name')} - setFormData({ ...formData, name: e.target.value })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> + + {errors.name && {errorMsg(errors.name.message)}} {t('sequences.prefix')} - setFormData({ ...formData, prefix: e.target.value })} placeholder="RE-" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> + + {errors.prefix && {errorMsg(errors.prefix.message)}} {t('sequences.padding')} - setFormData({ ...formData, padding: parseInt(e.target.value) || 4 })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> + + {errors.padding && {errorMsg(errors.padding.message)}} { setShowForm(false); setEditing(null); }} className="px-3 py-1.5 text-sm font-medium text-secondary-700 bg-secondary-100 rounded-lg hover:bg-secondary-200">{t('common.cancel')} - {t('common.save')} + {t('common.save')} )} diff --git a/frontend/src/pages/SettingsTaxes.tsx b/frontend/src/pages/SettingsTaxes.tsx index f62c13d..fc1377a 100644 --- a/frontend/src/pages/SettingsTaxes.tsx +++ b/frontend/src/pages/SettingsTaxes.tsx @@ -1,5 +1,8 @@ import React, { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client'; interface TaxRate { @@ -10,6 +13,16 @@ interface TaxRate { country: string | null; } +// ── Zod Schema ── +const taxSchema = z.object({ + name: z.string().min(1, 'required'), + rate: z.coerce.number().min(0, 'invalidNumber').max(100, 'invalidNumber'), + is_default: z.boolean().default(false), + country: z.string().max(2, 'maxLength').optional().default(''), +}); + +type TaxFormData = z.infer; + export function SettingsTaxesPage() { const { t } = useTranslation(); const [taxes, setTaxes] = useState([]); @@ -17,7 +30,16 @@ export function SettingsTaxesPage() { const [error, setError] = useState(null); const [showForm, setShowForm] = useState(false); const [editing, setEditing] = useState(null); - const [formData, setFormData] = useState({ name: '', rate: 0, is_default: false, country: '' }); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(taxSchema), + defaultValues: { name: '', rate: 0, is_default: false, country: '' }, + }); const fetchTaxes = useCallback(async () => { setLoading(true); @@ -34,10 +56,9 @@ export function SettingsTaxesPage() { React.useEffect(() => { fetchTaxes(); }, [fetchTaxes]); - const handleSave = async (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = async (data: TaxFormData) => { try { - const payload = { ...formData, country: formData.country || null }; + const payload = { ...data, country: data.country || null }; if (editing) { await apiPatch(`/taxes/${editing.id}`, payload); } else { @@ -45,7 +66,7 @@ export function SettingsTaxesPage() { } setShowForm(false); setEditing(null); - setFormData({ name: '', rate: 0, is_default: false, country: '' }); + reset({ name: '', rate: 0, is_default: false, country: '' }); await fetchTaxes(); } catch (err: any) { setError(err.message || t('common.error')); @@ -64,10 +85,24 @@ export function SettingsTaxesPage() { const handleEdit = (tax: TaxRate) => { setEditing(tax); - setFormData({ name: tax.name, rate: tax.rate, is_default: tax.is_default, country: tax.country || '' }); + reset({ name: tax.name, rate: tax.rate, is_default: tax.is_default, country: tax.country || '' }); setShowForm(true); }; + const handleNew = () => { + setEditing(null); + reset({ name: '', rate: 0, is_default: false, country: '' }); + setShowForm(true); + }; + + const errorMsg = (key: string | undefined) => { + if (!key) return undefined; + if (key === 'required') return t('validation.required'); + if (key === 'invalidNumber') return t('validation.invalidNumber', 'Invalid number'); + if (key === 'maxLength') return t('validation.maxLength', { count: 2 }); + return key; + }; + if (loading) return {t('common.loading')}; return ( @@ -75,7 +110,7 @@ export function SettingsTaxesPage() { {t('taxes.title')} { setEditing(null); setFormData({ name: '', rate: 0, is_default: false, country: '' }); setShowForm(true); }} + onClick={handleNew} className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700" > {t('taxes.add')} @@ -85,28 +120,31 @@ export function SettingsTaxesPage() { {error && {error}} {showForm && ( - + {t('taxes.name')} - setFormData({ ...formData, name: e.target.value })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> + + {errors.name && {errorMsg(errors.name.message)}} {t('taxes.rate')} (%) - setFormData({ ...formData, rate: parseFloat(e.target.value) || 0 })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> + + {errors.rate && {errorMsg(errors.rate.message)}} {t('taxes.country')} - setFormData({ ...formData, country: e.target.value })} maxLength={2} placeholder="DE" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> + + {errors.country && {errorMsg(errors.country.message)}} - setFormData({ ...formData, is_default: e.target.checked })} /> + {t('taxes.isDefault')} { setShowForm(false); setEditing(null); }} className="px-3 py-1.5 text-sm font-medium text-secondary-700 bg-secondary-100 rounded-lg hover:bg-secondary-200">{t('common.cancel')} - {t('common.save')} + {t('common.save')} )} diff --git a/frontend/src/pages/SettingsUsers.tsx b/frontend/src/pages/SettingsUsers.tsx index d7ee116..1ae4aa3 100644 --- a/frontend/src/pages/SettingsUsers.tsx +++ b/frontend/src/pages/SettingsUsers.tsx @@ -1,5 +1,8 @@ import React, { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { useUsers, useCreateUser, useUpdateUser, useDeleteUser, useRoles } from '@/api/hooks'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; @@ -21,6 +24,16 @@ const LEGACY_ROLES = [ { value: 'viewer', label: 'Viewer' }, ]; +// ── Zod Schema ── +const inviteSchema = z.object({ + name: z.string().min(1, 'required'), + email: z.string().min(1, 'required').email('invalidEmail'), + password: z.string().min(8, 'passwordTooShort'), + role_id: z.string().optional().default(''), +}); + +type InviteFormData = z.infer; + export function SettingsUsersPage() { const { t } = useTranslation(); const toast = useToast(); @@ -31,13 +44,19 @@ export function SettingsUsersPage() { const deleteUserMutation = useDeleteUser(); const [inviteOpen, setInviteOpen] = useState(false); - const [inviteEmail, setInviteEmail] = useState(''); - const [inviteName, setInviteName] = useState(''); - const [invitePassword, setInvitePassword] = useState(''); - const [inviteRoleId, setInviteRoleId] = useState(''); const [confirmDeactivate, setConfirmDeactivate] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(inviteSchema), + defaultValues: { name: '', email: '', password: '', role_id: '' }, + }); + const users = data?.items ?? []; const customRoles = rolesData?.items ?? []; @@ -69,34 +88,23 @@ export function SettingsUsersPage() { return 'viewer'; }; - const handleInvite = async () => { - if (!inviteEmail.trim() || !inviteName.trim() || !invitePassword.trim()) { - toast.error(t('validation.required')); - return; - } - if (invitePassword.length < 8) { - toast.error(t('auth.passwordTooShort')); - return; - } + const onSubmit = async (data: InviteFormData) => { try { const payload: any = { - email: inviteEmail.trim(), - name: inviteName.trim(), - password: invitePassword, + email: data.email.trim(), + name: data.name.trim(), + password: data.password, is_active: true, }; - if (inviteRoleId.startsWith('role_id:')) { - payload.role_id = inviteRoleId.substring('role_id:'.length); + if (data.role_id.startsWith('role_id:')) { + payload.role_id = data.role_id.substring('role_id:'.length); payload.role = 'viewer'; } else { - payload.role = inviteRoleId || 'viewer'; + payload.role = data.role_id || 'viewer'; } await createUserMutation.mutateAsync(payload); toast.success(t('settings.userInvited')); - setInviteEmail(''); - setInviteName(''); - setInvitePassword(''); - setInviteRoleId(''); + reset({ name: '', email: '', password: '', role_id: '' }); setInviteOpen(false); } catch (err: any) { toast.error(err.message || t('common.error')); @@ -147,6 +155,14 @@ export function SettingsUsersPage() { } }; + const errorMsg = (key: string | undefined) => { + if (!key) return undefined; + if (key === 'required') return t('validation.required'); + if (key === 'invalidEmail') return t('validation.email'); + if (key === 'passwordTooShort') return t('auth.passwordTooShort'); + return key; + }; + if (isLoading) { return ( @@ -164,7 +180,7 @@ export function SettingsUsersPage() { {t('settings.users')} - setInviteOpen(true)} data-testid="invite-user-btn"> + { reset({ name: '', email: '', password: '', role_id: '' }); setInviteOpen(true); }} data-testid="invite-user-btn"> {t('settings.inviteUser')} @@ -228,48 +244,47 @@ export function SettingsUsersPage() { )} setInviteOpen(false)} title={t('settings.inviteUser')}> - + setInviteName(e.target.value)} + {...register('name')} + error={errorMsg(errors.name?.message)} placeholder={t('settings.name')} data-testid="invite-name" /> setInviteEmail(e.target.value)} + {...register('email')} + error={errorMsg(errors.email?.message)} placeholder="neu.mitarbeiter@firma.de" data-testid="invite-email" /> setInvitePassword(e.target.value)} + {...register('password')} + error={errorMsg(errors.password?.message)} placeholder="********" data-testid="invite-password" /> setInviteRoleId(e.target.value)} + {...register('role_id')} /> setInviteOpen(false)}>{t('common.cancel')} {t('settings.inviteUser')} - +
{errorMsg(errors.code.message)}
{errorMsg(errors.symbol.message)}
{errorMsg(errors.name.message)}
{errorMsg(errors.prefix.message)}
{errorMsg(errors.padding.message)}
{errorMsg(errors.rate.message)}
{errorMsg(errors.country.message)}